diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13b20d80cd..8c15cb696a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,32 +21,111 @@ on: # because this workflow is `pull_request` (not `pull_request_target`), # declares `contents: read`, and reads no secrets. # - # Every integration push needs an exact-head aggregate check for promotion - # and release evidence. The `changes` job below keeps expensive work scoped. - # Merge queue candidates are synthetic commits. They must produce the same - # stable `ci` check as pull requests, but their changed-file range comes from - # the payload's immutable base/head SHAs rather than the moving queue ref. - merge_group: - types: [checks_requested] + # `push:` stays pinned to the integration lines: it gates the release path, + # and this trigger already covers review. push: branches: [main, preview, dev] + paths: + - "src/**" + - "bin/**" + - "tests/**" + - "scripts/**" + - "gui/**" + - "assets/**" + - ".gitattributes" + - ".npmignore" + - "package.json" + - "bun.lock" + - "tsconfig.json" + - "README.md" + - "LICENSE" + - ".github/workflows/ci.yml" + - ".github/workflows/release.yml" + - ".github/workflows/enforce-pr-target.yml" + - ".github/workflows/stale-needs-info.yml" workflow_dispatch: + inputs: + lane: + description: "all (default) or macos-control" + type: choice + default: all + options: [all, macos-control] permissions: contents: read # Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46). concurrency: - # Keep identities distinct by event. Merge-queue candidates use their - # immutable synthetic SHA; pull requests share a number so a new commit - # supersedes the obsolete run; pushes share a branch ref; manual dispatches - # get an independent run identity. - group: cross-platform-ci-${{ github.event_name }}-${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && github.run_id || github.run_id }} - # Candidate evidence must finish. New PR commits and pushes supersede stale - # runs, while merge-queue and manually dispatched runs are never cancelled. - cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} + group: cross-platform-ci-${{ github.ref }} + cancel-in-progress: true jobs: + # Which Windows runner this run is allowed to use. + # + # READ THIS BEFORE TREATING IT AS A SECURITY BOUNDARY: it is not one. + # + # On `pull_request` this workflow is loaded from the PR head, so the `case` + # below is owned by the proposed patch exactly like an `if:` guard would be. + # A hostile PR can delete the branch and hardcode the self-hosted labels into + # `$GITHUB_OUTPUT`, and `runs-on` will honour it. That this job runs on + # `ubuntu-latest` changes nothing — the untrusted part is its OUTPUT, not its + # host. `.github/workflows/ci.yml` is in the `changes` job's `ci` filter, so + # such an edit triggers every expensive verification job. + # + # What actually keeps untrusted code off a self-hosted runner lives OUTSIDE + # this file, where a PR cannot reach it: the fork-PR approval policy + # (`all_external_contributors`) and the judgement of whoever clicks approve. + # Runner groups would be the other lever, but they are an organisation + # feature and this repository is user-owned, so the approval policy is the + # only one available here. GitHub's own guidance is to avoid self-hosted + # runners on public repositories for this reason. + # + # So read the routing below as a STABILITY/OPERATIONS control that keeps + # honest pull requests on GitHub-hosted runners and lets trusted branch runs + # avoid the hosted-Windows Bun crashes. It is not the security boundary. + # + # `push` on dev/main/preview requires the push permission, and + # `workflow_dispatch` requires write access, so both carry a trusted author. + # A trusted author is not audited code: merging a contributor PR into `dev` + # fires `push`, and its dependencies and postinstall hooks then run here. + select-windows-runner: + name: select windows runner + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + runner: ${{ steps.pick.outputs.runner }} + label: ${{ steps.pick.outputs.label }} + steps: + - name: Pick runner + id: pick + env: + # Read through env rather than interpolating directly into the script: + # `github.event_name` is a fixed vocabulary, but keeping the habit means + # no future edit here can grow a script-injection sink. + EVENT_NAME: ${{ github.event_name }} + USE_SELF_HOSTED: ${{ vars.OCX_SELF_HOSTED_WINDOWS }} + shell: bash + run: | + set -euo pipefail + trusted=no + case "$EVENT_NAME" in + push|workflow_dispatch) trusted=yes ;; + esac + + # Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch, + # not a security control: a PR that rewrites this script ignores it for + # the same reason it ignores the event check above. Its job is to keep CI + # working when the box is off or busy. Anything other than `1` — + # including unset, the state before a runner exists — falls back to + # windows-latest. + if [ "$trusted" = "yes" ] && [ "${USE_SELF_HOSTED:-}" = "1" ]; then + echo 'runner=["self-hosted","Windows","X64","ocx-home"]' >> "$GITHUB_OUTPUT" + echo 'label=self-hosted (ocx-home)' >> "$GITHUB_OUTPUT" + else + echo 'runner="windows-latest"' >> "$GITHUB_OUTPUT" + echo 'label=windows-latest' >> "$GITHUB_OUTPUT" + fi + # Which areas this push actually touches. # # Deliberately a job-level filter rather than a wider workflow-level `paths:` @@ -64,7 +143,6 @@ jobs: # a failed filter produces empty outputs, which every `== 'true'` condition # below would read as "nothing changed, skip". permissions: - actions: read contents: read pull-requests: read outputs: @@ -72,26 +150,20 @@ jobs: # step. A missing or malformed filter output must fail this job instead # of silently making every expensive job skip. ci: ${{ steps.scope.outputs.ci }} - dependencies: ${{ steps.scope.outputs.dependencies }} - reuse_dependency_audit: ${{ steps.promotion-audit.outputs.reuse }} - gui: ${{ steps.scope.outputs.gui }} - packaging: ${{ steps.scope.outputs.packaging }} - macos: ${{ steps.scope.outputs.macos }} - swift: ${{ steps.scope.outputs.swift }} + gui: ${{ steps.filter.outputs.gui }} + packaging: ${{ steps.filter.outputs.packaging }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - # No job here pushes, so leaving a usable token in .git/config is - # avoidable residue. Matches the other workflows. + # No job here pushes, and the self-hosted box keeps its checkout + # between jobs, so leaving a usable token in .git/config is avoidable + # residue. Matches the convention already used by the other workflows. persist-credentials: false - # The promotion audit verifier needs both parents of the main merge. - fetch-depth: 2 - name: Detect changed areas id: filter - if: github.event_name != 'merge_group' - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: # Without this the action compares against the repository's DEFAULT # branch, which is `main`. A push to `dev` would then be diffed against @@ -99,66 +171,83 @@ jobs: # as "changed" — the scoped jobs would run on nearly every dev push and # the saving would silently not happen while CI stayed green. # - # paths-filter ignores `base` and uses the API file list for a pull - # request. On pushes this preserves the previous ref comparison. + # On `pull_request` the action ignores this and uses the PR's own file + # list. On a branch push it means "compare against the previous commit + # on this branch", which is the intent. base: ${{ github.ref }} - filters: .github/policies/ci-paths.yml - - - name: Detect merge queue changed areas - id: merge-group-filter - if: github.event_name == 'merge_group' - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - with: - # Never infer this range from the moving gh-readonly-queue ref. The - # merge_group webhook supplies the immutable synthetic head and the - # exact base commit against which this candidate was constructed. - base: ${{ github.event.merge_group.base_sha }} - ref: ${{ github.event.merge_group.head_sha }} - filters: .github/policies/ci-paths.yml + filters: | + # Mirrors the push trigger's path allowlist. Pull requests always + # start the workflow so the aggregate check exists, while these + # paths decide whether the expensive test jobs need to run. + ci: + - 'src/**' + - 'bin/**' + - 'tests/**' + - 'scripts/**' + - 'gui/**' + - 'assets/**' + - '.gitattributes' + - '.npmignore' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + - 'README.md' + - 'LICENSE' + - '.github/workflows/ci.yml' + - '.github/workflows/release.yml' + - '.github/workflows/enforce-pr-target.yml' + - '.github/workflows/stale-needs-info.yml' + gui: + - 'gui/**' + # Everything that ends up inside `npm pack`, or that decides what + # does. `src/**` belongs here because package.json ships `src` and + # bin/ocx.mjs executes it: without that entry an ordinary source PR + # would get no Windows verification at all, since the Windows suite + # now runs only at the shipping boundary. + packaging: + - 'package.json' + - 'bun.lock' + - 'src/**' + - 'bin/**' + - 'gui/**' + - 'assets/**' + - '.npmignore' + # `.gitattributes` decides how tracked package inputs are + # materialized on each runner, so an attribute change can put CRLF + # shebangs into the tarball without any source file moving. + - '.gitattributes' + - 'README.md' + - 'LICENSE' + - 'scripts/prepare-package.ts' - name: Assert the scope output is usable id: scope shell: bash env: - EVENT_NAME: ${{ github.event_name }} - NORMAL_CI_SCOPE: ${{ steps.filter.outputs.ci }} - NORMAL_DEPENDENCIES_SCOPE: ${{ steps.filter.outputs.dependencies }} - NORMAL_GUI_SCOPE: ${{ steps.filter.outputs.gui }} - NORMAL_PACKAGING_SCOPE: ${{ steps.filter.outputs.packaging }} - NORMAL_MACOS_SCOPE: ${{ steps.filter.outputs.macos }} - NORMAL_SWIFT_SCOPE: ${{ steps.filter.outputs.swift }} - MERGE_GROUP_CI_SCOPE: ${{ steps.merge-group-filter.outputs.ci }} - MERGE_GROUP_DEPENDENCIES_SCOPE: ${{ steps.merge-group-filter.outputs.dependencies }} - MERGE_GROUP_GUI_SCOPE: ${{ steps.merge-group-filter.outputs.gui }} - MERGE_GROUP_PACKAGING_SCOPE: ${{ steps.merge-group-filter.outputs.packaging }} - MERGE_GROUP_MACOS_SCOPE: ${{ steps.merge-group-filter.outputs.macos }} - MERGE_GROUP_SWIFT_SCOPE: ${{ steps.merge-group-filter.outputs.swift }} + CI_SCOPE: ${{ steps.filter.outputs.ci }} run: | set -euo pipefail - prefix=NORMAL - [ "$EVENT_NAME" = merge_group ] && prefix=MERGE_GROUP - for scope in ci dependencies gui packaging macos swift; do - name="${prefix}_${scope^^}_SCOPE" - value="${!name-}" - case "$value" in - true|false) printf '%s=%s\n' "$scope" "$value" >> "$GITHUB_OUTPUT" ;; - *) printf '::error::%s was %q, expected true or false\n' "$name" "$value"; exit 1 ;; - esac - done - - - name: Verify reusable promotion audit evidence - id: promotion-audit - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - BEFORE_SHA: ${{ github.event.before }} - DEPENDENCIES_CHANGED: ${{ steps.scope.outputs.dependencies }} - GITHUB_TOKEN: ${{ github.token }} - run: node .github/scripts/promotion-audit-reuse.cjs - - # The general suite retains fresh-process containment after direct native - # sharding failed qualification. The lane manifest is authoritative for - # membership; validated Bun 1.4 timings only drive longest-processing-time - # allocation. The batching helper remains the process boundary. + case "$CI_SCOPE" in + true|false) + printf 'ci=%s\n' "$CI_SCOPE" >> "$GITHUB_OUTPUT" + ;; + *) + printf '::error::changes.outputs.ci was %q, expected true or false\n' "$CI_SCOPE" + exit 1 + ;; + esac + + # The suite, split by file across four Linux runners. + # + # `scripts/ci/run-bun-test-batches.sh` mirrors Bun's sorted round-robin shard + # assignment, then runs each shard in small batches so every batch gets a fresh + # Bun process. The helper prints the exact files before each batch and retries + # only a Bun runtime crash once; ordinary test failures are never retried. + # Storage-policy API tests and api-usage are deliberately excluded here and run + # in dedicated jobs below. Bun 1.3.14 can corrupt the Linux isolate/epoll state + # around those Worker-heavy harnesses; keeping them out of the general shards + # prevents one runtime failure from wedging ~150 unrelated files while preserving + # the same coverage in fresh Bun processes. # # Only the suite lives here. Typecheck, lint, build, and the scans run once in # `gates` rather than four times — they are fixed cost, and paying it per shard @@ -166,9 +255,7 @@ jobs: test: name: test ${{ matrix.shard }}/4 needs: changes - if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: ubuntu-latest # A quarter of the suite. A shard that needs longer than this is wedged, not # slow — the old 30-minute ceiling was margin for the Windows leg, which no @@ -182,8 +269,9 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - # No job here pushes, so leaving a usable token in .git/config is - # avoidable residue. Matches the other workflows. + # No job here pushes, and the self-hosted box keeps its checkout + # between jobs, so leaving a usable token in .git/config is avoidable + # residue. Matches the convention already used by the other workflows. persist-credentials: false # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so @@ -222,35 +310,10 @@ jobs: cd gui bun run build - - name: Restore canonical test timings - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: .bun-timings.json - key: ocx-test-timings-dev-${{ github.sha }} - restore-keys: | - ocx-test-timings-dev- - - - name: Validate restored timing data - if: hashFiles('.bun-timings.json') != '' - run: bun scripts/ci/validate-timings.ts .bun-timings.json --discard-invalid - - - name: Test in fresh-process timing-aware batches - shell: bash + - name: Test in fresh-process batches env: TEST_SHARD: ${{ matrix.shard }}/4 - run: | - bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD" - - - name: Upload trusted-dev shard timing report - if: >- - success() && github.event_name == 'push' && github.ref == 'refs/heads/dev' && - hashFiles('.bun-timings.json') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ci-timings-general-${{ matrix.shard }} - path: .bun-timings.json - include-hidden-files: true - if-no-files-found: error + run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD" # Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy # harness. Keep the entire six-file family in one fresh process so a runtime @@ -258,9 +321,7 @@ jobs: storage-policy: name: storage policy needs: changes - if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -285,8 +346,13 @@ jobs: - name: Test storage policy API run: | - mapfile -t dedicated_files < <(bun scripts/ci/test-lanes.ts --lane dedicated-storage) - bun scripts/test.ts --isolate "${dedicated_files[@]}" + bun test --isolate \ + ./tests/storage/api-storage-policy-already-running.test.ts \ + ./tests/storage/api-storage-policy-mutation-busy.test.ts \ + ./tests/storage/api-storage-policy-put-race.test.ts \ + ./tests/storage/api-storage-policy-run.test.ts \ + ./tests/storage/api-storage-policy.test.ts \ + ./tests/storage/api-storage.test.ts # Bun 1.3.14 has shown a Linux isolate wedge around startServer() plus the user # cost overlay reconciler. Keep api-usage in one fresh process so a runtime @@ -294,9 +360,7 @@ jobs: api-usage: name: api usage needs: changes - if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -320,86 +384,7 @@ jobs: bun run build - name: Test api usage API - run: | - mapfile -t dedicated_files < <(bun scripts/ci/test-lanes.ts --lane dedicated-api) - bun scripts/test.ts --isolate "${dedicated_files[@]}" - - serial-load-sensitive: - name: serial load-sensitive tests - needs: changes - if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - needs.changes.outputs.ci == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - persist-credentials: false - fetch-tags: true - - - name: Setup project Bun - uses: ./.github/actions/setup-project-bun - - - name: Install dependencies - run: | - bun install --frozen-lockfile - cd gui - bun install --frozen-lockfile - - - name: Build GUI - run: | - cd gui - bun run build - - - name: Test serial lane - shell: bash - run: | - set -euo pipefail - mapfile -t serial_files < <(bun scripts/ci/test-lanes.ts --lane serial) - bun scripts/test.ts --isolate --parallel=1 "${serial_files[@]}" - - # Only trusted dev pushes may turn the four partial reports into the one - # canonical timing cache. PRs and release branches can restore that cache, - # but never publish data under a key they could later restore. - publish-test-timings: - name: publish test timings - needs: test - if: >- - needs.test.result == 'success' && github.event_name == 'push' && - github.ref == 'refs/heads/dev' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - persist-credentials: false - - - name: Setup project Bun - uses: ./.github/actions/setup-project-bun - - - name: Download trusted-dev shard timing reports - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: ci-timings-general-* - path: .tmp/ci-timings - - - name: Merge and validate timing reports - shell: bash - run: | - set -euo pipefail - mapfile -t timing_files < <(find .tmp/ci-timings -type f -name .bun-timings.json -print) - test "${#timing_files[@]}" -eq 4 - bun scripts/ci/merge-timings.ts "${timing_files[@]}" - bun scripts/ci/validate-timings.ts .bun-timings.json - - - name: Save canonical dev timing cache - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: .bun-timings.json - key: ocx-test-timings-dev-${{ github.sha }} + run: bun test --isolate ./tests/server/api-usage.test.ts # Everything that is not the suite: type safety, privacy, lint, build, smoke. # One runner, once per push. Splitting these across the shards would repeat a @@ -407,17 +392,16 @@ jobs: gates: name: gates needs: changes - if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - # No job here pushes, so leaving a usable token in .git/config is - # avoidable residue. Matches the other workflows. + # No job here pushes, and the self-hosted box keeps its checkout + # between jobs, so leaving a usable token in .git/config is avoidable + # residue. Matches the convention already used by the other workflows. persist-credentials: false - name: Setup project Bun @@ -429,49 +413,6 @@ jobs: cd gui bun install --frozen-lockfile - - name: Dependency audit (high severity) - id: dependency-audit - if: needs.changes.outputs.dependencies == 'true' && needs.changes.outputs.reuse_dependency_audit != 'true' - run: bun run audit:high - - - name: Create dependency audit proof - id: dependency-audit-proof - if: github.event_name == 'pull_request' && steps.dependency-audit.outcome == 'success' - shell: bash - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]] - [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] - [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] - audited_tree="$(git rev-parse 'HEAD^{tree}')" - proof_name="dependency-audit-pr-${PR_NUMBER}-base-${BASE_SHA}-head-${HEAD_SHA}-tree-${audited_tree}" - mkdir -p .tmp/dependency-audit-proof - printf '%s\n' "$proof_name" > .tmp/dependency-audit-proof/evidence.txt - printf 'name=%s\n' "$proof_name" >> "$GITHUB_OUTPUT" - - - name: Publish dependency audit proof - if: steps.dependency-audit-proof.outcome == 'success' - # Artifact availability affects only later reuse. The audit itself has - # already passed, so an artifact outage must withhold reuse, not turn - # this PR red; main then falls back to its own live fail-closed audit. - continue-on-error: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ steps.dependency-audit-proof.outputs.name }} - path: .tmp/dependency-audit-proof/evidence.txt - if-no-files-found: error - retention-days: 1 - - - name: Validate GitHub Actions workflows - run: bun run lint:workflows - - - name: Validate repository workflow policy - run: bun scripts/ci/check-workflow-policy.ts - - name: GUI lint if: needs.changes.outputs.gui == 'true' run: | @@ -483,15 +424,6 @@ jobs: bun x tsc --noEmit bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json - - name: Install replit-gateway companion - run: cd integrations/replit-gateway && bun install --frozen-lockfile - - - name: Replit gateway typecheck - run: cd integrations/replit-gateway && bun run typecheck - - - name: Replit gateway tests - run: cd integrations/replit-gateway && bun run test - - name: GUI tests run: cd gui && bun test --isolate tests @@ -513,20 +445,183 @@ jobs: cd gui bun run build + - name: Record dashboard preview source + if: needs.changes.outputs.gui == 'true' + run: | + git rev-parse HEAD > gui/dist/build-commit.txt + git rev-parse HEAD:gui > gui/dist/build-gui-tree.txt + + - name: Upload dashboard preview + if: needs.changes.outputs.gui == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dashboard-preview-${{ github.sha }} + path: gui/dist + retention-days: 7 + if-no-files-found: error + - name: CLI help smoke run: bun run src/cli/index.ts help - # macOS is focused on dev pushes and relevant pull requests, while main, - # preview, and dispatch retain the full unsharded control. This keeps Darwin - # process coverage on the fast path without dropping the release control. platform-macos: - name: macos + name: macos ${{ matrix.shard }}/2 needs: changes - if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - (github.event_name == 'pull_request' && github.base_ref == 'main') || - (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/main') || - needs.changes.outputs.macos == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: macos-latest + # Two shards. Unsharded, this job was the critical path on every green dev + # push (mean 14.9 min against a 4.7 min Linux maximum; devlog + # 260905_test_modularization_and_windows/003). Two halves finish in ~7.7 and + # cost 0.6 extra macOS minutes of setup per run. The whole-pool control that + # the single job used to provide lives in macos-control below, on dispatch. + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + # No job here pushes, and the self-hosted box keeps its checkout + # between jobs, so leaving a usable token in .git/config is avoidable + # residue. Matches the convention already used by the other workflows. + persist-credentials: false + # tests/ci-workflows/release-version-line.test.ts compares package.json against the + # newest release tag. actions/checkout fetches no tags by default, so + # without this the check reads an empty tag set and passes on anything - + # the exact regression it exists to catch would ride through CI green. + # + # Tags only, not full history: `fetch-depth: 0` would clone every commit to + # answer a question about refs. A shallow fetch still brings each tag and its + # target commit, which is all the check reads - the tag list, and whether the + # newest tag names HEAD. That second read only happens on a release commit, + # where the tag points at HEAD and the commit is present by definition. + fetch-tags: true + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd gui + bun install --frozen-lockfile + + # Same reason as the shards: the suite serves gui/dist and reads it back. + - name: Build GUI + run: | + cd gui + bun run build + + # Bun 1.3.14 segfaults while reclaiming a Worker at an `--isolate` file + # boundary: the header shows BALANCED `workers_spawned(N) + # workers_terminated(N)` and the process dies with exit 133 after the last + # assertion in the file already passed. It landed on + # `storage-worker-lifecycle` and `server-background-lifecycle` — the two + # files that tear down a still-busy policy Worker — and accounted for most + # of this leg's red runs on `dev` while every failing SHA passed on rerun. + # + # This is a runtime crash, not a test result, and the Linux shards already + # retry exactly this class through `scripts/ci/run-bun-test-batches.sh` + # (`is_bun_runtime_crash`). The unsharded macOS control had no equivalent, + # so the same crash that Linux absorbs failed the whole promotion here. + # + # Keep the signature list in sync with `is_bun_runtime_crash` in + # scripts/ci/run-bun-test-batches.sh. An assertion failure still fails on + # the first attempt — only the crash signature is retried, exactly once. + - name: Test + env: + MACOS_TEST_SHARD: ${{ matrix.shard }} + run: | + # GitHub Actions starts bash `run:` blocks with `-e`. Disable + # errexit so a Bun crash reaches PIPESTATUS and the bounded retry. + set +e + set -uo pipefail + + run_macos_suite() { + local suite_log suite_status attempt + suite_log="$(mktemp -t ocx-macos-suite.XXXXXX)" || return $? + for attempt in 1 2; do + # Preserve the existing per-test ceiling and crash-only retry for + # every invocation, including each isolated serial file. + bun test --isolate --timeout 60000 "$@" 2>&1 | tee "$suite_log" + suite_status="${PIPESTATUS[0]}" + if [ "$suite_status" -eq 0 ]; then + rm -f "$suite_log" + return 0 + fi + if ! grep -Eqi 'oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' "$suite_log"; then + echo "::error::macOS suite failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried." + rm -f "$suite_log" + return "$suite_status" + fi + echo "::warning::Bun runtime crash in the macOS suite (exit ${suite_status}, attempt ${attempt})." + done + echo "::error::Bun runtime crash repeated on the macOS suite; failing after one retry." + rm -f "$suite_log" + return "$suite_status" + } + + case "$MACOS_TEST_SHARD" in + 1|2) ;; + *) echo "::error::Invalid macOS test shard"; exit 64 ;; + esac + serial_manifest="$(bun -e 'import { SERIAL_FULL_SUITE_FILES } from "./scripts/test.ts"; console.log(SERIAL_FULL_SUITE_FILES.join("\n"));')" + manifest_status=$? + if [ "$manifest_status" -ne 0 ]; then + exit "$manifest_status" + fi + serial_files=() + ignore_args=() + serial_count=0 + while IFS= read -r file; do + if [[ ! "$file" =~ ^[[:alnum:]_./-]+$ || "$file" == /* || "/$file/" == *"/../"* || "/$file/" == *"/./"* ]]; then + echo "::error::Invalid serial test path" + exit 1 + fi + for ((index=0; index- - needs.changes.outputs.swift == 'true' && - (github.event_name == 'pull_request' || github.event_name == 'merge_group' || - (github.event_name == 'push' && github.ref == 'refs/heads/dev')) - run: bash scripts/ci/run-bun-with-crash-retry.sh -- bun scripts/test.ts --isolate --timeout 60000 tests/adapters/google/aistudio-native-webkit.test.ts - - - name: Focused Darwin/process lifecycle tests - if: >- - github.event_name == 'pull_request' || github.event_name == 'merge_group' || - (github.event_name == 'push' && github.ref == 'refs/heads/dev') + # Bun 1.3.14 segfaults while reclaiming a Worker at an `--isolate` file + # boundary: the header shows BALANCED `workers_spawned(N) + # workers_terminated(N)` and the process dies with exit 133 after the last + # assertion in the file already passed. It landed on + # `storage-worker-lifecycle` and `server-background-lifecycle` — the two + # files that tear down a still-busy policy Worker — and accounted for most + # of this leg's red runs on `dev` while every failing SHA passed on rerun. + # + # This is a runtime crash, not a test result, and the Linux shards already + # retry exactly this class through `scripts/ci/run-bun-test-batches.sh` + # (`is_bun_runtime_crash`). The unsharded macOS control had no equivalent, + # so the same crash that Linux absorbs failed the whole promotion here. + # + # Keep the signature list in sync with `is_bun_runtime_crash` in + # scripts/ci/run-bun-test-batches.sh. An assertion failure still fails on + # the first attempt — only the crash signature is retried, exactly once. + - name: Test run: | - bash scripts/ci/run-bun-with-crash-retry.sh -- bun scripts/test.ts --isolate --timeout 60000 \ - --timings .bun-timings.json --update-timings \ - tests/codex-integration/codex-app-server-processes.test.ts \ - tests/codex-integration/codex-prompt-text-probe.test.ts \ - tests/providers/cursor/cursor-native-exec-shell.test.ts \ - tests/lib/process-control.test.ts \ - tests/service/process-state.test.ts \ - tests/service/service.test.ts \ - tests/storage/storage-worker-lifecycle.test.ts \ - tests/storage/storage-worker-os-join-settle.test.ts \ - tests/storage/storage-worker-teardown-isolate.test.ts \ - tests/ci-workflows/test-runner.test.ts - - - name: Full macOS suite - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && github.base_ref == 'main') || - (github.event_name == 'merge_group' && - github.event.merge_group.base_ref == 'refs/heads/main') || - (github.event_name == 'push' && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/preview')) - run: bash scripts/ci/run-bun-with-crash-retry.sh -- bun scripts/test.ts --timeout 60000 + # GitHub Actions starts bash `run:` blocks with `-e`. Disable + # errexit so a Bun crash reaches PIPESTATUS and the bounded retry. + set +e + set -uo pipefail + suite_log="$(mktemp -t ocx-macos-suite.XXXXXX)" + for attempt in 1 2; do + # --timeout: Bun's default 5s per-test ceiling is the recurring flake + # class on this loaded shared runner (real retry windows + server + # round-trips exceed 5s under contention; a 10s-floor in-test + # watchdog fired at 10.16s there). 60s keeps hangs bounded (the 30m + # job timeout is the outer backstop) while removing the timing + # flakes — assertions are untouched. Pairs with the 30s CI floor in + # tests/helpers/ci-watchdog.ts. + bun test --isolate --timeout 60000 tests 2>&1 | tee "$suite_log" + suite_status="${PIPESTATUS[0]}" + if [ "$suite_status" -eq 0 ]; then + exit 0 + fi + if ! grep -Eqi 'oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' "$suite_log"; then + echo "::error::macOS suite failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried." + exit "$suite_status" + fi + echo "::warning::Bun runtime crash in the macOS suite (exit ${suite_status}, attempt ${attempt})." + done + echo "::error::Bun runtime crash repeated on the macOS suite; failing after one retry." + exit 1 - name: CLI help smoke run: bun run src/cli/index.ts help - # Windows is a required shipping-platform gate on integration pushes and on - # pull requests that touch CI-relevant code. Documentation-only pull requests - # may skip it through the same `changes` decision used by the Linux shards. + # Windows runs only when a maintainer asks for it by hand. # # It left the PR lane first (16m23s against a 6-minute Linux critical path; # the ceiling was raised twice rather than the gap closed — #711 vs #653, @@ -630,20 +722,17 @@ jobs: # version — gating the release on them blocks shipping fixes to the platforms # that pass, for a platform that has never shipped green. # - # The tracked portability failures are fixed and the four hosted shards are - # green again. Keep the leg sharded and bounded: removing it from ordinary - # pushes is what allowed the Windows-only backlog to accumulate unnoticed. + # The leg stays in the workflow, sharded and dispatchable, so the failure + # list can be burned down without losing the ability to measure progress. + # release.yml gates on a successful push-event run of this workflow, which + # now means Linux + macOS + the gates; Windows re-enters the gate when the + # tracked failures are fixed, not before. platform-windows: name: windows ${{ matrix.shard }}/6 - needs: [changes] + needs: select-windows-runner if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - needs.changes.outputs.ci == 'true' - # Public pull-request code must never reach a persistent self-hosted host. - # Keep the complete Windows lane on an ephemeral GitHub-hosted runner for - # every event, including trusted pushes, so event-routing drift cannot turn - # a repository workflow edit into host access. - runs-on: windows-latest + github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') + runs-on: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }} # Sharded like the Linux legs. The single-leg run reached 30 minutes on a # green suite and was killed in cleanup; four shards put each leg inside the # same budget the Linux shards already hold. @@ -656,20 +745,50 @@ jobs: # margin. 25 leaves the outer bound in place — a wedged shard still dies — # while making a completed shard the normal outcome. The crash-retry below can # double a shard's work, and this ceiling has to cover that second attempt too. - # Four shards later grew back into that ceiling as the suite expanded. Six - # shards reduce each hosted-Windows process's filesystem, Worker, and child - # process pressure without raising the ceiling or weakening assertions. - timeout-minutes: 25 + # + # Four shards then grew into the ceiling: across five runs of one branch, completed + # shards took 17-25 minutes and run 33934756997 cancelled a green 3/4 at 25m12s — + # the same truncation as above. The bound is kept; the work per shard is cut instead. + # Six shards put each leg at roughly two-thirds of the four-shard wall time, back + # inside the margin 25 was chosen to provide. + # Shard 1 of run 34036848646 then reached that wall with 2736 passing tests + # and no test failures. The matched tests were 25% slower than the prior + # complete run; about one minute of tests remained. Keep every test deadline + # and all six shards, but leave the whole batch and cleanup a 30-minute bound. + timeout-minutes: 30 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6] steps: + - name: Show selected runner + shell: bash + run: echo "windows leg on ${{ needs.select-windows-runner.outputs.label }}" + + # A self-hosted runner keeps its working directory between jobs. Without an + # explicit wipe, a file deleted in the commit under test survives on disk + # and the suite passes against a tree that no longer exists in git. + # `--ephemeral` registration de-registers the runner after each job but does + # not clean the workspace, so this step is what makes the checkout honest. + - name: Clean workspace (self-hosted only) + if: runner.environment == 'self-hosted' + shell: bash + # `|| true` used to swallow this, which defeats the point: a clean that + # fails on permissions leaves the deleted files in place and the checkout + # below then validates a tree that no longer exists in git. Only the + # not-a-repository case is tolerated — that is the first run on a fresh + # box, where there is nothing to clean. + run: | + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git clean -xffd . + fi + - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - # No job here pushes, so leaving a usable token in .git/config is - # avoidable residue. Matches the other workflows. + # No job here pushes, and the self-hosted box keeps its checkout + # between jobs, so leaving a usable token in .git/config is avoidable + # residue. Matches the convention already used by the other workflows. persist-credentials: false # Same reason as the Linux shards and the macOS control: this leg runs the # whole suite, and tests/ci-workflows/release-version-line.test.ts reads release tags. @@ -691,24 +810,6 @@ jobs: cd gui bun run build - - name: Restore canonical test timings - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: .bun-timings.json - key: ocx-test-timings-dev-${{ github.sha }} - restore-keys: | - ocx-test-timings-dev- - - - name: Validate restored timing data - if: hashFiles('.bun-timings.json') != '' - run: bun scripts/ci/validate-timings.ts .bun-timings.json --discard-invalid - - # Validate a fresh Windows Bun/CLI process before the process-heavy shard. - # A hosted VM can otherwise finish every test and then fail the loader with - # STATUS_DLL_INIT_FAILED before the CLI executes at all. - - name: CLI help smoke - run: bun run src/cli/index.ts help - - name: Test # --timeout: the Linux batches and the macOS control both pass 60000; this leg was # the only one left on Bun's 5s default, and it is the slowest hardware on the board. @@ -720,68 +821,14 @@ jobs: # on it reports a defect this repository does not have (#2152). An ordinary assertion # failure returns its status immediately — only the crash signatures below are retried, # and only once, so a genuinely broken build cannot be retried into green. - # - # Every shard owns one ephemeral GitHub-hosted VM and this step invokes its - # test runners sequentially. There is no peer process to queue behind, so - # resolving and ACL-hardening the desktop-grade per-user lock adds no safety. - # More importantly, that resolution starts PowerShell before any test can - # run; under hosted contention the bounded SID lookup has timed out and - # discarded an otherwise usable shard. Keep the local/multi-worktree lock - # fail-closed, while explicitly using its documented no-queue mode inside - # this isolated single-owner CI boundary. - env: - OCX_TEST_NO_QUEUE: "1" shell: bash run: | set +e set -uo pipefail suite_log="$(mktemp -t ocx-windows-suite.XXXXXX)" - timing_args=() - if [ -f .bun-timings.json ]; then - timing_args=(--timings .bun-timings.json) - fi - general_list="$(mktemp -t ocx-windows-general.XXXXXX)" - serial_list="$(mktemp -t ocx-windows-serial.XXXXXX)" - if ! bun scripts/ci/test-lanes.ts --lane general "${timing_args[@]}" --shard ${{ matrix.shard }}/6 > "$general_list"; then - echo "::error::Windows general lane selection failed." - exit 1 - fi - if ! bun scripts/ci/test-lanes.ts --lane serial "${timing_args[@]}" --shard ${{ matrix.shard }}/6 > "$serial_list"; then - echo "::error::Windows serial lane selection failed." - exit 1 - fi - mapfile -t general_files < "$general_list" - mapfile -t serial_files < "$serial_list" - rm -f "$general_list" "$serial_list" - if [ "${#general_files[@]}" -eq 0 ] || [ "${#serial_files[@]}" -eq 0 ]; then - echo "::error::Windows lane selection returned an empty shard." - exit 1 - fi for attempt in 1 2; do - : > "$suite_log" - bun scripts/test.ts --isolate --timeout 60000 "${general_files[@]}" 2>&1 | tee "$suite_log" + bun test --isolate --timeout 60000 tests --shard=${{ matrix.shard }}/6 2>&1 | tee "$suite_log" suite_status="${PIPESTATUS[0]}" - if [ "$suite_status" -eq 0 ]; then - # Each process-heavy serial file gets a fresh Bun runner. `--isolate` - # resets test globals, but one multi-file Bun process can still retain - # runtime/handle pressure before the next file spawns its own children. - for serial_file in "${serial_files[@]}"; do - serial_timeout=60000 - if [[ "$serial_file" == "tests/codex-integration/codex-composed-acceptance.test.ts" ]]; then - # Case E starts two real lock children. Each resolves its - # effective-token namespace through bounded PowerShell calls, - # so the file's 240s case budget must not be preempted by the - # general 60s per-test ceiling. The extra minute lets the test - # report its own deadline instead of racing Bun's outer kill. - serial_timeout=300000 - fi - bun scripts/test.ts --isolate --parallel=1 --timeout "$serial_timeout" "$serial_file" 2>&1 | tee -a "$suite_log" - suite_status="${PIPESTATUS[0]}" - if [ "$suite_status" -ne 0 ]; then - break - fi - done - fi if [ "$suite_status" -eq 0 ]; then exit 0 fi @@ -794,15 +841,16 @@ jobs: echo "::error::Bun runtime crash repeated on Windows shard ${{ matrix.shard }}/6; failing after one retry." exit 1 + - name: CLI help smoke + run: bun run src/cli/index.ts help + # Keep every OS credential-store check on a disposable GitHub-hosted machine. # A force-cancelled process cannot run its in-process finally cleanup, so no - # keyring matrix leg may use a persistent runner. + # keyring matrix leg may use the persistent self-hosted Windows runner. keyring-smoke: name: keyring ${{ matrix.name }} needs: changes - if: >- - (github.event_name != 'pull_request' && github.event_name != 'merge_group') || - needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: ${{ matrix.runner }} timeout-minutes: 8 strategy: @@ -865,19 +913,28 @@ jobs: needs: changes if: needs.changes.outputs.packaging == 'true' runs-on: ${{ matrix.os }} + # 8 minutes was too tight for the Windows leg: dependency installation alone takes + # about 7 there, leaving under a minute for pack, verify, global install, and the + # help smoke. The job was cancelled at the wall rather than failing, so whichever + # step happened to be running was reported `cancelled` — observed on step 8 four + # times and on step 7 once, which is what made it read as a flaky global install + # instead of a budget that one OS cannot meet (#3441). timeout-minutes: 20 strategy: fail-fast: false matrix: - # Keep global-install smoke on disposable hosted machines: it writes - # into the machine's global prefix and must not leave state behind. + # Deliberately NOT routed to the self-hosted box. This job runs + # `npm install -g`, which writes into the machine's global prefix and + # would leave an `ocx` on a maintainer's personal PATH. It is an + # short job on Linux and macOS, so there is nothing to win by moving it. os: [ubuntu-latest, windows-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - # No job here pushes, so leaving a usable token in .git/config is - # avoidable residue. Matches the other workflows. + # No job here pushes, and the self-hosted box keeps its checkout + # between jobs, so leaving a usable token in .git/config is avoidable + # residue. Matches the convention already used by the other workflows. persist-credentials: false # Deliberately NO setup-bun: prove `npm install -g` works without a @@ -885,7 +942,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 22 + node-version: 20 - name: Install package dependencies run: npm install @@ -902,7 +959,7 @@ jobs: - name: Install globally (downloads bundled bun) shell: bash - run: npm install -g "./$(node -p "require('./pack.json')[0].filename")" + run: npm install -g ./bitkyc08-opencodex-*.tgz - name: ocx help via bundled bun run: ocx help @@ -925,8 +982,11 @@ jobs: ci: name: ci if: always() - # Every producer, including the ones that only feed other jobs. - needs: [changes, test, storage-policy, api-usage, serial-load-sensitive, publish-test-timings, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke] + # Every producer, including the ones that only feed other jobs. `needs` holds + # direct dependencies only, so a failing `select-windows-runner` would + # otherwise reach this gate as nothing at all while its dependents report + # `skipped` — which the gate is required to read as a deliberate skip. + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -934,8 +994,6 @@ jobs: shell: bash env: RESULTS: ${{ toJSON(needs) }} - WINDOWS_REQUIRED: ${{ (github.event_name != 'pull_request' && github.event_name != 'merge_group') || needs.changes.outputs.ci == 'true' }} - WINDOWS_RESULT: ${{ needs.platform-windows.result }} run: | set -euo pipefail echo "$RESULTS" | jq . @@ -953,7 +1011,9 @@ jobs: echo "::error::needed job(s) did not pass: $bad" exit 1 fi - if [ "$WINDOWS_REQUIRED" = "true" ] && [ "$WINDOWS_RESULT" != "success" ]; then - echo "::error::Windows was required but reported: $WINDOWS_RESULT" - exit 1 - fi + + # Windows is dispatch-only, so there is no event where a skipped Windows + # leg is a gate violation: on push events it is always skipped, and on + # dispatch a failed Windows leg already fails the allowlist above. The + # old "windows must have run on main/preview" assertion left with the + # condition it policed. diff --git a/AGENTS.md b/AGENTS.md index 7c677f4bfb..ccecf47b04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -363,9 +363,13 @@ keeps the PR a draft. Authors with repository push permission skip the ancestry heuristic only. As with approval requirements in [`MAINTAINERS.md`](./MAINTAINERS.md), the ancestry heuristic is a CI check rather than a branch rule. The branches themselves are protected: `dev`, -`main`, and `preview` each carry an active ruleset requiring a reviewed pull -request and blocking force-pushes and deletion, so a direct push to `dev` is -rejected regardless of `--no-verify`. +`main`, and `preview` each require a pull request and block force-pushes and deletion. +For `dev` only, a current maintainer with GitHub `maintain` or `admin` access may +explicitly integrate through a PR without another maintainer approval, including +their own PR, under the policy in `MAINTAINERS.md`. Record the decision and exact-head +CI evidence; keep outstanding maintainer objections and security review separate. +The bypass is PR-only, so a direct push to `dev` remains rejected regardless of +`--no-verify`. Contributor review and `main`/`preview` rules remain unchanged. [`MAINTAINERS.md`](./MAINTAINERS.md) is authoritative for review and merge policy (approvals, CI requirements, security review, promotion). This file diff --git a/CREDITS.md b/CREDITS.md index 14bd5c84d5..16fa2553d8 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -23,8 +23,8 @@ invalidating every tag and clone — direction: authorship credit in git history is not rewritten. This file is the forward repair. -Every entry cites the maintainer's own words from the closing comment or the -landing commit. Nothing here is inferred from a diff. +Every entry cites the maintainer's own words from the closing comment, pull-request +description, or landing commit. Nothing here is inferred from a diff. This file is **not** a contributor list. Most contributions merged normally, with authorship intact, and need no entry. Absence from this page means the @@ -57,6 +57,104 @@ Code, design, or tests from these pull requests shipped. | [#3300](https://github.com/lidge-jun/opencodex/pull/3300) | [@S0RYUASUKA](https://github.com/S0RYUASUKA) | `15b43e51c` | the same two test files made hermetic | | [#3284](https://github.com/lidge-jun/opencodex/pull/3284) | [@mdwsk88](https://github.com/mdwsk88) | `3d3c4fe26` | "Core implementation is already on `dev` via #3286 (`3d3c4fe26`), including the suffix wire ladder, picker collapse, Google adapter coverage" | +### 2026-09-07 follow-up: missing or malformed trailers + +These additional landings are present in the audited 3,000-commit window. +The linked landing descriptions or commit messages identify what was taken. + +| Pull request | Author | Landed as | What landed | +| --- | --- | --- | --- | +| [#1748](https://github.com/lidge-jun/opencodex/pull/1748) | [@Blushyes](https://github.com/Blushyes) | [`e3bbf5321`](https://github.com/lidge-jun/opencodex/commit/e3bbf5321c6c0483e9662466e044545bb0e086ba) | [Scoped reimplementation of the outbound Fake-IP discovery fix; the source PR author is @Blushyes, correcting the name in the landing message.](https://github.com/lidge-jun/opencodex/commit/e3bbf5321c6c0483e9662466e044545bb0e086ba) | +| [#1842](https://github.com/lidge-jun/opencodex/pull/1842) | [@luvs01](https://github.com/luvs01) | [`e1e431332`](https://github.com/lidge-jun/opencodex/commit/e1e43133281cba5f952dfa3226a4d55d505365b5) | [Public OAuth error projection and preservation of typed authentication failures.](https://github.com/lidge-jun/opencodex/pull/2043) | +| [#1889](https://github.com/lidge-jun/opencodex/pull/1889) | [@dbc-hbin](https://github.com/dbc-hbin) | [`ea16f8613`](https://github.com/lidge-jun/opencodex/commit/ea16f86130291042486ba3c10640e73b63772d27) | [The remaining x-goog-api-client removal and the contributor's header assertions.](https://github.com/lidge-jun/opencodex/pull/2018) | +| [#1896](https://github.com/lidge-jun/opencodex/pull/1896) | [@luvyoun0224-beep](https://github.com/luvyoun0224-beep) | [`5f2b93979`](https://github.com/lidge-jun/opencodex/commit/5f2b93979e4eae78e1a8c66d1f4a324c4f394084) | [The functions-namespace parser flattening; the hardcoded-name guidance was not taken.](https://github.com/lidge-jun/opencodex/pull/2020) | +| [#1899](https://github.com/lidge-jun/opencodex/pull/1899) | [@ntdatt812](https://github.com/ntdatt812) | [`fb5ceee35`](https://github.com/lidge-jun/opencodex/commit/fb5ceee35f18925a118615b3eb69dc0093f27730) | [The catalog-writer temp-path binding, extended with ordered write/harden/publish assertions.](https://github.com/lidge-jun/opencodex/pull/1923) | +| [#1920](https://github.com/lidge-jun/opencodex/pull/1920) | [@Yuxin-Qiao](https://github.com/Yuxin-Qiao) | [`34b167367`](https://github.com/lidge-jun/opencodex/commit/34b167367c09a3a2445261b845ed10a3c0664693) | [Scoped native and replay Computer Use tool-result normalization.](https://github.com/lidge-jun/opencodex/pull/2038) | +| [#1932](https://github.com/lidge-jun/opencodex/pull/1932) | [@harryzhou2000](https://github.com/harryzhou2000) | [`f2b507f83`](https://github.com/lidge-jun/opencodex/commit/f2b507f831e8065d4fac2fd9fe44bf8106b3fa0e) | [The transient bare-401 concept and test scaffolding; the literal trailer "PR #1932" identifies no account.](https://github.com/lidge-jun/opencodex/pull/2021) | +| [#2027](https://github.com/lidge-jun/opencodex/pull/2027) | [@yzxcj797](https://github.com/yzxcj797) | [`5445ce3e6`](https://github.com/lidge-jun/opencodex/commit/5445ce3e626849855521e5ff54571a1c3babc7e2), [`293494cfa`](https://github.com/lidge-jun/opencodex/commit/293494cfa0c89542320a14bb3af6e4a3f31935a2) | ["Absorbed from #2027" — OpenCode Go identity follows its destination.](https://github.com/lidge-jun/opencodex/commit/5445ce3e626849855521e5ff54571a1c3babc7e2) | +| [#2040](https://github.com/lidge-jun/opencodex/pull/2040) | [@Ingwannu](https://github.com/Ingwannu) | [`16345ab8b`](https://github.com/lidge-jun/opencodex/commit/16345ab8b0a9e5d6fdc8a237786339b674fc374c) | ["implementation and tests, with two corrections".](https://github.com/lidge-jun/opencodex/commit/16345ab8b0a9e5d6fdc8a237786339b674fc374c) | +| [#2053](https://github.com/lidge-jun/opencodex/pull/2053) | [@Ingwannu](https://github.com/Ingwannu) | [`f4ad13922`](https://github.com/lidge-jun/opencodex/commit/f4ad1392271370e1c7c08b0b11ddea346d065138) | ["Carries @Ingwannu's #2053 unchanged."](https://github.com/lidge-jun/opencodex/commit/f4ad1392271370e1c7c08b0b11ddea346d065138) | +| [#2056](https://github.com/lidge-jun/opencodex/pull/2056) | [@Ingwannu](https://github.com/Ingwannu) | [`9b0c5a02d`](https://github.com/lidge-jun/opencodex/commit/9b0c5a02d95220161fc18c72aba5756fdcbf68e3) | [The shortPercent known-quota and scoring changes.](https://github.com/lidge-jun/opencodex/commit/9b0c5a02d95220161fc18c72aba5756fdcbf68e3) | +| [#2075](https://github.com/lidge-jun/opencodex/pull/2075) | [@olddonkey](https://github.com/olddonkey) | [`647b98eb8`](https://github.com/lidge-jun/opencodex/commit/647b98eb8ae4d4ca3c16e1d515dc17a97e5993e4) | [The native Chat fast-capability gate, reconciled with the exact-ID contract.](https://github.com/lidge-jun/opencodex/commit/647b98eb8ae4d4ca3c16e1d515dc17a97e5993e4) | +| [#2077](https://github.com/lidge-jun/opencodex/pull/2077) | [@ntdatt812](https://github.com/ntdatt812) | [`f9c224b70`](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | ["Both patches are @ntdatt812's work from #2100 and #2077, applied unchanged."](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | +| [#2082](https://github.com/lidge-jun/opencodex/pull/2082) | [@yzxcj797](https://github.com/yzxcj797) | [`06cdbc109`](https://github.com/lidge-jun/opencodex/commit/06cdbc109a631203618a2d1bcf2f395f121af8fa), [`057e8575c`](https://github.com/lidge-jun/opencodex/commit/057e8575c3138f4e90d31e23feccbad60511a2fe) | [AgentRouter opening-turn framing, with exact hostname and separate-block corrections.](https://github.com/lidge-jun/opencodex/commit/06cdbc109a631203618a2d1bcf2f395f121af8fa) | +| [#2099](https://github.com/lidge-jun/opencodex/pull/2099) | [@yzxcj797](https://github.com/yzxcj797) | [`81492fd10`](https://github.com/lidge-jun/opencodex/commit/81492fd10f428d7d638550de4ba2ebcdf13d7715) | [The repro-shaped prompt-cache-retention test fixture; the runtime contract came from #2102.](https://github.com/lidge-jun/opencodex/pull/2138) | +| [#2100](https://github.com/lidge-jun/opencodex/pull/2100) | [@ntdatt812](https://github.com/ntdatt812) | [`f9c224b70`](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | ["Both patches are @ntdatt812's work from #2100 and #2077, applied unchanged."](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | +| [#2101](https://github.com/lidge-jun/opencodex/pull/2101) | [@Ingwannu](https://github.com/Ingwannu) | [`0bce9516d`](https://github.com/lidge-jun/opencodex/commit/0bce9516d8d987d5b209d1c92cce24d278941b55) | [The entitlement module and full test suite, with maintainer corrections.](https://github.com/lidge-jun/opencodex/pull/2146) | +| [#2102](https://github.com/lidge-jun/opencodex/pull/2102) | [@lilinxiong](https://github.com/lilinxiong) | [`5904178c3`](https://github.com/lidge-jun/opencodex/commit/5904178c349c555704b5f461ef38a47a47324074) | [The prompt-cache-retention implementation, narrowed to the exact native model family.](https://github.com/lidge-jun/opencodex/pull/2138) | +| [#2104](https://github.com/lidge-jun/opencodex/pull/2104) | [@olddonkey](https://github.com/olddonkey) | [`7cd270dc2`](https://github.com/lidge-jun/opencodex/commit/7cd270dc2cd4f5a4cb07b2f23b4eb0dfe869b0da) | ["Carries @olddonkey's #2104 unchanged."](https://github.com/lidge-jun/opencodex/commit/7cd270dc2cd4f5a4cb07b2f23b4eb0dfe869b0da) | +| [#2105](https://github.com/lidge-jun/opencodex/pull/2105) | [@lilinxiong](https://github.com/lilinxiong) | [`a0635eaa2`](https://github.com/lidge-jun/opencodex/commit/a0635eaa2ec16344e25f77f41916637f9cddc4c3) | ["Carries @lilinxiong's #2105 implementation and tests."](https://github.com/lidge-jun/opencodex/commit/a0635eaa2ec16344e25f77f41916637f9cddc4c3) | +| [#2109](https://github.com/lidge-jun/opencodex/pull/2109) | [@drakonkat](https://github.com/drakonkat) | [`d2493a147`](https://github.com/lidge-jun/opencodex/commit/d2493a147d5286f54be34735a13f5d13f8f19597) | [The shared base-URL override implementation from #2109 and #2110.](https://github.com/lidge-jun/opencodex/pull/2148) | +| [#2110](https://github.com/lidge-jun/opencodex/pull/2110) | [@drakonkat](https://github.com/drakonkat) | [`d2493a147`](https://github.com/lidge-jun/opencodex/commit/d2493a147d5286f54be34735a13f5d13f8f19597) | [The shared base-URL override implementation from #2109 and #2110.](https://github.com/lidge-jun/opencodex/pull/2148) | +| [#2122](https://github.com/lidge-jun/opencodex/pull/2122) | [@chilung-cgu](https://github.com/chilung-cgu) | [`6a6efa928`](https://github.com/lidge-jun/opencodex/commit/6a6efa928a165726c0fd17d893d11c21176dc812) | [Configuration union, schema and migration design for retainModels; #2860 supplied the retention predicate.](https://github.com/lidge-jun/opencodex/pull/3206) | +| [#2127](https://github.com/lidge-jun/opencodex/pull/2127) | [@agentHits](https://github.com/agentHits) | [`1adcfde0c`](https://github.com/lidge-jun/opencodex/commit/1adcfde0cfff10589eefdaa33aae077ffe404bcc) | ["Carries @agentHits's #2127 unchanged."](https://github.com/lidge-jun/opencodex/commit/1adcfde0cfff10589eefdaa33aae077ffe404bcc) | +| [#2131](https://github.com/lidge-jun/opencodex/pull/2131) | [@bet4it](https://github.com/bet4it) | [`8ff77e11e`](https://github.com/lidge-jun/opencodex/commit/8ff77e11ebe7bc6472164d29c89c779986b9469a) | ["Carries @bet4it's #2131 implementation and tests."](https://github.com/lidge-jun/opencodex/commit/8ff77e11ebe7bc6472164d29c89c779986b9469a) | +| [#2155](https://github.com/lidge-jun/opencodex/pull/2155) | [@waw4303](https://github.com/waw4303) | [`64ba54edb`](https://github.com/lidge-jun/opencodex/commit/64ba54edbe5ac90b1127f51d5890e33da183c3f0), [`772d375fe`](https://github.com/lidge-jun/opencodex/commit/772d375fed252f838c41b9315cf88046f64d192a) | [Resolved tool-call padding handling, with per-field provenance and diagnostic corrections.](https://github.com/lidge-jun/opencodex/commit/64ba54edbe5ac90b1127f51d5890e33da183c3f0) | +| [#2227](https://github.com/lidge-jun/opencodex/pull/2227) | [@olddonkey](https://github.com/olddonkey) | [`63d387cae`](https://github.com/lidge-jun/opencodex/commit/63d387cae369e3e72d4d0a7702db9d98e607d785) | [The Grok OAuth registry flip, structure documentation and test conversions.](https://github.com/lidge-jun/opencodex/pull/2255) | +| [#2432](https://github.com/lidge-jun/opencodex/pull/2432) | [@mdwsk88](https://github.com/mdwsk88) | [`850afb2e9`](https://github.com/lidge-jun/opencodex/commit/850afb2e9f84979c87e914b248de482f44b34cd6) | [The omit-sentinel documentation and provider wire comments.](https://github.com/lidge-jun/opencodex/pull/3603) | +| [#2639](https://github.com/lidge-jun/opencodex/pull/2639) | [@bet4it](https://github.com/bet4it) | [`fefeb0501`](https://github.com/lidge-jun/opencodex/commit/fefeb05016d21dc9a3b8afe1b52427e4e1d8a0ed) | [The Responses item status backfill; queued/in_progress mapping was corrected separately.](https://github.com/lidge-jun/opencodex/pull/2721) | +| [#2647](https://github.com/lidge-jun/opencodex/pull/2647) | [@darwintree](https://github.com/darwintree) | [`e1e6ec04f`](https://github.com/lidge-jun/opencodex/commit/e1e6ec04f43a287b4cfb5149893d2c6c0a520588) | [Command Code profile metadata, reconciled with the intervening catalog update.](https://github.com/lidge-jun/opencodex/pull/2721) | +| [#2663](https://github.com/lidge-jun/opencodex/pull/2663) | [@Eleven-is-cool](https://github.com/Eleven-is-cool) | [`cb9bb9b76`](https://github.com/lidge-jun/opencodex/commit/cb9bb9b7634640f18568207322d386a059f6c9ac) | [Bare code-mode helper calls through exec: "the implementation is yours, unchanged".](https://github.com/lidge-jun/opencodex/pull/2724) | +| [#2938](https://github.com/lidge-jun/opencodex/pull/2938) | [@luvs01](https://github.com/luvs01) | [`8427efe6e`](https://github.com/lidge-jun/opencodex/commit/8427efe6e80a5ce9488eab7b80b2b1663ab20579) | [The failed-wrapper diagnosis and linear-scan design, with six behavioral divergences corrected.](https://github.com/lidge-jun/opencodex/pull/2945) | +| [#3069](https://github.com/lidge-jun/opencodex/pull/3069) | [@justin-mc-lai](https://github.com/justin-mc-lai) | [`a0d386b49`](https://github.com/lidge-jun/opencodex/commit/a0d386b49074ec81df5646fcc20a5e4979c67878) | [The two query/queries parity commits, followed by malformed-history boundary fixes.](https://github.com/lidge-jun/opencodex/pull/3089) | +| [#3329](https://github.com/lidge-jun/opencodex/pull/3329) | [@Veritas-7](https://github.com/Veritas-7) | [`3ac310782`](https://github.com/lidge-jun/opencodex/commit/3ac31078244ea04c9abce0e50275ffaccf25455a) | [Combo cooldown/wait design with corrected reset metadata, clocks and Retry-After handling.](https://github.com/lidge-jun/opencodex/pull/3606) | +| [#3407](https://github.com/lidge-jun/opencodex/pull/3407) | [@turin-dev](https://github.com/turin-dev) | [`3b3fe21d4`](https://github.com/lidge-jun/opencodex/commit/3b3fe21d45e57761e9769020da4b37de5cd95726) | [The desired-state Codex switch, observed-state badge and effective-home path.](https://github.com/lidge-jun/opencodex/pull/3617) | +| [#3421](https://github.com/lidge-jun/opencodex/pull/3421) | [@Skyline-23](https://github.com/Skyline-23) | [`89c0a64fe`](https://github.com/lidge-jun/opencodex/commit/89c0a64fe2c59af1814230b0c85d61cd08672bd5) | [The Compose/container foundation, with loopback binding and generated compatibility identity.](https://github.com/lidge-jun/opencodex/pull/3604) | +| [#3469](https://github.com/lidge-jun/opencodex/pull/3469) | [@agentHits](https://github.com/agentHits) | [`c44e187ee`](https://github.com/lidge-jun/opencodex/commit/c44e187ee901275f977f5a2be32c782f4e1f1794) | [Google location-error classification, carried through #3547 and corrected for error precedence.](https://github.com/lidge-jun/opencodex/pull/3608) | +| [#3487](https://github.com/lidge-jun/opencodex/pull/3487) | [@Ingwannu](https://github.com/Ingwannu) | [`f8ba644f3`](https://github.com/lidge-jun/opencodex/commit/f8ba644f3ad650b14af9cc420d4d42782939bfef) | [The bounded Kiro fallback-execution assertion at the migrated test path.](https://github.com/lidge-jun/opencodex/pull/3602) | +| [#3489](https://github.com/lidge-jun/opencodex/pull/3489) | [@Flowershangfromthebranches](https://github.com/Flowershangfromthebranches) | [`55395a9dc`](https://github.com/lidge-jun/opencodex/commit/55395a9dc8a252a01f606b7b65859579e4f2e53d) | [Canonical-final-URL discovery injection alongside independently proxy-bound IPv6 handling.](https://github.com/lidge-jun/opencodex/pull/3618) | +| [#3528](https://github.com/lidge-jun/opencodex/pull/3528) | [@benedictusrey](https://github.com/benedictusrey) | [`bef04efbc`](https://github.com/lidge-jun/opencodex/commit/bef04efbcf506ac26ebd3eeba8ac397a5d8a8d0d) | [The effort CLI command, exact selectors and distinct live/offline failures.](https://github.com/lidge-jun/opencodex/pull/3612) | +| [#3531](https://github.com/lidge-jun/opencodex/pull/3531) | [@benedictusrey](https://github.com/benedictusrey) | [`45045623b`](https://github.com/lidge-jun/opencodex/commit/45045623bfc9c1ec7f8c55e47493da343b98a968) | [The agy alias, with captured discovery authority retained.](https://github.com/lidge-jun/opencodex/pull/3601) | + +### 2026-09-07 follow-up: unlinked trailers + +These commits contain a contributor name, but GitHub's commit-author mapping +does not resolve that trailer to the source PR author. No personal addresses +are reproduced here. The forward correction uses account-linked noreply identities. + +| Pull request | Author | Landed as | What landed | +| --- | --- | --- | --- | +| [#2817](https://github.com/lidge-jun/opencodex/pull/2817) | [@gulup](https://github.com/gulup) | [`6fe46312c`](https://github.com/lidge-jun/opencodex/commit/6fe46312cd509bbef0e79025181e7ab6fc285681) | [The opt-in upstream Responses WebSocket transport and six carried commits.](https://github.com/lidge-jun/opencodex/pull/3216) | +| [#3148](https://github.com/lidge-jun/opencodex/pull/3148) | [@Veritas-7](https://github.com/Veritas-7) | [`865a36ef0`](https://github.com/lidge-jun/opencodex/commit/865a36ef04eb6395e617f94ed87aaa474a903444) | [The two subscription-launch admission-key fixes, plus connected-launch reconciliation.](https://github.com/lidge-jun/opencodex/pull/3182) | +| [#3293](https://github.com/lidge-jun/opencodex/pull/3293) | [@Veritas-7](https://github.com/Veritas-7) | [`3a9c4d297`](https://github.com/lidge-jun/opencodex/commit/3a9c4d297451bc40abb24cf13d5f50648450fc2e) | [The missing claude-fable-5-1 model metadata and accompanying usage-cost test.](https://github.com/lidge-jun/opencodex/pull/3478) | + +The correction commit records these contributors and the earlier **Carried work** +authors as co-authors. This is forward attribution: the old commit objects, +their original dates and release tags are unchanged. + +### 2026-09-07 follow-up: four-track source-to-landing attribution + +At the owner's request, this audit makes the original PR titles, authors and +delivered slices explicit for the four follow-up tracks after #3771. All linked +landing commits are ancestors of `5759d9ea2f1e7281cdc01eb9628f2e0a123fb59c`. +The human contributors already resolve through reachable source commits or merge +trailers; a merge commit with no repeated trailer does not erase its parents' +authorship. This forward record strengthens discoverability without claiming that +every earlier landing omitted credit or rewriting existing commits and tags. + +| Original pull request | Original author | Landed through | Delivered scope | +| --- | --- | --- | --- | +| [#3769: fix(responses): fallback to routed compaction on 404 and enable quota failover on incomplete terminal](https://github.com/lidge-jun/opencodex/pull/3769) | [@ideabib](https://github.com/ideabib) | [#3791](https://github.com/lidge-jun/opencodex/pull/3791) (`fcf07446aa`) | Quota/incomplete attribution only; native compact 404 fallback remains outside this landing. | +| [#3736: fix: preserve compaction progress and use a 600s stall budget](https://github.com/lidge-jun/opencodex/pull/3736) | [@Hylouis233](https://github.com/Hylouis233) | [#3792](https://github.com/lidge-jun/opencodex/pull/3792) (`823ffeb771`) | Content-free buffered-compaction progress; the proposed global 600-second default was not adopted. | +| [#3744: fix(server): opt the compact route out of the request idle timeout](https://github.com/lidge-jun/opencodex/pull/3744) | [@mashfromband](https://github.com/mashfromband) | [#3792](https://github.com/lidge-jun/opencodex/pull/3792) (`823ffeb771`) | Accepted compact-request lifetime, with complete-body admission and bounded response-body inactivity. | +| [#3740: fix(responses): answer a wrapped WebSocket rejection with its HTTP status](https://github.com/lidge-jun/opencodex/pull/3740) | [@FredAmartey](https://github.com/FredAmartey) | [#3793](https://github.com/lidge-jun/opencodex/pull/3793) (`110623ecfc`) | Precommit wrapped WebSocket rejection status; mid-turn failures retain their separate boundary. | +| [#3779: fix(chat): preserve completion semantics in JSON-to-SSE fallback](https://github.com/lidge-jun/opencodex/pull/3779) | [@Ingwannu](https://github.com/Ingwannu) | [#3803](https://github.com/lidge-jun/opencodex/pull/3803) (`ac4a7659fd`) | JSON-to-stream tools, reasoning, usage and finish-reason preservation, extended across both fallback paths. | +| [#3730: feat(claude): gate routed protocol compatibility](https://github.com/lidge-jun/opencodex/pull/3730) | [@yansigit](https://github.com/yansigit) | [#3806](https://github.com/lidge-jun/opencodex/pull/3806) (`4255bfac61`), [#3808](https://github.com/lidge-jun/opencodex/pull/3808) (`5759d9ea2f`) | Opt-in translated Messages compatibility and bounded diagnostics; integrated with the final fixture layer. | +| [#3747: fix(container): persist Codex home separately from OCX state](https://github.com/lidge-jun/opencodex/pull/3747) | [@Ingwannu](https://github.com/Ingwannu) | [#3788](https://github.com/lidge-jun/opencodex/pull/3788) (`ad5285e415`) | Separate persisted Codex home under the read-only container root, with serializer and documentation corrections. | +| [#3324: docs(skill): keep access-key secrets out of agent sessions](https://github.com/lidge-jun/opencodex/pull/3324) | [@luvs01](https://github.com/luvs01) | [#3789](https://github.com/lidge-jun/opencodex/pull/3789) (`26fa36424a`) | Agent-facing secret-bearing command and rotation-recipe restrictions, including aliases and management API spellings. | +| [#3632: feat(config): add exclusive initialize-if-missing primitive](https://github.com/lidge-jun/opencodex/pull/3632) | [@yansigit](https://github.com/yansigit) | [#3796](https://github.com/lidge-jun/opencodex/pull/3796) (`443310e5dc`), [#3802](https://github.com/lidge-jun/opencodex/pull/3802) (`f89b815090`) | Exclusive initial configuration publication and its real setup consumer, with filesystem and cancellation corrections. | +| [#3728: feat(quota): show subscription credits in capacity bars](https://github.com/lidge-jun/opencodex/pull/3728) | [@yansigit](https://github.com/yansigit) | [#3798](https://github.com/lidge-jun/opencodex/pull/3798) (`b72b8ea6c8`) | Subscription-credit quota rows, including duplicate-label and displayed-row urgency handling. | +| [#3250: perf(logs): poll request history incrementally](https://github.com/lidge-jun/opencodex/pull/3250) | [@chilung-cgu](https://github.com/chilung-cgu) | [#3800](https://github.com/lidge-jun/opencodex/pull/3800) (`57211f43d4`) | Incremental request-history polling, extended to preserve changed requests and reset behavior. | +| [#3383: feat(models): add main picker ordering controls](https://github.com/lidge-jun/opencodex/pull/3383) | [@x3M3x](https://github.com/x3M3x) | [#3801](https://github.com/lidge-jun/opencodex/pull/3801) (`8615f1a1c9`) | Picker-order controls and isolated saves; unrelated source-PR Windows changes are not credited as part of this layer. | + +The source commits for @yansigit also credit Yumi. That original automation +attribution remains in the reachable history; this audit does not invent a GitHub +account mapping for its unlinked automation identity. The forward human trailers +use the source authors' verified numeric GitHub account identities. + +A delivered slice is not a statement that every requirement in its original PR +or umbrella issue is complete. The table deliberately retains the unadopted scope. + ## Report and diagnosis These fixes exist because of the report. The branch's own approach was not the @@ -73,6 +171,25 @@ code would misstate what happened in the other direction. | [#3143](https://github.com/lidge-jun/opencodex/pull/3143) | [@Ingwannu](https://github.com/Ingwannu) | `408652698` | "The diagnosis here was yours and it was right" | | [#3223](https://github.com/lidge-jun/opencodex/pull/3223) | [@alex-jordan547](https://github.com/alex-jordan547) | `d23eab43a` | "The report itself was what made the fix quick; the wire capture pointed straight at the cause" | +### Four-track reports and diagnostic evidence + +These issue authors supplied the reports or observations used by the follow-up +work. They are acknowledged as reporters, separately from the carried PR authors. + +| Report | Reporter | Follow-up | Contribution | +| --- | --- | --- | --- | +| [#3778](https://github.com/lidge-jun/opencodex/issues/3778) | [@turin-dev](https://github.com/turin-dev) | [#3786](https://github.com/lidge-jun/opencodex/pull/3786) | Non-atomic cleanup-manifest failure report. | +| [#3746](https://github.com/lidge-jun/opencodex/issues/3746) | [@juzijia](https://github.com/juzijia) | [#3788](https://github.com/lidge-jun/opencodex/pull/3788) | Read-only container Codex-home persistence failure. | +| [#3770](https://github.com/lidge-jun/opencodex/issues/3770) | [@turin-dev](https://github.com/turin-dev) | [#3803](https://github.com/lidge-jun/opencodex/pull/3803) | JSON-to-SSE completion-semantics loss. | +| [#3767](https://github.com/lidge-jun/opencodex/issues/3767) | [@turin-dev](https://github.com/turin-dev) | [#3805](https://github.com/lidge-jun/opencodex/pull/3805) | Refusal loss across Chat projections. | +| [#3775](https://github.com/lidge-jun/opencodex/issues/3775) | [@leonclab](https://github.com/leonclab) | [#3804](https://github.com/lidge-jun/opencodex/pull/3804) | Unsupported effort report; the delivered fix is limited to proven native capability aliases. | +| [#3661](https://github.com/lidge-jun/opencodex/issues/3661) | [@Hu9956](https://github.com/Hu9956) | [#3794](https://github.com/lidge-jun/opencodex/pull/3794) | Encrypted-task recovery failure classes; the landed change exposes bounded reasons. | +| [#3522](https://github.com/lidge-jun/opencodex/issues/3522) | [@stephen-drew](https://github.com/stephen-drew) | [#3790](https://github.com/lidge-jun/opencodex/pull/3790) | Same-process Windows spill failure evidence; the landed change separates timeout origins. | +| [#3781](https://github.com/lidge-jun/opencodex/issues/3781) | [@jaychou0642-create](https://github.com/jaychou0642-create) | [#3799](https://github.com/lidge-jun/opencodex/pull/3799) | Canonical-destination/Fake-IP quota-path investigation; field acceptance remains separate. | + +Diagnostic-only delivery does not establish that the reported runtime failure +has been resolved. + ## Closed as landed, carry not stated Two more were closed with a landing commit and nothing further. The landing is @@ -84,6 +201,19 @@ inaccuracy this file exists to correct. - [#2675](https://github.com/lidge-jun/opencodex/pull/2675) by [@Ingwannu](https://github.com/Ingwannu) — closed "Landed via #2677 at `8412fe156`". +Two further source PRs were closed with a landing reference but without an +explicit statement of what was carried. Their authors are acknowledged here; +they are not counted as carried code solely from that closure: + +- [#2360](https://github.com/lidge-jun/opencodex/pull/2360) by + [@chilung-cgu](https://github.com/chilung-cgu) — + [closed as landed via #2371](https://github.com/lidge-jun/opencodex/pull/2360#issuecomment-5379638729) + at `ae05672e3`. +- [#3621](https://github.com/lidge-jun/opencodex/pull/3621) by + [@yansigit](https://github.com/yansigit) — + [closed as landed via #3622](https://github.com/lidge-jun/opencodex/pull/3621#issuecomment-5549370301) + at `1505cb196`. + ## How this stays accurate This page is a repair, not a process. The process is @@ -127,3 +257,18 @@ The lesson generalizes: when carrying work, take the trailer address from the author's GitHub account (the numeric-id `users.noreply.github.com` form is always safe), not from the commit metadata on their branch. A contributor who commits under a work email is the normal case, not an edge case. + +### Verify the landing, not just the proposal + +The 2026-09-07 audit read exactly 3,000 commits reachable from +`7d8523eed75a67f7a4a15b533744fcd0e6059aa8`, ending at +`53130de4e540fbfcf2629079effb851af54e989e`, and followed source-PR descriptions, +closure comments and GitHub commit-author mappings. Normally merged work, +credited cherry-picks, independent fixes and report-only acknowledgements were +kept distinct from the carried-work tables. + +A PR description or an intermediate branch commit can contain the right trailer +and still lose it when a custom squash message replaces that text. Before +calling a carry credited, inspect the **actual landing commit**: its final +`Co-authored-by` trailer must remain present and resolve to the source author's +GitHub account. The existing presence gate alone does not establish either fact. diff --git a/Dockerfile b/Dockerfile index 382e2db380..1ed000743d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN cd gui && bun install --frozen-lockfile COPY --chown=bun:bun src ./src COPY --chown=bun:bun scripts/model-metadata.source.json ./scripts/model-metadata.source.json -COPY --chown=bun:bun docker/bootstrap-tls.ts docker/bootstrap-token.ts docker/config.json docker/healthcheck.ts docker/verify-compatibility.ts ./docker/ +COPY --chown=bun:bun docker ./docker COPY --chown=bun:bun gui ./gui RUN cd gui && bun run build @@ -27,9 +27,11 @@ WORKDIR /home/bun/app ENV NODE_ENV=production \ OPENCODEX_HOME=/home/bun/.opencodex \ + CODEX_HOME=/home/bun/.codex \ OCX_API_TOKEN_FILE=/home/bun/.opencodex/service-api-token -RUN install -d -m 0700 -o bun -g bun /home/bun/.opencodex +# These homes have incompatible auth.json formats; persist them without combining them. +RUN install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex COPY --chown=bun:bun --chmod=0600 docker/config.json /home/bun/.opencodex/config.json COPY --from=build --chown=bun:bun /home/bun/app/package.json ./package.json @@ -44,13 +46,12 @@ COPY --from=build --chown=bun:bun /home/bun/app/docker ./docker COPY --from=build --chown=bun:bun /home/bun/app/gui/dist ./gui/dist USER bun -RUN ["bun", "docker/verify-compatibility.ts", "--runtime"] +RUN ["bun", "docker/verify-compatibility.ts"] RUN ["bun", "-e", "import { readOpenCodexCompatibilityVersion } from './src/routing/compatibility/version.ts'; if (!/^[0-9a-f]{64}$/.test(readOpenCodexCompatibilityVersion() ?? '')) throw new Error('Missing or invalid generated compatibility manifest');"] -RUN ["/usr/bin/openssl", "version"] -VOLUME ["/home/bun/.opencodex"] +VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"] EXPOSE 10100 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD ["bun", "docker/healthcheck.ts"] + CMD ["bun", "-e", "const r=await fetch('http://127.0.0.1:10100/healthz');if(!r.ok)process.exit(1)"] -CMD ["sh", "-c", "bun run docker/bootstrap-tls.ts && exec bun run src/cli/index.ts start --port 10100"] +CMD ["bun", "run", "src/cli/index.ts", "start", "--port", "10100"] diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 86caac7cb0..4d04764956 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,13 +1,14 @@ # Maintainers -This document lists the people responsible for maintaining the `yansigit/opencodex` fork and -defines its review and merge policy. +This document lists the people responsible for maintaining opencodex and defines the project's +review and merge policy. ## Current maintainers | GitHub account | Project role | Responsibilities | | --- | --- | --- | -| [@yansigit](https://github.com/yansigit) | Fork owner | Fork direction, `dev` integration, releases, repository administration, security review, and final governance decisions | +| [@lidge-jun](https://github.com/lidge-jun) | Project owner | Project direction, releases, repository administration, and final governance decisions | +| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` integration, security review, and repository maintenance | The table describes project responsibilities. Actual repository permissions remain controlled through GitHub repository settings. @@ -15,12 +16,6 @@ through GitHub repository settings. `dev` is the only integration line. The former `dev2-go` carry duty is retired; see [The retired `dev2-go` line](#the-retired-dev2-go-line). -## Upstream contacts - -[@lidge-jun](https://github.com/lidge-jun) and -[@Ingwannu](https://github.com/Ingwannu) maintain the upstream project. Their approval is not -required for changes made only in this fork. - ## Former maintainers | GitHub account | Project role | Period | @@ -56,12 +51,20 @@ when a maintainer steps down. start repository CI; a maintainer has to — so the gate never disproves it; a new push still resets every box. A disproved claim unticks the matching box and keeps the PR a draft. - Authors with repository push permission skip the contributor-readiness - checklist. Branch and quality failures still apply. -- Contributor pull requests require successful required CI checks and exact controller authorization; - generic maintainer approval is not a merge prerequisite. -- The fork owner may merge their own pull requests or push directly. An explicit owner request is - sufficient authorization; no upstream or second-maintainer approval is required. + Authors with repository push permission skip the ancestry heuristic only. As + with the approval requirement above, this part is enforced by convention; + the ruleset does not check ancestry (see the note under the change log). +- Pull requests require successful required CI checks before merge. Contributor pull requests + normally require approval from at least one maintainer other than the author. +- A current maintainer with GitHub `maintain` or `admin` access may explicitly integrate a pull + request into `dev` without another maintainer's approval, including their own pull request. + Record that choice and the exact-head verification in the pull-request description or comment. + This is maintainer integration, not a self-approval or an independent review. Outstanding + maintainer change requests must still be resolved or explicitly withdrawn. Technical review, + attribution, documentation and security-review duties remain in force. +- The maintainer-integration exception applies only to `dev`. It does not change review rules + for `main` or `preview`, grant contributor authors approval authority, or permit direct pushes, + force-pushes or branch deletion. Authors do not submit approving reviews of their own work. - Authentication, credential handling, GitHub Actions, release automation, dependency installation, and other security-boundary changes require explicit security review. - A new or promoted provider preset is a credential-destination change. Before merge it needs the @@ -73,19 +76,34 @@ when a maintainer steps down. with the service is disclosed, not disqualifying, and it does not lower the evidence bar. When the evidence is incomplete, prefer an inert `src/providers/free-directory.ts` reference row over a canonical registry entry. -- Security-sensitive and release-related changes should receive additional review when practical; - upstream approval is not required for fork-only work. -- Required CI and documentation checks apply to owner merges and direct pushes. -- Required merge checks are `ci`, `hygiene`, `enforce-target`, and `mergeable`, bound to the trusted check App where supported. Autonomous sync requires exact published-head provenance and no handoff, protected path, ownership conflict, or agent resolution. Jules controller advances require recorded parents `[previous Jules head, current dev]`; active editing blocks them. Holds older than 24 hours are summarized and never removed automatically. -- Promotion from `dev` to `main` and npm releases is controlled by the fork owner. -- Post-release version advancement has one writer: `promote-dev.yml` verifies the - published tag and exact release SHA, then advances `dev` through the repository - App. `dev-version-bump.yml` remains a dormant fallback and must not become a - second live authority. -- Opening a preview for the next core ends the current patch line. After +- Security-sensitive and release-related changes should be reviewed by both maintainers when + practical. +- Integration uses pull requests, including urgent maintainer repairs. The PR-only ruleset + bypass does not authorize direct pushes; incident changes to branch protection require a + separate owner decision. +- Promotion from `dev` to `main` and npm releases is maintainer-controlled. +- **Opening a release starts by moving `dev`'s version line forward.** Before cutting + a release, `dev` must already outrank the version being released; `release.yml` + asserts this and refuses to publish otherwise. Dispatch + `.github/workflows/dev-version-bump.yml` with the intended version, merge the pull + request it opens, then promote and release. When `dev` already outranks the target + — a preview cut, or a stable hotfix below `dev`'s line — no move is needed and the + workflow reports `changed=false`. + + Opening a preview for the next core ends the current patch line. After `vX.Y.0-preview.*` is tagged, a fix ships as part of `X.Y.0`, not as - `X.(Y-1).(Z+1)`. The release helper refuses that lower stable patch instead - of publishing a version already outranked by the repository's release line. + `X.(Y-1).(Z+1)`. The release helper refuses such a bump rather than producing a + version the repository would reject. This is a deliberate policy restriction, not + a claim that lower stable patches were historically unused. + + Done after the publish, as this repository did for ten releases (`32529c2b2`, + `e4a85d134`, `076ad3036`, `befcac3e1`, then #3045, #3076, #3127, #3265, #3354, + #3434), it leaves `dev` and every open pull request carrying a failure contributors + cannot fix from their own diff. The pull request itself does not go away — `Protect + dev` requires a reviewed merge. If the pre-move is missed and publication somehow + succeeds, dispatch `dev-version-bump.yml` from the default branch with the released + version and `mode=repair`, then merge the repair pull request. Design: + `devlog/_plan/260904_release_version_line/`. ## The retired `dev2-go` line @@ -114,15 +132,24 @@ defects. Bun-native TypeScript on `dev` is the single runtime line again. Adding or removing a maintainer requires: -1. agreement from the fork owner, +1. agreement from the project owner, 2. review by another current maintainer when available, and 3. updates to this file and [`.github/CODEOWNERS`](./.github/CODEOWNERS). ### Change log -- 2026-08-28 — [@yansigit](https://github.com/yansigit) recorded as owner and sole current - maintainer of this fork. Upstream maintainers remain credited as upstream contacts but are not - required approvers for fork-only work. +- 2026-09-06 — The owner authorized explicit maintainer integration into `dev` without a second + maintainer approval. Both current maintainers have `admin` access. The dev-only PR bypass + includes GitHub's `admin` and `maintain` roles; `write` access alone is insufficient. Contributor + review remains the default and the `main`/`preview` rules are unchanged. The optional + `scripts/ci/assert-mergeable-review.sh --maintainer-integration [repo]` path checks + the authenticated actor against the trusted `dev` roster and live repository permissions, + preserves outstanding maintainer objections, and binds its result to the current head and base. + The helper emits a validation snapshot, not a ready-to-run privileged merge command: head + matching does not pin a PR's base, which may change after inspection. Revalidate the current + actor and `dev` base before a separately authorized merge. The helper is not proof of CI or + security review and not a barrier against an administrator bypassing it. Repository settings + remain authoritative for actual permissions. - 2026-08-19 — [@Wibias](https://github.com/Wibias) stepped down as a maintainer and is now a contributor. This follows his own decision to stop developing @@ -171,12 +198,11 @@ Adding or removing a maintainer requires: changes, and it blocks deletion and non-fast-forward pushes. Allowed merge methods are merge and squash; rebase merges are off. - The one carve-out is that the `maintain`/`admin` repository role holds a - `pull_request` bypass, so an owner can merge without the approval the rules - otherwise require. That is a bypass, not an exemption: "Authors do not approve - their own pull requests" above still governs, and an owner who uses the bypass - should record it on the pull request rather than leave it to be inferred from - a merge timestamp. Widening the security boundary is a separate decision. + At that time, the actual PR bypass covered `admin`; the earlier wording that + included `maintain` was inaccurate. The 2026-09-06 policy above adds the explicit + maintainer-integration exception for `dev` and the corresponding `maintain` role. + Both roles bypass through pull requests only. Force-push and deletion protections + remain in place, and the integrating maintainer records the decision and evidence. ## Security reports diff --git a/README.md b/README.md index 44a618df2b..61b93b8240 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ Two commands, and every one of them runs any LLM you point it at.

Follow @claudeebum on X - npm version - license - node version + npm version + license + node version

```bash -npm install -g @yansigit/opencodex -ocx start # proxy + dashboard on localhost:10100 +npm install -g @bitkyc08/opencodex +ocx start ``` @@ -78,53 +78,61 @@ account while existing threads stay pinned to the account that started them. ## Quick start -### For humans +### Personal install ```bash -npm install -g @yansigit/opencodex # Node 22+; the Bun runtime is bundled automatically -ocx start # or `ocx service` to run it in the background +npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically +ocx start # proxy + dashboard on localhost:10100 ``` -### Docker Compose +Use `ocx service` to run it in the background. + +Open **http://localhost:10100** and configure everything in the web dashboard — add providers +(40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` +re-opens the dashboard at any time. +It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, +refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use +the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex +threads normally retain affinity to the account that started them, so long SSH, tmux, or +mobile-connected sessions do not jump accounts mid-conversation — but quota re-evaluation, failover, +account exclusion, affinity expiry, or 401/403 and 429 recovery can rebind them. Give the accounts a +selection order when one of them — usually your Codex Desktop login — should only be reached for +once the others are drained. + +
+Docker Compose The repository ships a digest-pinned, non-root Compose build. With Git and Bun installed on the host, generate the canonical compatibility manifest before every image build, then initialize -the data-plane token once through stdin and start the hub. The first normal start creates a -per-volume self-signed TLS identity; copy its public certificate out for local verification: +the data-plane token once through stdin and start the hub: ```bash -git clone https://github.com/yansigit/opencodex.git +git clone https://github.com/lidge-jun/opencodex.git cd opencodex bun scripts/generate-compatibility-version.ts docker compose build openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.ts docker compose up -d -mkdir -p .tmp -docker compose cp hub:/home/bun/.opencodex/container-tls/cert.pem .tmp/opencodex-container-ca.pem -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/healthz -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/readyz +curl --fail --silent http://127.0.0.1:10100/healthz +curl --fail --silent http://127.0.0.1:10100/readyz ``` The default host binding is `127.0.0.1:10100`. Remote exposure requires explicit `OPENCODEX_BIND_ADDRESS= docker compose up -d`; `0.0.0.0` opts into -all host interfaces. `OPENCODEX_PORT` also updates the generated localhost `tls.publicOrigin`; -set `OPENCODEX_PUBLIC_ORIGIN` only when installing a matching operator-managed identity. The -generated certificate covers only `localhost` and `127.0.0.1`; keep the -default loopback publication behind an authenticated TLS/tailnet frontend, or install a certificate -and `tls.publicOrigin` for the exact remote name before publishing directly. Restrict either setup -with a firewall. +all host interfaces. Restrict access with a firewall and an authenticated TLS/tailnet frontend. The generated JSON stays untracked; it is copied into the image without including `.git`. Regenerate it after source changes, and do not change the source between generation and build. -The build rejects stale manifests, missing or mismatched files, extra source or Docker-authority -files, and symlinks. It checks every recorded SHA-256 against the build context and copied runtime -files, including the Dockerfile, Compose/config/bootstrap/probe files, `package.json`, `bun.lock`, -and the specifically included `scripts/model-metadata.source.json`. +The build rejects stale manifests, missing or mismatched files, extra source files, and symlinks. +It checks every recorded SHA-256 against the build context and copied runtime files, including +`package.json`, `bun.lock`, and the specifically included `scripts/model-metadata.source.json`. -The token, TLS private key, and mutable state stay in the `ocx-state` named volume; no credential is -placed in the image, Compose file, environment, or shell arguments. See the +The token and mutable state stay in the `ocx-state` named volume; no credential is placed in the +image, Compose file, environment, or shell arguments. See the [Remote Hub deployment guide](https://opencodex.me/guides/remote-hub/#docker-compose) for provider setup, authenticated acceptance checks, remote management, and rollback. +
+
Install from source (latest dev) @@ -132,7 +140,7 @@ setup, authenticated acceptance checks, remote management, and rollback. ```bash curl -fsSL https://bun.sh/install | bash -git clone https://github.com/yansigit/opencodex.git +git clone https://github.com/lidge-jun/opencodex.git cd opencodex && ~/.bun/bin/bun install ~/.bun/bin/bun run src/cli/index.ts start ``` @@ -141,7 +149,7 @@ cd opencodex && ~/.bun/bin/bun install ```powershell irm bun.sh/install.ps1 | iex -git clone https://github.com/yansigit/opencodex.git +git clone https://github.com/lidge-jun/opencodex.git cd opencodex; bun install bun run src/cli/index.ts start ``` @@ -152,22 +160,11 @@ they reach the npm package.
-Open **http://localhost:10100** and configure everything in the web dashboard — add providers -(40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` -re-opens the dashboard at any time. -It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, -refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use -the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex -threads normally retain affinity to the account that started them, so long SSH, tmux, or -mobile-connected sessions do not jump accounts mid-conversation — but quota re-evaluation, failover, -account exclusion, affinity expiry, or 401/403 and 429 recovery can rebind them. Give the accounts a -selection order when one of them — usually your Codex Desktop login — should only be reached for -once the others are drained. - -### For agents +
+For agents ```bash -npm install -g @yansigit/opencodex +npm install -g @bitkyc08/opencodex ocx start # or `ocx service` ocx init # interactive setup: writes ~/.opencodex/config.json and wires Codex ``` @@ -176,18 +173,13 @@ ocx init # interactive setup: writes ~/.opencodex/config.json and wires Cod commands like `ocx provider add` and `ocx combo set` talk to the **live** proxy and exit nonzero when it is unreachable). `ocx status` / `ocx doctor` / `ocx health` report the running state. -### Fork release automation - -The fork publishes from `main` after a human merge and green Cross-platform CI. npm Trusted -Publishing must be bound to GitHub Actions repository `yansigit/opencodex`, workflow `release.yml`, -for tokenless OIDC publishing. The automation never bumps `package.json`; a merged tree must -already contain an unused version. - > **Agents installing or running opencodex:** read > [`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md). An interactive `ocx start` may ask once whether to > star this repository — that is the user's decision, never an agent's. The CLI suppresses the > prompt for agent-driven runs and the API refuses them with `403 agent_consent_required`. +
+ ## Supported platforms | OS | Status | Service manager | @@ -196,7 +188,7 @@ already contain an unused version. | Linux (x64 / arm64) | Fully supported | systemd (user unit) | | Windows (x64) | Fully supported | Task Scheduler (hidden) / opt-in native service (`--native`, WinSW) | -Requires [Node](https://nodejs.org) 22+. The Bun runtime is bundled on `npm install` — no separate +Requires [Node](https://nodejs.org) 18+. The Bun runtime is bundled on `npm install` — no separate Bun install needed, no WSL needed on Windows. If npm blocked the bundled runtime's install scripts, see the [installation docs](https://opencodex.me/getting-started/installation/). @@ -330,20 +322,15 @@ daemon. Remove them with `ocx service uninstall` / `ocx codex-shim uninstall`. ```bash ocx uninstall # stop, remove service/shim, restore native Codex, clean up state -npm uninstall -g @yansigit/opencodex +npm uninstall -g @bitkyc08/opencodex ``` ## Remote access -By default opencodex binds to `127.0.0.1` over HTTP. Binding beyond loopback requires both a -data-plane credential and native TLS; the proxy refuses remote plaintext. Configure certificate -and key file paths plus the client-visible HTTPS `publicOrigin`, then restart the listener. Data-plane -clients must trust the operator-managed certificate and send the credential as `x-opencodex-api-key`. -Remote dashboard access additionally requires the separate admin token -(`OPENCODEX_ADMIN_AUTH_TOKEN` or the generated admin-token file); a data-plane API key does not grant -management access. -SSH port forwarding remains the simpler loopback-only alternative. Details: -[configuration reference](https://opencodex.me/reference/configuration/). +By default opencodex binds to `127.0.0.1` and needs no extra authentication. Binding beyond +loopback (`"hostname": "0.0.0.0"`) **requires** a bearer token — the proxy refuses to start +without `OPENCODEX_API_AUTH_TOKEN`, and every client request must carry it as +`x-opencodex-api-key`. Details: [configuration reference](https://opencodex.me/reference/configuration/). ## Documentation @@ -354,7 +341,7 @@ published to **[opencodex.me](https://opencodex.me/)**. Maintainer source-of-truth notes live under [`structure/`](./structure), contributor setup in [`CONTRIBUTING.md`](./CONTRIBUTING.md), and security reporting in [`SECURITY.md`](./SECURITY.md). Report undisclosed vulnerabilities privately through -[GitHub private vulnerability reporting](https://github.com/yansigit/opencodex/security/advisories/new), +[GitHub private vulnerability reporting](https://github.com/lidge-jun/opencodex/security/advisories/new), not a public issue. ## Development @@ -363,7 +350,7 @@ Source development requires the `bun` CLI on your `PATH`. This is separate from package's bundled Bun runtime, which is used only by installed `ocx` commands. ```bash -git clone https://github.com/yansigit/opencodex.git +git clone https://github.com/lidge-jun/opencodex.git cd opencodex bun install bun run typecheck diff --git a/compose.yaml b/compose.yaml index cea1818568..7692d1691c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,9 @@ services: target: runtime init: true read_only: true + environment: + # A custom CODEX_HOME also requires a matching writable volume target below. + CODEX_HOME: /home/bun/.codex ports: - "${OPENCODEX_BIND_ADDRESS:-127.0.0.1}:${OPENCODEX_PORT:-10100}:10100" environment: @@ -16,6 +19,7 @@ services: OCX_CONTAINER_PUBLIC_ORIGIN: "${OPENCODEX_PUBLIC_ORIGIN:-}" volumes: - ocx-state:/home/bun/.opencodex + - codex-state:/home/bun/.codex tmpfs: - /tmp:size=64m,mode=1777 security_opt: @@ -27,3 +31,4 @@ services: volumes: ocx-state: + codex-state: diff --git a/devlog/_fin/260906_a_final_closeout/000_plan.md b/devlog/_fin/260906_a_final_closeout/000_plan.md new file mode 100644 index 0000000000..404af3c08c --- /dev/null +++ b/devlog/_fin/260906_a_final_closeout/000_plan.md @@ -0,0 +1,7 @@ +# A final verified landing + +Terminal outcome: all five source PRs are closed, credited changes are on dev, and integrated dev CI passed. See [the outcome](../260906_a_runtime_stack/090_outcome.md) and [final evidence](030_quota_followup.md). + +All five feature implementations and two additional verification repairs have independent review and remote regression evidence. Three feature carries and the Windows foundation are already on dev. Remaining chain:3708 (bounded macOS cleanup/replay-fixture foundation) →3692 (Command Code affinity) →3694 (effective capabilities). + +This final cycle preserves the original owner objective: all five source PRs dispositioned, credited changes on dev, and fresh final dev verification. No local suites/typecheck/build. Existing owner-authorized admin merge applies only after every actual current-head producer passed; a queued aggregation-only job may be evaluated with its exact allowlist and recorded honestly. No pending functional test or failed test is waived. diff --git a/devlog/_fin/260906_a_final_closeout/001_gate_audit.md b/devlog/_fin/260906_a_final_closeout/001_gate_audit.md new file mode 100644 index 0000000000..b3149e0741 --- /dev/null +++ b/devlog/_fin/260906_a_final_closeout/001_gate_audit.md @@ -0,0 +1,3 @@ +# Final gate audit resolution + +Accepted the independent audit finding: merge automation must not ignore a failed/cancelled aggregate or skipped applicable producer. The helper now enumerates all24 applicable producer names for the pinned manual-all workflow and requires every one completed/successful. No job-level skip is applicable to this workflow invocation. Exactly one ci aggregator must be successful or only queued; all other states reject. Queued aggregation is accepted only after the full producer predicate has been independently established and recorded. Prior three A merge records were rechecked and satisfy this stronger condition; no failed/skipped producer was previously bypassed. diff --git a/devlog/_fin/260906_a_final_closeout/010_landing.md b/devlog/_fin/260906_a_final_closeout/010_landing.md new file mode 100644 index 0000000000..27844a050a --- /dev/null +++ b/devlog/_fin/260906_a_final_closeout/010_landing.md @@ -0,0 +1,10 @@ +# Final actions and acceptance + +1. Refresh final integration PR #3716 and every carried layer head/base/review thread. Require all 24 actual producers at the exact final top head, including every Windows shard, both macOS shards and unsharded control, Linux shards, types/privacy and smokes. Preserve failed predecessor runs and the evidence for their repairs; a predecessor result is never relabeled successful. See 020 for the independently approved topology amendment. +2. Retarget the fully verified final top to dev and admin-merge it with a match-head guard. This carries all reviewed ancestor commits without rewriting them. Prove the top merge, all pending layer heads and original contributor commits are ancestors of freshly fetched dev. Close lower review PRs with the actual top integration evidence; preserve their actual GitHub state if GitHub automatically recognizes them as merged. Do not claim separate layer merge commits. +3. Immediately close each superseded original after checking it did not gain unique new changes. Source3679's rebase08d25 has verified identical patch; other originals are refreshed normally. Source3672/3679/3568 are already closed. Source3581/3671 close immediately after the verified top landing. Related3661 remains open for its explicitly excluded residual scope. +4. Merge latest dev into this own closeout branch only after code landings. Move only completed A unit directories from devlog/_plan to devlog/_fin, preserving historical contents. Add a concise public outcome table with source/carry/merge/CI/author proof and scope limitations. Never copy ignored logs or private investigations. +5. Publish a docs-only closeout PR using the repository template. Verify its changed paths, privacy and metadata; admin merge when checks allow. Runtime/tests/dependencies must be byte-identical to the last code merge. Existing code CI may prove that identical runtime tree; documentation metadata alone is not runtime test evidence. +6. Require final dev push-CI producer success on the last code head, verify any subsequent docs-only difference and final ancestry, and refresh all source/carry states. Complete the landing criterion and goal only after the durable ledger is complete and the FSM has closed. No release, deployment, service or account changes. + +Independent final audit checks this plan and later actual evidence. Any new valid implementation or CI finding returns to a narrowly scoped repair; it is not discarded to finish the goal. diff --git a/devlog/_fin/260906_a_final_closeout/020_verified_top_amendment.md b/devlog/_fin/260906_a_final_closeout/020_verified_top_amendment.md new file mode 100644 index 0000000000..d0ad70b43c --- /dev/null +++ b/devlog/_fin/260906_a_final_closeout/020_verified_top_amendment.md @@ -0,0 +1,9 @@ +# Verified descendant integration + +The original sequential landing plan was amended after independent review. The capability tip #3694 at b59a34ce5 passed all 24 actual functional CI producers in run33988434944. A preceding foundation run retained a distinct pre-ready transition-fixture timeout on Windows. The test-only repair and its outer-budget review correction form final top #3716 at782e21ed7; this top must independently pass all24 producers before any remaining code lands. + +One merge of the fully verified descendant preserves the complete reviewed stack and its original contributor commits. It avoids rewriting three branch heads for the same fixture repair. The completion bar remains exact-head functional verification, fresh source/review checks, dev ancestry for every layer, explicit original-author credit, immediate original closure and final aggregate dev evidence. Pending or failed functional jobs at the final candidate cannot be waived. + +Independent topology review: Lagrange PASS. The concrete execution helper is also reviewed before use. Historical failed predecessor jobs remain historical failures, while lower PRs are recorded as carried through the actual final integration. This is a change in landing topology, not a claim that unmerged review heads already shipped. + +The transition fixture passed four tests and typecheck remotely on pinned Bun1.4.0 at782e21ed7. A10.5-second controlled startup passed the new budget and failed the old10-second guard. An injected startup exception produced the direct pre-barrier diagnostic. Temporary mutations were restored; independent repair review passed after budgeting parent setup plus both sequential lock-test children. No local product tests, builds or typechecks ran. diff --git a/devlog/_fin/260906_a_final_closeout/030_quota_followup.md b/devlog/_fin/260906_a_final_closeout/030_quota_followup.md new file mode 100644 index 0000000000..54a86cac50 --- /dev/null +++ b/devlog/_fin/260906_a_final_closeout/030_quota_followup.md @@ -0,0 +1,7 @@ +# Final fixture follow-up and landing + +The final candidate advanced from782e21ed7 to5097e66fa after Windows1 exposed the quota observer fixture's unjoined queue. Only that test file and its record changed; runtime and dependency trees were identical. Independent review passed, remote16tests/typecheck passed, delayed-queue controls reproduced the exact old3failures and kept all16green after repair, and suppressed delivery still failed both event-count assertions. Temporary remote mutations were restored. + +Full509CI33991642514 passed all24actual producers plusci. Finaltop3716 was owner-authorized admin-merged into dev asa2f69c8aa60976345740ae6f3d2301f89297328e. Every pending layer head is an ancestor; originals3581/3671 closed immediately after proof. Earlier3672/3679/3568 remain closed. Live source/carry states, coauthor trailers and3661OPEN were independently re-read by the final state verifier. + +IntegrateddevCI33993960826 completed successfully: all17 applicable producers and the aggregate passed, with only the two dispatch-only jobs skipped. Independent final evidence review confirmed all five carry heads and all eight contributor commits are ancestors of the integration. The closeout contains only the five A documentation units; its runtime, tests and dependencies match the verified integration tree. diff --git a/devlog/_fin/260906_a_macos_verification/000_plan.md b/devlog/_fin/260906_a_macos_verification/000_plan.md new file mode 100644 index 0000000000..460d566fec --- /dev/null +++ b/devlog/_fin/260906_a_macos_verification/000_plan.md @@ -0,0 +1,5 @@ +# Final macOS verification repairs + +C4 spec-satisfaction repair of Unix probe cleanup classification. Consume the already reviewed replay-fixture commit7ff811ced to keep caller identity stable in the shared verification baseline. Main owns this new foundation PR below the two remaining A layers. No local suite/typecheck/build; all execution uses isolated remote Bun1.4.0 and CI. Existing GitHub/SSH identities and own branches only; no account/service/release changes. The only live processes exercised are temporary launchers created by the regression fixture. No additional termination signals or widened permissions are authorized. A2h checkpoint reassesses progress; no token/cost cap was specified. Detailed OS traces stay in ignored scratch. + +Goal: initial EPERM during an already-owned probe-group teardown does not prevent bounded observation of that group's disappearance. Success still requires an observed ESRCH. Persistent permission uncertainty or live groups continue to refuse installation and restore the launcher. Keep the existing one-second cleanup bound, one SIGKILL attempt, diagnostic sanitation and rollback guarantees. diff --git a/devlog/_fin/260906_a_macos_verification/010_cleanup_plan.md b/devlog/_fin/260906_a_macos_verification/010_cleanup_plan.md new file mode 100644 index 0000000000..403b1770e4 --- /dev/null +++ b/devlog/_fin/260906_a_macos_verification/010_cleanup_plan.md @@ -0,0 +1,11 @@ +# Diff-level cleanup plan + +1. Carry reviewed commit7ff811ced (test-only replay caller snapshots, forced second boundary and changed-token isolation) onto this dev foundation. Resolve only contextual offsets; do not introduce affinity production code or its cohort matrix. +2. MODIFY src/codex/shim.ts terminateUnixProcessGroup: retain the single initial SIGKILL. Save EPERM rather than immediately throwing it; other non-ESRCH errors still throw. Use the unchanged one-second passive signal-0 observation loop. An observed disappearance succeeds; if the group remains or cannot be observed, rethrow saved EPERM, otherwise retain the existing generic nontermination error. No new signal retry, timeout increase, cache change or test-only production export. +3. MODIFY tests/codex-integration/codex-shim.test.ts timeout rollback fixture. Keep its real native case, exact timeout message, restored launcher/no backup/no marker, native group-missing and child-dead/zombie assertions. Add scoped parent-only process.kill observation for its recorded negative PGID; unrelated calls delegate unchanged and spawned probes have independent native bindings. +4. Deterministic cases: SIGKILL throws sentinel EPERM then signal-0 EPERM→ESRCH must produce ordinary timeout refusal; persistent EPERM and continually-live signal-0 must retain fail-closed EPERM diagnostics. Assert one SIGKILL, actual passive probes, and the existing bounded runtime. Restore spies before native process cleanup proof; never count synthetic ESRCH as real cleanup. Passive bounded joining of the known fixture group is allowed for injected cases; native case retains its original immediate cleanup assertions. Finally restore environment/mocks and clean only fixture-owned paths/processes. +5. Emit bounded pid/state/error-code diagnostics on failure, with no commands, credentials or environment dumps. Actual CI EPERM is observed; the zombie-only-group explanation is a hypothesis, not claimed captured fact. +6. Remote proof: focused shim and replay/cache/security tests plus typecheck. Revert only the EPERM observation correction in remote scratch; the disappearing-group control must fail its exact diagnostic assertion. Candidate must pass transient, persistent and live controls, the native timeout integration and all original rollback checks. Restore source bytes. Independent implementation/security audit then exact-head full CI before admin landing. +7. Cascade verified foundation into affinity then capability, retain source-author commits, update PR bases before auto-deletion and reverify their current heads. No original remaining PR is closed before its change is on dev. Full current-head CI and final dev proof remain mandatory. + +Cleanup completion is not installation approval: existing timeout/recursive/descendant markers and the pre-cleanup group-survival result still refuse the launcher. The change only permits bounded absence proof before choosing the existing refusal diagnostic. No previously unsafe launcher is accepted. diff --git a/devlog/_fin/260906_a_macos_verification/020_direct_transport_watchdog.md b/devlog/_fin/260906_a_macos_verification/020_direct_transport_watchdog.md new file mode 100644 index 0000000000..708a1ce380 --- /dev/null +++ b/devlog/_fin/260906_a_macos_verification/020_direct_transport_watchdog.md @@ -0,0 +1,7 @@ +# Focused verification watchdog correction + +ClassC1: one test file, no production behavior or public API change. WindowsCIjob101361741694 hit the fixture's flat3000ms childwatchdog before routing assertions. The same child performs imports, an unbounded control fetch, two750ms probes and a2000ms read. The log cannot identify which stage consumed time. + +Use the existing CI-watchdog owner for a derived whole-child budget:3000ms startup +2000ms bounded control +750ms identity +750ms readiness +2000ms read +1000ms exit =9500ms. OnCI the existing30s/45s floor applies. Give the test itself the child budget plus1000ms cleanup. Add fixed child phase markers and bounded phase/request-count diagnostics, never capability values. Keep every exact routing/header assertion and existing per-operation budgets. Bound only the previously unbounded control fetch. + +Verify remotely on pinnedBun: originalfilechecks, an explicit3500ms pre-import delay underCI that succeeds withthecorrectbudget and fails withtheold3000ms guard, and an intentional memory-read misroute that fails the unchangedproxy/capability assertions despite valid-looking responses. No local execution. This is a causal verifier fix within the ongoing final landing repair loop, not an unconditional rerun or production timeout increase. diff --git a/devlog/_fin/260906_a_macos_verification/030_transition_probe_watchdog.md b/devlog/_fin/260906_a_macos_verification/030_transition_probe_watchdog.md new file mode 100644 index 0000000000..768c5104af --- /dev/null +++ b/devlog/_fin/260906_a_macos_verification/030_transition_probe_watchdog.md @@ -0,0 +1,9 @@ +# Transition probe readiness budget + +The next full Windows verification of the foundation (run33988432596, job101366851939) had one failure: the two-process transition initialization fixture reached its ten-second readiness deadline before both children published their barriers. No transition assertion ran. The same fixture passed in the fully verified stack tip33988434944. The failed log is retained; the exact slow operation on that runner was not captured. + +The harness nevertheless has a concrete budget defect: before publishing ready, each Windows child resolves the effective SID and the known folder through two separately bounded thirty-second PowerShell calls. A ten-second enclosing deadline can reject valid operation within those existing product limits. This is a C1 fixture-only follow-up within the final landing cycle. + +Derive the child budget from both identity calls plus startup headroom, use the existing CI watchdog on other platforms, and scale each outer test deadline to its sequential phases. Detect an exited child while waiting for a barrier so a crash cannot masquerade as slow startup, and await child exit before deleting its sandbox. Preserve every real process race, lock refusal, winner count, generation and database assertion; no product timing changes. + +Verification requires remote pinned-runtime focused tests and typecheck, a delayed-ready control that passes the new budget and fails the old ten-second budget, an early-exit diagnostic control, independent review, and full exact-head cross-platform CI on the final stacked follow-up. No local test, typecheck or build runs. diff --git a/devlog/_fin/260906_a_macos_verification/040_quota_observation_drain.md b/devlog/_fin/260906_a_macos_verification/040_quota_observation_drain.md new file mode 100644 index 0000000000..31e5091ce2 --- /dev/null +++ b/devlog/_fin/260906_a_macos_verification/040_quota_observation_drain.md @@ -0,0 +1,7 @@ +# Join asynchronous quota observations in fixtures + +The final top's Windows1 verification (run33990109175, job101372136435) found a concrete fixture ordering defect. The first two quota-reset seam assertions saw no event after six microtasks and five milliseconds. A later test that used the existing explicit drain received those earlier scheduled and surprise events instead. The fixed sleep did not join cold lazy imports or the serialized observation chain, and fixture reset replaced the capture sink while old work was still pending. + +This C1 test-only follow-up uses the existing flushQuotaObservationsForTests seam. Join observations before assertions; join before resetting a fixture or replacing its sink; and join asynchronous baseline forgetting after clearAccountQuota. Keep all event counts, reset kinds, account separation and no-notification assertions unchanged. Production quota logic and timing remain untouched. + +Verify on the remote pinned runtime with the full focused file and typecheck. Delay the existing observation/forget chain in scratch to prove the new drain still passes and the old five-millisecond fixture fails. Restore every temporary mutation. Require independent review and final exact-head CI before integration. No local tests, builds or typechecks. diff --git a/devlog/_fin/260906_a_replay_credentials/000_plan.md b/devlog/_fin/260906_a_replay_credentials/000_plan.md new file mode 100644 index 0000000000..01b5f57117 --- /dev/null +++ b/devlog/_fin/260906_a_replay_credentials/000_plan.md @@ -0,0 +1,3 @@ +# Stable replay-fixture caller identity + +C2 spec-satisfaction repair of a concrete macOS control failure. Two logical replay conversations generated a new synthetic credential for each request; a second-boundary change made them different callers. Preserve production credential scope and every existing response/cache assertion. Only tests/server/server-agent-task-recovery-replay.test.ts and this numbered unit change. No local tests/typecheck/build; pinned remote Bun1.4 isolated regressions, deterministic old/new control, typecheck and current-head CI before final landing. Owner-authorized no-verify pushes/admin merge remain scoped to A. No credential or service changes. Same session goal/ledger owns this extra mandatory cycle; no completion criteria removed. diff --git a/devlog/_fin/260906_a_replay_credentials/010_replay_plan.md b/devlog/_fin/260906_a_replay_credentials/010_replay_plan.md new file mode 100644 index 0000000000..abe95054b7 --- /dev/null +++ b/devlog/_fin/260906_a_replay_credentials/010_replay_plan.md @@ -0,0 +1,9 @@ +# Replay fixture diff plan + +MODIFY tests/server/server-agent-task-recovery-replay.test.ts only: + +1. In the two original real-handler tests (cached NEW_TASK continuation and MESSAGE replay), capture one headers object before the first post and reuse it for the second. Keep status200, one recovery, two provider bodies, plaintext-present and ciphertext-absent assertions. +2. Scope a Date.now spy to each test at a real current second plus995ms. Advance controlled time by10ms between posts. Assert a newly constructed unused credential differs across that boundary, while the actual conversation continues with its original headers. Restore the clock in finally. No sleep or timeout increase. +3. Add a changed-token isolation control using the existing fakeChatGptJwt claim override: same account/envelope and two valid tokens differing in exp must not share cached plaintext. Reusing the original request still restores. Assert no extra network recovery and unchanged encrypted input on the miss. +4. Main performs exact-head remote isolated replay/cache/security tests and typecheck. A scratch red control restores per-post codexHeaders() calls while keeping the forced boundary; both conversations must lose the expected plaintext. The changed-token negative remains a pass. Restore candidate bytes after the probe. +5. Independent review checks fixture identity, clock cleanup and unchanged production boundary. Publish the own affinity branch, cascade the capability child and obtain fresh CI after all recorded verification repairs. Original source author commits remain intact. No new production file or test-layout entry. diff --git a/devlog/_fin/260906_a_runtime_stack/000_plan.md b/devlog/_fin/260906_a_runtime_stack/000_plan.md new file mode 100644 index 0000000000..c724b27854 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/000_plan.md @@ -0,0 +1,43 @@ +# A runtime integration roadmap + +## Loop specification + +- Archetype: spec-satisfaction repair; C3 runtime, C4 proxy credential/recovery boundaries. +- Trigger: owner assigned A (#3672, #3679, #3568, #3581, #3671), authorized inherited parallel subagents, contributor-preserving stacked PRs, no-verify pushes, dev integration and immediate resolved-work closure. +- Goal: preserve transport termination, configured WS egress, native subagent MESSAGE recovery, conversation affinity and effective policy capabilities. +- Non-goals: B/C/D implementation, release promotion/publication, production service/config/credential changes. #3661 remains open unless its complete residual scope is independently proven solved. +- Verification: remote focused activation checks during each implementation cycle; required current-head hosted CI before readiness/merge; final dev ancestry and CI. No local tests, typecheck or builds. Git diff checks and prose validation only locally. +- Stop: all five changes or proven equivalents on dev; original PRs closed with attribution and landing references; fully solved linked issues closed; unresolved issue scope documented. +- Memory: this unit, the session-bound goalplan/ledger, and ignored `.tmp/a-runtime-stack/` evidence. +- Outcomes: DONE / proven NOOP; external blockers recorded, never inferred from ordinary conflicts or pending CI. +- Delegation: main owns FSM, branches, commits, pushes and merges. Plan/review lanes have disjoint file scope. Two distinct failed dispatches return ownership to main; new worker scope is added at P. +- Resource scope: existing git/gh identity, owned `codex/a-*` branches, public contributor PR reads, and existing the isolated remote verification host SSH for isolated verification. No new account credentials or provider requests. User imposed no subagent/model-inheritance budget cap; no model override. Two-hour checkpoint per work phase triggers evidence/reliability reassessment; pending CI is monitored with bounded waits, not abandoned. + +## Phase map + +| Cycle | Artifact | Consumes | Delivers | +|---|---|---|---| +| roadmap | 000 + 010..080 | live dev and public contributor changes | audited full integration plan; docs only | +| sse | 010_sse.md | existing SSE relay boundary | failure notification independent of tee cancellation | +| ws | 020_ws.md | prior transport baseline | WS outbound policy and pool identity | +| recovery | 030_recovery.md | validated transport stack | MESSAGE recovery + reparse/cache semantics | +| affinity | 040_affinity.md | recovery/reparse fields | stable Command Code conversation identity | +| capabilities | 050_capabilities.md | final effective dispatch behavior | policy selection congruent with dispatch | +| windows-fixtures | 070_windows_fixtures.md | current Windows failure evidence | deterministic verifier repair below A stack | +| landing | 080_landing.md | independently verified stack layers and verifier repair | current dev inclusion and closeout | + +The owner explicitly requested a stack. Independent transport fixes are retained as separate cumulative layers to expose interaction at each head; this publication order is not a claim of a hard dependency between SSE and WS. The actual code dependency is recovery before affinity. Each layer has its own PR diff, regression proof and CI. Bottom-up merge only; retarget before deleting parent branches. Keep stacks short by landing verified lower layers while subsequent cycles continue when possible. + +## Ownership + +A owns shared `src/server/responses/core.ts` integration for #3568 then #3581. C owns #3576 and may land its separate OAuth replay region first; both lanes refresh dev and preserve each other's changes. B owns `src/config.ts` final field reconciliation with #3679. Source snapshots use `refs/codex/a-original/N`, not remote-tracking scratch refs that concurrent fetch-prune can remove. + +## Evidence and provenance + +CI entry `.github/workflows/ci.yml` has unrestricted pull_request bases for stacks. `src/**`, `tests/**`, `scripts/**` are observed by its changes job; Linux test shards invoke `scripts/ci/run-bun-test-batches.sh`, gates run tsc/privacy, and macOS/Windows jobs validate platform behavior. These definitions were inspected without executing local suites. Remote-check scripts and real run IDs will be captured at C, not invented at P. Original source changes and review histories are public; any newly discovered security reasoning stays in ignored scratch. + +- #3672: `077dd61f66ac80678d071ae8fe516507f43a4264` +- #3679: `b05cccf264b4ab61db5d8dee8232c2f89bb1b541` +- #3568: `036a9321788464fdf33a387c9f44a834a844bdc1` +- #3581: `f60397d3408e0339ffc66acdcaca8133e40866c2` +- #3671: `7b1beb9c5eacd8dde22681a5df26804be52380b8` diff --git a/devlog/_fin/260906_a_runtime_stack/003_audit_resolution.md b/devlog/_fin/260906_a_runtime_stack/003_audit_resolution.md new file mode 100644 index 0000000000..f871eeb920 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/003_audit_resolution.md @@ -0,0 +1,8 @@ +# Roadmap audit resolution + +Independent reviewer returned GO-WITH-FIXES (2). Both findings accepted and folded before B: + +1. Implementation-cycle D previously implied full CI/dev landing, inconsistent with prepared stack layers. 010..050 now explicitly distinguish exact-head remote focused/type verified draft preparation from 080 full-gate landing. Final objective and full-CI-before-merge criteria remain unchanged. +2. Affinity reparse tests required a cohort option the shared post helper did not accept. 040 now names tests/helpers/agent-task-recovery.ts option extension, internal handler forwarding, and true/false/undefined observation in real initial/cache-only adapter calls. + +Private remote host/user paths were replaced with placeholders; exact machine setup remains ignored scratch. No product edits or local suites in roadmap cycle. diff --git a/devlog/_fin/260906_a_runtime_stack/004_windows_amendment.md b/devlog/_fin/260906_a_runtime_stack/004_windows_amendment.md new file mode 100644 index 0000000000..53b8712654 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/004_windows_amendment.md @@ -0,0 +1,3 @@ +# Windows verifier amendment + +Full Windows CI for SSE head failed two unchanged shutdown-spill fixtures. Logs and causal analysis are retained in ignored ci-triage/report.md. C confirms no concurrent ownership of responses-state.test.ts. Add a separate windows-fixtures PABCD after capabilities and before final landing. It repairs test-only clocks/fallback isolation, independently validates on Windows, publishes a small foundation PR and inserts its verified change beneath the source stack. Refresh descendants bottom-up while preserving contributor commits and required current-head checks. No production ACL/budget change, no test skip, no unexamined rerun. The final landing document moves to080; no existing completion criterion is weakened. Owner explicitly authorized admin merge. diff --git a/devlog/_fin/260906_a_runtime_stack/005_amendment_audit.md b/devlog/_fin/260906_a_runtime_stack/005_amendment_audit.md new file mode 100644 index 0000000000..f871b8eb37 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/005_amendment_audit.md @@ -0,0 +1,3 @@ +# Capability and verifier amendment audit + +Independent reviewer: capability plan PASS; roadmap GO-WITH-FIXES one prerequisite finding. Accepted. Added a new windows-fixtures prerequisite to landing while retaining its existing capabilities edge. No task/criterion completion states or existing prerequisite edges were removed. The durable dependency graph now prevents final landing from being selected before Windows verifier completion. Replaced stale060 landing references with080. Temporary Windows verification workflow still requires concrete security review before push. diff --git a/devlog/_fin/260906_a_runtime_stack/010_sse.md b/devlog/_fin/260906_a_runtime_stack/010_sse.md new file mode 100644 index 0000000000..27aee24e97 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/010_sse.md @@ -0,0 +1,218 @@ +# 010 — Surface SSE rewrite failure before tee cancellation (#3672) + +Status: candidate plan, docs-only; implementation class C3 (stream lifecycle). Evidence refreshed 2026-09-06 KST through GitHub API and local persistent refs. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Source, authorship and drift + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3672 +- Exact original head/commit: `077dd61f66ac80678d071ae8fe516507f43a4264`, persistent ref `refs/codex/a-original/3672`. +- Original parent: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +- Original author: Hako, GitHub `devswha`; trailer: `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>`. +- Planning dev/working HEAD: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`, also confirmed by live dev API. Although commits differ, comparing original parent to dev restricted to the three original touched files returns no changed paths. Original patch applies to the same source blobs; later P must repeat this check. +- Live reviewThreads: zero. PR body reports focused/affected passes but explicitly does not claim a green full suite. No outstanding published code-review fix is presently known; exact carried-head remote tests and maintainer review remain acceptance gates. + +## Behavior and necessity + +Current `src/server/sse-payload-rewrite.ts:249-253` releases budget/disposes the rewrite, then awaits `reader.cancel(error)` before `controller.error(error)`. With a real tee and an open inspection sibling, cancellation waits for that sibling; the outer relay cannot observe the failure and abort the work that releases it. Reuse the existing failed-tail owner at `src/server/relay.ts:259`; no new error wrapper, retry mechanism, stream type or configuration is needed. Doing nothing leaves the wait cycle; deleting cancellation loses cleanup; configuration cannot fix the ordering. + +After the change, release/dispose remain synchronous, cancellation rejection is handled asynchronously, and `controller.error(error)` runs immediately. The outer failed-tail relay emits one `response.failed` then `[DONE]` and aborts upstream while inspection remains open. Budget overflow keeps `translation_buffer_limit`. Normal EOF, explicit client cancellation, rewriting, and disposal idempotence remain unchanged. + +## Exact file manifest and diff contract + +| Operation | Path | Required change | +|---|---|---| +| MODIFY | `src/server/sse-payload-rewrite.ts` | At catch line 252 replace awaited cancellation with `void reader.cancel(error).catch(() => {});` and explain the tee dependency. Keep release/dispose/error ordering. | +| MODIFY | `tests/responses/sse-payload-rewrite.test.ts` | Append the original parameterized real-tee regression after the current last test (line 153); cover source cancel resolve and reject, bounded completion and cleanup. | +| MODIFY | `docs-site/src/content/docs/reference/proxy-formats.md` | After line 83 add the five-line native rewrite failure/terminal/budget contract. | + +NEW: none. DELETE: none. Existing test file is already registered; no layout manifest edit. The appendix contains the exact original patch for all three paths, not an outline. No production implementation has been performed by this planning task. + +## Regression activation and independent acceptance + +1. Remote RED: place the original two added tests on the layer's current parent without the one-line production change in an isolated remote verification checkout. Hold a real tee sibling open, exhaust a 64-byte test budget with `data: partial` plus 80 bytes, and require both cases to reject with the one-second inspection-wait deadline. Record that failure, then restore the candidate patch remotely. +2. Remote GREEN: for resolve and reject cancellation, terminal arrives before inspection settles; exactly one `response.failed`, `translation_buffer_limit`, final `data: [DONE]`, abort signal true, zero source cancel calls before sibling release, one dispose, zero current budget bytes and one overflow. +3. Release inspection afterwards: underlying source cancel executes once; late cancellation rejection is observed/handled; no unhandled asynchronous error; disposal stays once. Test `finally` releases locks and budgets even on RED timeout. +4. Run adjacent failed-tail tests remotely to preserve disconnect, terminal and cancellation behavior. Existing Windows-sensitive composition must remain covered by an actual Windows run. +5. A reviewer confirms no awaited sibling-dependent cancellation remains on this exception path, no cancellation errors escape, and no downstream terminal duplication. This layer does not depend on #3679 or recovery/cache work. + +Remote focused command, after verifying remote checkout SHA and installing its pinned runtime/dependencies: + +```sh +bun test tests/responses/sse-payload-rewrite.test.ts tests/responses/sse-failed-tail.test.ts +``` + +Static anchors: `sse-payload-rewrite.ts:145` disposal guard, `:192` budget release, `:249` exception path, `:256` consumer cancellation; `relay.ts:259` failed-tail entry. The original regression fixture itself is the activation instrument; contributor-reported previous RED is context, not this layer's proof. + +#3679 shares only `docs-site/src/content/docs/reference/proxy-formats.md` with this layer. Preserve both paragraphs when the child lands. No release promotion or linked issue is bundled. + +## Execution boundary and resource scope + +This document is candidate planning for a later implementation P, authored during the first docs-only cycle. Main owns roadmap, FSM, goal, implementation and stack integration. This delegated task writes only this document and its sibling `010_sse.md`/`020_ws.md`; it does not run tests, typecheck, builds, Git mutations, GitHub mutations, FSM transitions or goal commands. + +Later implementation scope uses existing `gh` credentials and writes only the assigned own stack branches. Inherited parallel reviewers are authorized. There is no explicit user token/cost cap; a two-hour checkpoint triggers reassessment, not automatic success or abandonment. No production account probes, deployment or release actions belong to this layer. User explicitly forbids local suites; every executable verification below is for a remote isolated checkout or GitHub Actions later. No local typecheck/build is permitted here either. Security investigation material stays in `.tmp`; this public plan records only already-public PR behavior and general integration requirements. + +At the later P, refresh live dev and original PR head through main, compare touched-path blobs and parent changes, and amend this plan before implementation. A changed original SHA invalidates the carried-patch assumption. Preserve unrelated workers' changes. Main may carry the original commit with author identity preserved; every carry/superseding PR and squash message must include the exact `Co-authored-by` trailer below. Publish with the user's authorized `--no-verify` push, never a direct push to dev. Local hook bypass does not supply CI evidence. + +## Main-confirmed remote execution handoff + +Main reports the existing remote repository at `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun `1.3.14` have been verified. These are main-provided environment facts, not a local execution claim by this planner. Implementation C uses an isolated remote clone at the exact carried SHA; do not alter the existing remote checkout or its service. Record `git rev-parse HEAD` and `bun --version` from that isolated remote clone with focused activation-test and typecheck receipts. If the carried tree requires a different pinned Bun version, reconcile and record that runtime difference remotely before treating results as representative. + +Carry PRs remain draft until full current-head GitHub CI is green. Focused remote tests/typecheck are implementation evidence, not permission to skip full gates. The final landing cycle requires every full gate described below, including an actually executed Windows lane where Windows behavior is claimed, current-head review, and dev ancestry proof. No local project command execution is allowed at any point. Deeper implementation review belongs to the next cycle; this handoff completes only the concrete candidate plan. + +## Static workflow coverage and later remote evidence + +Inspected at `dev@81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base branch filter: an open stacked child gets the same workflow. Push trigger at line 27 covers integration branches only; pushing an own feature branch without opening its PR does not establish CI coverage. +- Runtime/test changes activate the `changes` gate and four Linux test shards (`ci.yml:255`), two macOS shards (`ci.yml:451`), and gates (`ci.yml:392`, typecheck at 422, privacy at 430). Linux test discovery is `scripts/ci/run-bun-test-batches.sh:197`; these layer tests are not the storage/API-usage exclusions at line 52. +- Windows full test shards are **dispatch-only**, `ci.yml:658-686`; ordinary PR CI cannot prove Windows behavior. `workflow_dispatch` has only `lane` (`ci.yml:46`), so use the own branch as `--ref`, not a nonexistent SHA input. `lane=all` runs Windows plus the unsharded macOS control (`ci.yml:549`). +- The aggregate `ci` accepts intentional skips (`ci.yml:927`); a green aggregate alone cannot prove a Windows run, regression activation, or even runtime tests on a docs-only PR. Check producer job conclusions and logs. +- `.github/actions/setup-project-bun/action.yml:18` resolves the runtime from `package.json.dependencies.bun`. Record actual Bun version rather than substituting contributor-reported Bun 1.4.0 results. + +Later main-owned CI commands (not executed by this planning task): + +```sh +# Freeze/read own branch head first; then dispatch its checked-in workflow. +gh workflow run ci.yml --repo lidge-jun/opencodex --ref "$A_LAYER_BRANCH" -f lane=all +gh run list --repo lidge-jun/opencodex --workflow ci.yml --branch "$A_LAYER_BRANCH" --limit 10 --json databaseId,headSha,event,status,conclusion +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --json headSha,event,conclusion,jobs +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --log +``` + +Assert dispatch `headSha` equals the frozen layer head. For PR merge-ref runs record actual checkout SHA and its head/base parents. A refresh/restack/new commit requires evidence for that resulting tree. Capture URLs, SHA, OS, runtime, command, exit code, failed/skipped test counts and any baseline comparison in main's evidence receipt. `action_required`, pending/cancelled checks, hygiene-only success and author attestations are not green test evidence. Do not check a contributor's local-CI attestation when no such local execution occurred. + +Full relevant suite coverage, typecheck, privacy and docs build must run remotely before readiness. For separately authorized remote checkout verification, install pinned dependencies there, run `bun run typecheck`, `bun run privacy:scan`, `bun run test`, and `(cd docs-site && bun run build)` there. Do not run those commands in the local managed workspace. Failures require a named current-base comparison and repair/reassessment; historic Windows failures do not automatically excuse a new failure. + +## Integration and close-out + +Each layer must be reviewable and independently acceptable against its immediate parent. No acceptance depends on a later A layer fixing its behavior. Main merges bottom-up with current-head CI and review evidence, retargets/restacks children before parent branch deletion, and preserves author trailers in squash/carry history. After main verifies the resulting merge commit is an ancestor of freshly fetched dev, immediately close the superseded original PR with the carry PR/commit reference. Close a linked issue only when its full acceptance scope is satisfied; do not infer an issue from a similar title. This planning task performs none of those actions. + +## Original patch appendix (candidate implementation) + +The following is source material already published in the linked PR. Revalidate context at the later P; do not apply during the docs-only cycle. + +```diff +diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md +index 77a67147a..19049e87f 100644 +--- a/docs-site/src/content/docs/reference/proxy-formats.md ++++ b/docs-site/src/content/docs/reference/proxy-formats.md +@@ -83,6 +83,11 @@ This applies to both tee inspection and eager relay, including Windows rewrite t + even when the upstream read rejects before the response-body cancellation hook runs. + A terminal captured during the bounded post-disconnect drain retains its actual outcome. + ++If native passthrough rewriting fails, including when it exceeds the translation ++buffer budget, the relay reports the failure without waiting for upstream inspection ++to finish. It cancels the upstream work and emits `response.failed` followed by ++`data: [DONE]`; a budget overflow uses the `translation_buffer_limit` error code. ++ + Client-facing Responses SSE frames are limited to 4 MiB per frame, measured in raw bytes before the + SSE block delimiter. On HTTP, an unterminated upstream frame that exceeds the limit fails closed + with a synthetic `response.failed` event followed by `data: [DONE]`. On the Responses WebSocket +diff --git a/src/server/sse-payload-rewrite.ts b/src/server/sse-payload-rewrite.ts +index 3c6d825e6..f9fb62065 100644 +--- a/src/server/sse-payload-rewrite.ts ++++ b/src/server/sse-payload-rewrite.ts +@@ -249,7 +249,9 @@ export function relaySseWithBlockRewrite( + } catch (error) { + releaseBuffer(); + disposeRewrite(); +- try { await reader.cancel(error); } catch { /* already closed */ } ++ // Cancelling one tee branch waits for its sibling. Surface the failure ++ // now so downstream can abort upstream and release the inspection branch. ++ void reader.cancel(error).catch(() => {}); + controller.error(error); + } + }, +diff --git a/tests/responses/sse-payload-rewrite.test.ts b/tests/responses/sse-payload-rewrite.test.ts +index 34dae59e0..773665a05 100644 +--- a/tests/responses/sse-payload-rewrite.test.ts ++++ b/tests/responses/sse-payload-rewrite.test.ts +@@ -153,4 +153,82 @@ describe("SSE payload rewrite composition", () => { + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + }); ++ ++ test.each(["resolve", "reject"] as const)( ++ "surfaces a rewrite failure before tee cancellation can %s", ++ async cancellationOutcome => { ++ const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); ++ const upstream = new AbortController(); ++ const cancellation = Promise.withResolvers(); ++ const cancellationError = new Error("upstream cancellation failed"); ++ let cancelCalls = 0; ++ let disposeCalls = 0; ++ const source = new ReadableStream({ ++ start(controller) { ++ controller.enqueue(new TextEncoder().encode("data: partial")); ++ controller.enqueue(new TextEncoder().encode("x".repeat(80))); ++ // Keep the source open after exhausting the rewrite budget. ++ }, ++ cancel() { ++ cancelCalls += 1; ++ return cancellation.promise; ++ }, ++ }); ++ const [native, inspection] = source.tee(); ++ const inspectionReader = inspection.getReader(); ++ await inspectionReader.read(); ++ await inspectionReader.read(); ++ let inspectionSettled = false; ++ const pendingInspection = inspectionReader.read().then(() => { inspectionSettled = true; }); ++ const rewrite = Object.assign((block: string) => [block], { ++ dispose() { disposeCalls += 1; }, ++ }); ++ const rewritten = relaySseWithBlockRewrite(native, rewrite, budget); ++ const client = relaySseWithFailedTail(rewritten, upstream); ++ const completion = readAll(client); ++ let deadline: ReturnType | undefined; ++ ++ try { ++ const out = await Promise.race([ ++ completion, ++ new Promise((_, reject) => { ++ deadline = setTimeout(() => reject(new Error("rewrite failure waited for the inspection tee")), 1_000); ++ }), ++ ]); ++ expect(out.match(/event: response.failed/g)).toHaveLength(1); ++ expect(out).toContain('"code":"translation_buffer_limit"'); ++ expect(out).toEndWith("data: [DONE]\n\n"); ++ expect(upstream.signal.aborted).toBe(true); ++ expect(inspectionSettled).toBe(false); ++ expect(cancelCalls).toBe(0); ++ expect(disposeCalls).toBe(1); ++ expect(budget.snapshot().currentBytes).toBe(0); ++ expect(budget.snapshot().overflows).toBe(1); ++ ++ // Releasing inspection settles both tee cancellation promises. A late ++ // rejection must be handled by the rewriter as well as this reader. ++ const siblingCancellation = inspectionReader.cancel("inspection cleanup"); ++ expect(cancelCalls).toBe(1); ++ if (cancellationOutcome === "reject") { ++ cancellation.reject(cancellationError); ++ await expect(siblingCancellation).rejects.toBe(cancellationError); ++ } else { ++ cancellation.resolve(); ++ await siblingCancellation; ++ } ++ await pendingInspection; ++ await Bun.sleep(0); // Let the runner observe any unhandled cancellation rejection. ++ expect(disposeCalls).toBe(1); ++ } finally { ++ clearTimeout(deadline); ++ const cleanup = inspectionReader.cancel().catch(() => {}); ++ cancellation.resolve(); ++ await cleanup; ++ await pendingInspection; ++ await completion.catch(() => {}); ++ inspectionReader.releaseLock(); ++ budget.dispose(); ++ } ++ }, ++ ); + }); +``` diff --git a/devlog/_fin/260906_a_runtime_stack/011_sse_refresh.md b/devlog/_fin/260906_a_runtime_stack/011_sse_refresh.md new file mode 100644 index 0000000000..506c051db2 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/011_sse_refresh.md @@ -0,0 +1,3 @@ +# SSE layer P refresh + +The original #3672 head remains 077dd61f66ac80678d071ae8fe516507f43a4264 and open. Fresh dev fetch and restricted original-parent/dev diff show no drift in all three touched files. Consume 010 unchanged. Implementation branch: codex/a-01-sse; base dev, including audited roadmap commit. Carry original Hako commit with -x and unchanged author. All project verification remote; actual tee failure/late reject regression plus adjacent failed-tail and typecheck on exact carried SHA. Full CI remains mandatory before landing. diff --git a/devlog/_fin/260906_a_runtime_stack/020_ws.md b/devlog/_fin/260906_a_runtime_stack/020_ws.md new file mode 100644 index 0000000000..2db3f2d07b --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/020_ws.md @@ -0,0 +1,886 @@ +# 020 — Honor upstream WebSocket proxy routing (#3679) + +Status: candidate plan after layer 010, docs-only; implementation class C4 for the outbound routing boundary. Evidence refreshed 2026-09-06 KST; the 01:28 update supersedes the earlier triage snapshot. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Source, authorship and drift + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3679 +- Exact current original head/commit: `b05cccf264b4ab61db5d8dee8232c2f89bb1b541`, persistent ref `refs/codex/a-original/3679`. +- Original parent and current live dev: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +- Original author: Clive Rosfield, GitHub `S0RYUASUKA`; trailer: `Co-authored-by: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com>`. +- Ref/head equality verified. All 13 original touched files have identical parent/dev blobs. Layer 010 will additionally change `proxy-formats.md`; preserve its failure paragraph. `src/config.ts` overlaps lane B ownership, so main must recheck fresh dev and coordinate its comment hunk at later P. +- The body still names earlier tested head `182006615c484756012f2d0c1ba72f47c4e5cf5b`. Its counts are author-reported evidence for that head, not proof of this updated head or a later carry. Full suite is explicitly incomplete/non-green in the body. + +## Current review resolution + +All three live review threads are now resolved, not outstanding: + +- Companion documentation request was addressed by this head: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941233811 . Provider guide and adapter reference now distinguish adapter selection from transport selection. +- Proxy precedence request was withdrawn; preserve scheme-specific environment precedence and `config.proxy` filling absent scheme variables. The resulting HTTPS proxy intentionally precedes ALL_PROXY. Current patch adds uppercase/lowercase ALL_PROXY regression coverage: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941252968 . Do not reintroduce the withdrawn behavior change. +- The separate proxy policy request was withdrawn; retain established operator-selected HTTP/HTTPS proxy support in this routing-only layer: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941252966 . Any new investigation belongs in scratch, not this document. + +Remaining gates: independent current-head routing/security review under MAINTAINERS.md and remote executed verification. A resolved bot discussion does not substitute for that review. + +## Behavior and reuse decision + +Current `src/server/responses/codex-ws-session.ts:12-14` constructs WebSocket with headers only. `ws-upstream.ts:167-169` does not resolve or pass a proxy, and pool identity at `codex-ws-pool.ts:55` does not distinguish routes. `src/lib/proxy-env.ts:28` already owns HTTP fetch proxy matching; `src/lib/provider-outbound.ts:79` owns NO_PROXY matching. Reuse and move that matcher rather than adding a second implementation or altering Bun HTTP fetch rules. + +After the patch, choose a route once before dialing. NO_PROXY wins (WSS default 443, WS default 80). Otherwise choose first nonempty HTTPS_PROXY/https_proxy/ALL_PROXY/all_proxy for WSS; HTTP_PROXY alone is not a WSS proxy. An unusable selected value returns immediate HTTP/SSE fallback without dialing WebSocket or trying a lower-priority proxy. HTTP/SSE continues its existing scheme-specific behavior; ALL_PROXY does not become an HTTP fetch input. One-shot and retained sessions receive the same selected route. Pool reuse key includes the route while scope still identifies account/thread/turn; changed route retires the old session. Existing dispatch refusal, abort, headers, quota handling and post-send no-replay behavior remain intact. + +No-code/config-only options do not cover Bun WebSocket construction or retained-session route affinity; no new transport, package dependency, proxy discovery method or routing flag is necessary. + +## Exact file manifest and diff contract + +All operations are MODIFY; NEW and DELETE are none. The appendix is the complete diff against the pinned original parent. No new test file means no layout registration additions. + +| Path | Before → after / exact change | +|---|---| +| `src/lib/proxy-env.ts` | After ProxyEnvMap (line 5), add ProxyRoute direct/proxy/fallback union; exported normalizeProxyHostname and noProxyMatches moved from provider-outbound; matcher accepts an env map and WSS default port. Add resolveProxyRoute with first-nonempty selection, HTTP/HTTPS scheme acceptance and fallback on parse/unsupported value. Keep effectiveProxyFor semantics unchanged. | +| `src/lib/provider-outbound.ts` | Import the shared matcher/normalizer, delete private copies and configuredProxyFor wrapper, call outboundProxyConfigured directly. Keep DNS/destination admission and effective HTTP proxy snapshot logic unchanged. | +| `src/config.ts` | Update only the applyProxyEnv comment at line 3739 to explain transport use and scheme-versus-ALL precedence. No executable ALL_PROXY guard is added. | +| `src/server/responses/ws-upstream.ts` | Import resolver; after frame-size guard at line 151 compute wsUrl, route and optional proxy; fallback before creating socket on route fallback; pass same proxy to identity, pool acquire and one-shot constructor. Existing admission hooks remain effective through HTTP fallback and WS exchange. | +| `src/server/responses/codex-ws-pool.ts` | Add optional proxy to identity/acquire signatures at lines 28/78, include proxy-or-null in hashed key at 55 and forward it into retained constructor at 97. Do not change scope, bounds or eviction. | +| `src/server/responses/codex-ws-session.ts` | Add optional fifth constructor argument; append proxy option only when selected. Preserve headers and all listener/lease lifecycle behavior. | +| `tests/server/proxy-env.test.ts` | Add ALL_PROXY spellings to saved/restored fixture env. Add resolver precedence/bypass/fallback cases, direct Bun WebSocket CONNECT fixture, Windows-only NO_PROXY fetch fixture, and both config-versus-ALL precedence cases. | +| `tests/responses/ws-upstream.test.ts` | Capture constructor options, isolate/restore all proxy env values, assert option+header propagation, zero sockets/one fallback for malformed/unsupported selection, existing upgrade fallback through proxy, NO_PROXY header/custom destination behavior. | +| `tests/responses/ws-upstream-reuse.test.ts` | Isolate/restore proxy env, capture options, exercise proxy A→B→NO_PROXY with two requests per route; expect three sockets, two frames each, old two closed and last retained. | +| `docs-site/src/content/docs/reference/proxy-formats.md` | Add canonical WSS routing, invalid-route fallback and scheme/config/ALL precedence paragraphs after line 113; retain 010's earlier SSE failure paragraph. | +| `docs-site/src/content/docs/guides/providers.md` | After line 620 distinguish adapter selection from transport with link to canonical rules. | +| `docs-site/src/content/docs/reference/adapters.md` | After line 95 add companion transport note, link and HTTP-vs-WSS distinction. | +| `structure/04_transports-and-sidecars.md` | At lines 438 and 647 include route in reuse identity and explain WSS route/fallback without changing HTTP rules. | + +Localized pages currently omit the new behavior; public review records no contradiction. Recheck that remains true at later P; do not add unrelated locale rewrites. The 13-file breadth is one route-selection contract with tests/docs, not 13 independent product changes; keep it one independently reviewed layer. + +## Regression activation and independent acceptance + +Remote RED/GREEN must prove each mechanism rather than only compiling the added API: + +1. Resolver: uppercase/lowercase ordering, blank values, fallback priority, invalid selected proxy, unsupported scheme; NO_PROXY exact/suffix/wildcard/port/IPv6/URL entry, uppercase-empty overriding lowercase; retain HTTP fetch behavior through provider-outbound tests. +2. Construction: one-shot and retained constructors get the chosen option with unchanged authorization/beta/originator/header filtering. Before the production change, a constructor-option assertion must fail on the parent. The test-only resolver import must not be mistaken for sufficient behavioral RED. +3. Invalid route: zero created sockets and exactly one fallback. NO_PROXY produces direct option omission; HTTP_PROXY-only does not create a WSS proxy route. Existing dispatch-refusal/aborted/post-send tests must retain no duplicate dispatch or replay. +4. Affinity: two requests on route A reuse, route B causes replacement, NO_PROXY causes another replacement; sockets `[A,B,direct]`, two frames each, states `[closed,closed,open]`. Parent without proxy in key must fail this assertion remotely. +5. Real runtime: original `proxy-env.test.ts` local CONNECT fixture executes **on the remote test runner**, observing `proxy-probe.invalid:443` with a loopback HTTP proxy. This fixture directly constructs Bun WebSocket; it does not by itself prove codexWsUpstreamFetch integration. Combine it with option-propagation tests and capture a separate remote loopback harness through codexWsUpstreamFetch if an end-to-end integration claim is made. No production credentials required. +6. Actual Windows execution must exercise the new Windows-only NO_PROXY fetch fixture; a Linux skip is expected and not Windows proof. Check full CI and privacy independently; validate disposal/no lingering test listener behavior. + +Remote focused command (only inside verified remote checkout, using fixture-specific env cleanup and restoration): + +```sh +bun test tests/server/proxy-env.test.ts tests/providers/provider-outbound.test.ts tests/providers/provider-outbound-private-network.test.ts tests/responses/ws-upstream.test.ts tests/responses/ws-upstream-reuse.test.ts tests/responses/reserve-dispatch-ws.test.ts --timeout 20000 +``` + +Control the eight HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY case variants within the isolated remote test process; never clear the user's global environment. Original contributor observed inherited-environment failures on an older baseline; reproduce any new discrepancy against this layer's exact parent before classifying it. Any skipped runtime probe must be recorded as unproven rather than silently accepted. + +## Execution boundary and resource scope + +This document is candidate planning for a later implementation P, authored during the first docs-only cycle. Main owns roadmap, FSM, goal, implementation and stack integration. This delegated task writes only this document and its sibling `010_sse.md`/`020_ws.md`; it does not run tests, typecheck, builds, Git mutations, GitHub mutations, FSM transitions or goal commands. + +Later implementation scope uses existing `gh` credentials and writes only the assigned own stack branches. Inherited parallel reviewers are authorized. There is no explicit user token/cost cap; a two-hour checkpoint triggers reassessment, not automatic success or abandonment. No production account probes, deployment or release actions belong to this layer. User explicitly forbids local suites; every executable verification below is for a remote isolated checkout or GitHub Actions later. No local typecheck/build is permitted here either. Security investigation material stays in `.tmp`; this public plan records only already-public PR behavior and general integration requirements. + +At the later P, refresh live dev and original PR head through main, compare touched-path blobs and parent changes, and amend this plan before implementation. A changed original SHA invalidates the carried-patch assumption. Preserve unrelated workers' changes. Main may carry the original commit with author identity preserved; every carry/superseding PR and squash message must include the exact `Co-authored-by` trailer below. Publish with the user's authorized `--no-verify` push, never a direct push to dev. Local hook bypass does not supply CI evidence. + +## Main-confirmed remote execution handoff + +Main reports the existing remote repository at `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun `1.3.14` have been verified. These are main-provided environment facts, not a local execution claim by this planner. Implementation C uses an isolated remote clone at the exact carried SHA; do not alter the existing remote checkout or its service. Record `git rev-parse HEAD` and `bun --version` from that isolated remote clone with focused activation-test and typecheck receipts. If the carried tree requires a different pinned Bun version, reconcile and record that runtime difference remotely before treating results as representative. + +Carry PRs remain draft until full current-head GitHub CI is green. Focused remote tests/typecheck are implementation evidence, not permission to skip full gates. The final landing cycle requires every full gate described below, including an actually executed Windows lane where Windows behavior is claimed, current-head review, and dev ancestry proof. No local project command execution is allowed at any point. Deeper implementation review belongs to the next cycle; this handoff completes only the concrete candidate plan. + +## Static workflow coverage and later remote evidence + +Inspected at `dev@81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base branch filter: an open stacked child gets the same workflow. Push trigger at line 27 covers integration branches only; pushing an own feature branch without opening its PR does not establish CI coverage. +- Runtime/test changes activate the `changes` gate and four Linux test shards (`ci.yml:255`), two macOS shards (`ci.yml:451`), and gates (`ci.yml:392`, typecheck at 422, privacy at 430). Linux test discovery is `scripts/ci/run-bun-test-batches.sh:197`; these layer tests are not the storage/API-usage exclusions at line 52. +- Windows full test shards are **dispatch-only**, `ci.yml:658-686`; ordinary PR CI cannot prove Windows behavior. `workflow_dispatch` has only `lane` (`ci.yml:46`), so use the own branch as `--ref`, not a nonexistent SHA input. `lane=all` runs Windows plus the unsharded macOS control (`ci.yml:549`). +- The aggregate `ci` accepts intentional skips (`ci.yml:927`); a green aggregate alone cannot prove a Windows run, regression activation, or even runtime tests on a docs-only PR. Check producer job conclusions and logs. +- `.github/actions/setup-project-bun/action.yml:18` resolves the runtime from `package.json.dependencies.bun`. Record actual Bun version rather than substituting contributor-reported Bun 1.4.0 results. + +Later main-owned CI commands (not executed by this planning task): + +```sh +# Freeze/read own branch head first; then dispatch its checked-in workflow. +gh workflow run ci.yml --repo lidge-jun/opencodex --ref "$A_LAYER_BRANCH" -f lane=all +gh run list --repo lidge-jun/opencodex --workflow ci.yml --branch "$A_LAYER_BRANCH" --limit 10 --json databaseId,headSha,event,status,conclusion +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --json headSha,event,conclusion,jobs +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --log +``` + +Assert dispatch `headSha` equals the frozen layer head. For PR merge-ref runs record actual checkout SHA and its head/base parents. A refresh/restack/new commit requires evidence for that resulting tree. Capture URLs, SHA, OS, runtime, command, exit code, failed/skipped test counts and any baseline comparison in main's evidence receipt. `action_required`, pending/cancelled checks, hygiene-only success and author attestations are not green test evidence. Do not check a contributor's local-CI attestation when no such local execution occurred. + +Full relevant suite coverage, typecheck, privacy and docs build must run remotely before readiness. For separately authorized remote checkout verification, install pinned dependencies there, run `bun run typecheck`, `bun run privacy:scan`, `bun run test`, and `(cd docs-site && bun run build)` there. Do not run those commands in the local managed workspace. Failures require a named current-base comparison and repair/reassessment; historic Windows failures do not automatically excuse a new failure. + +## Integration and close-out + +Each layer must be reviewable and independently acceptable against its immediate parent. No acceptance depends on a later A layer fixing its behavior. Main merges bottom-up with current-head CI and review evidence, retargets/restacks children before parent branch deletion, and preserves author trailers in squash/carry history. After main verifies the resulting merge commit is an ancestor of freshly fetched dev, immediately close the superseded original PR with the carry PR/commit reference. Close a linked issue only when its full acceptance scope is satisfied; do not infer an issue from a similar title. This planning task performs none of those actions. + +## Original patch appendix (candidate implementation) + +The following is source material already published in the linked PR. Revalidate context at the later P; do not apply during the docs-only cycle. + +```diff +diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md +index 6a37cf8a7..255c0d8dc 100644 +--- a/docs-site/src/content/docs/guides/providers.md ++++ b/docs-site/src/content/docs/guides/providers.md +@@ -620,6 +620,12 @@ A provider is included when opencodex has a matching wire adapter, **not** based + (AI Studio, Vertex, and Antigravity/Cloud Code Assist modes), `azure` / `azure-openai`, `kiro`, and + `cursor`. A proprietary API without one of these implementations, such as native Amazon Bedrock, + is not supported directly. ++ ++Provider configuration selects the adapter; upstream transport selection is separate. Eligible ++Responses traffic can use WSS with [explicit proxy routing](/reference/proxy-formats/#json-and-sse-output). ++Invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE, which uses Bun's HTTP ++proxy rules rather than the WSS-specific `ALL_PROXY` fallback. ++ + **GitHub Copilot** is an OAuth provider (`ocx login github-copilot`) that exchanges a GitHub + device-flow login for a short-lived Copilot API token — not a pasted API key. **GitLab Duo** remains + a key/subscription-token gateway on its OpenAI-compatible endpoint. **Cloudflare AI +diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md +index 1db98357d..e2a24c67d 100644 +--- a/docs-site/src/content/docs/reference/adapters.md ++++ b/docs-site/src/content/docs/reference/adapters.md +@@ -95,6 +95,11 @@ body and response, with narrow compatibility rewrites for routed gateways. + `forward` uses configured static headers without relaying caller authorization; `key` uses the + configured provider key. + ++Adapter selection does not select the upstream transport. Eligible requests can use the ++[upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported ++WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's ++HTTP proxy rules and does not inherit the WSS-specific `ALL_PROXY` fallback. ++ + Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a + collision-safe public function tool. Matching request history and JSON/SSE function calls are + translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward +diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md +index 77a67147a..b4d7e5dea 100644 +--- a/docs-site/src/content/docs/reference/proxy-formats.md ++++ b/docs-site/src/content/docs/reference/proxy-formats.md +@@ -113,6 +113,19 @@ the raw JSON frame and its SSE envelope at 4 MiB, and closes the upstream when i + would overflow. That overflow emits a terminal downstream `response.failed` event followed by + `[DONE]`. + ++The upstream WebSocket checks `NO_PROXY`/`no_proxy` first. Otherwise it uses the first non-empty ++`HTTPS_PROXY`, `https_proxy`, `ALL_PROXY`, or `all_proxy` value; `HTTP_PROXY` alone does not proxy a ++WSS connection. HTTP and HTTPS proxy URLs are passed to Bun. If the selected value is invalid or ++uses an unsupported protocol, opencodex skips the WebSocket attempt and uses HTTP/SSE instead of ++dialing the upstream directly. ++ ++These rules belong to the upstream WebSocket transport, independently of the selected provider ++adapter. HTTP fetch-based Responses requests, including SSE fallback, use Bun's HTTP proxy rules ++and do not use `ALL_PROXY`. `config.proxy` fills missing `HTTP_PROXY`/`HTTPS_PROXY` values; the ++resulting scheme-specific value also takes precedence over an existing `ALL_PROXY` for WebSocket. ++For an HTTPS upstream that requires a proxy, set `HTTPS_PROXY` or `config.proxy`; `HTTP_PROXY` ++alone leaves both WSS and its HTTPS fallback without a scheme-matched proxy. ++ + Every terminal Responses usage object includes both detail objects, even when the provider did not + report those details: + +diff --git a/src/config.ts b/src/config.ts +index 5d67275dc..72da45538 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -3738,11 +3738,12 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements + } + + /** +- * Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound +- * provider call through the proxy — no per-callsite changes (verified: Bun honors these plus +- * NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the +- * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry +- * that makes outbound provider requests (server start, catalog sync). ++ * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports ++ * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY ++ * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. ++ * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and ++ * running-proxy API calls stay direct. Call once per process entry that makes outbound provider ++ * requests (server start, catalog sync). + */ + export function applyProxyEnv(config: OcxConfig): void { + applyProxyEnvWith(config); +diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts +index 495fef0b8..02bdbc207 100644 +--- a/src/lib/provider-outbound.ts ++++ b/src/lib/provider-outbound.ts +@@ -7,7 +7,7 @@ import { + resolvePublicAddresses, + } from "./destination-policy"; + import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; +-import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env"; ++import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; + import { publicProviderBaseUrl } from "./provider-url"; + + type ProviderGetInit = Omit; +@@ -37,10 +37,6 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }> + return addresses.find(address => address.family === 4) ?? addresses[0]!; + } + +-function configuredProxyFor(): boolean { +- return outboundProxyConfigured(); +-} +- + /** + * Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode). + * +@@ -76,45 +72,6 @@ function transparentFakeIpException( + return isCanonicalUrl(name, url); + } + +-function normalizeProxyHostname(hostname: string): string { +- const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); +- return normalized.startsWith("[") && normalized.endsWith("]") +- ? normalized.slice(1, -1) +- : normalized; +-} +- +-function noProxyMatches(url: URL): boolean { +- const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; +- const hostname = normalizeProxyHostname(url.hostname); +- const port = url.port || (url.protocol === "https:" ? "443" : "80"); +- for (const rawEntry of raw.split(",")) { +- let entry = rawEntry.trim().toLowerCase(); +- if (!entry) continue; +- if (entry === "*") return true; +- entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!; +- +- let entryHost = entry; +- let entryPort = ""; +- const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); +- if (bracketed) { +- entryHost = bracketed[1]!; +- entryPort = bracketed[2] ?? ""; +- } else if ((entry.match(/:/g)?.length ?? 0) === 1) { +- const separator = entry.lastIndexOf(":"); +- const possiblePort = entry.slice(separator + 1); +- if (/^\d+$/.test(possiblePort)) { +- entryHost = entry.slice(0, separator); +- entryPort = possiblePort; +- } +- } +- if (entryPort && entryPort !== port) continue; +- entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); +- if (!entryHost) continue; +- if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true; +- } +- return false; +-} +- + let proxyBoundaryWarned = false; + let proxyDnsDegradationWarned = false; + +@@ -181,7 +138,7 @@ async function providerOutboundRequest( + return provider.fetch(url, { ...init, method, redirect: "manual" }); + } + const parsed = postUrl ?? new URL(url); +- const proxyConfigured = configuredProxyFor(); ++ const proxyConfigured = outboundProxyConfigured(); + // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport + // below reason about the same value. `null` here means "no proxy fetch would actually use", + // even if some other proxy variable is set. +diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts +index 46df59268..0ac9ed735 100644 +--- a/src/lib/proxy-env.ts ++++ b/src/lib/proxy-env.ts +@@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const; + + export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number]; + export type ProxyEnvMap = Record; ++export type ProxyRoute = ++ | { kind: "direct" } ++ | { kind: "proxy"; proxy: string } ++ | { kind: "fallback" }; ++ ++export function normalizeProxyHostname(hostname: string): string { ++ const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); ++ return normalized.startsWith("[") && normalized.endsWith("]") ++ ? normalized.slice(1, -1) ++ : normalized; ++} ++ ++export function noProxyMatches( ++ url: URL, ++ env: ProxyEnvMap = process.env, ++): boolean { ++ const raw = env.NO_PROXY ?? env.no_proxy ?? ""; ++ const hostname = normalizeProxyHostname(url.hostname); ++ const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80"); ++ for (const rawEntry of raw.split(",")) { ++ let entry = rawEntry.trim().toLowerCase(); ++ if (!entry) continue; ++ if (entry === "*") return true; ++ entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!; ++ ++ let entryHost = entry; ++ let entryPort = ""; ++ const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); ++ if (bracketed) { ++ entryHost = bracketed[1]!; ++ entryPort = bracketed[2] ?? ""; ++ } else if ((entry.match(/:/g)?.length ?? 0) === 1) { ++ const separator = entry.lastIndexOf(":"); ++ const possiblePort = entry.slice(separator + 1); ++ if (/^\d+$/.test(possiblePort)) { ++ entryHost = entry.slice(0, separator); ++ entryPort = possiblePort; ++ } ++ } ++ if (entryPort && entryPort !== port) continue; ++ entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); ++ if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true; ++ } ++ return false; ++} ++ ++export function resolveProxyRoute( ++ url: URL, ++ env: ProxyEnvMap = process.env, ++): ProxyRoute { ++ if (noProxyMatches(url, env)) return { kind: "direct" }; ++ const key = url.protocol === "https:" || url.protocol === "wss:" ++ ? "HTTPS_PROXY" ++ : "HTTP_PROXY"; ++ const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"] ++ .map(candidate => env[candidate]?.trim()) ++ .find(Boolean); ++ if (!proxy) return { kind: "direct" }; ++ try { ++ const protocol = new URL(proxy).protocol; ++ return protocol === "http:" || protocol === "https:" ++ ? { kind: "proxy", proxy } ++ : { kind: "fallback" }; ++ } catch { ++ return { kind: "fallback" }; ++ } ++} + + export function proxyEnvPresent( + key: ProxyEnvKey, +diff --git a/src/server/responses/codex-ws-pool.ts b/src/server/responses/codex-ws-pool.ts +index 378cf2d4a..5d406bee4 100644 +--- a/src/server/responses/codex-ws-pool.ts ++++ b/src/server/responses/codex-ws-pool.ts +@@ -25,7 +25,7 @@ function digest(input: unknown): string { + } + + /** Identity comes from the selected outgoing request, never a model label or caller hint. */ +-export function codexWsReuseIdentity(url: string, headers: Record, frameText: string): CodexWsReuseIdentity | null { ++export function codexWsReuseIdentity(url: string, headers: Record, frameText: string, proxy?: string): CodexWsReuseIdentity | null { + if (url !== CODEX_RESPONSES_HTTP_URL) return null; + let body: unknown; + try { body = JSON.parse(frameText); } catch { return null; } +@@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record): CodexWsSession | null { ++ acquire(identity: CodexWsReuseIdentity, url: string, headers: Record, proxy?: string): CodexWsSession | null { + this.sweep(); + for (const entry of this.entries.values()) { + if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue; +@@ -94,7 +94,7 @@ export class CodexWsPool { + this.remove(oldest); + } + const createdAt = this.now(); +- const session = new CodexWsSession(url, headers, true, () => this.changed(entry)); ++ const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy); + const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false }; + session.reserve(); + this.entries.set(identity.key, entry); +diff --git a/src/server/responses/codex-ws-session.ts b/src/server/responses/codex-ws-session.ts +index bbf62f813..32716a529 100644 +--- a/src/server/responses/codex-ws-session.ts ++++ b/src/server/responses/codex-ws-session.ts +@@ -10,8 +10,8 @@ export class CodexWsSession { + private readonly completedIds = new Set(); + + constructor(url: string, headers: Record, readonly retainable = false, +- private readonly changed: () => void = () => {}) { +- this.socket = new WebSocket(url, { headers } as unknown as string[]); ++ private readonly changed: () => void = () => {}, proxy?: string) { ++ this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]); + this.socket.addEventListener("open", this.onOpen); + this.socket.addEventListener("message", this.onIdleMessage); + this.socket.addEventListener("close", this.onClose); +diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts +index e9773d02a..87b3767d2 100644 +--- a/src/server/responses/ws-upstream.ts ++++ b/src/server/responses/ws-upstream.ts +@@ -13,6 +13,7 @@ + // (passthrough relay, adapter parsers, usage sniffing) is unchanged. + + import { compareBunVersions } from "../../lib/bun-stream-caps"; ++import { resolveProxyRoute } from "../../lib/proxy-env"; + import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; + import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request"; + import { codexWsExchange } from "./codex-ws-exchange"; +@@ -150,6 +151,10 @@ export function codexWsUpstreamFetch( + return sseFallback(url, init); + } + ++ const wsUrl = wsUpstreamUrlFor(url); ++ const proxyRoute = resolveProxyRoute(new URL(wsUrl)); ++ if (proxyRoute.kind === "fallback") return sseFallback(url, init); ++ const proxy = proxyRoute.kind === "proxy" ? proxyRoute.proxy : undefined; + // A genuine caller `originator` is already in these headers via the forward + // set. Never fabricate one here: pool/forward traffic must not impersonate + // Codex CLI, per the metadata-integrity contract. (The backend's fast lane +@@ -164,9 +169,9 @@ export function codexWsUpstreamFetch( + } + let session: CodexWsSession; + try { +- const identity = codexWsReuseIdentity(url, headers, frameText); +- session = (identity ? codexWsPool.acquire(identity, wsUpstreamUrlFor(url), headers) : null) +- ?? new CodexWsSession(wsUpstreamUrlFor(url), headers); ++ const identity = codexWsReuseIdentity(url, headers, frameText, proxy); ++ session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null) ++ ?? new CodexWsSession(wsUrl, headers, false, undefined, proxy); + if (!session.busy && !session.reserve()) { + session.dispose(); + return sseFallback(url, init); +diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md +index 4ee22c114..a45a98c87 100644 +--- a/structure/04_transports-and-sidecars.md ++++ b/structure/04_transports-and-sidecars.md +@@ -435,7 +435,7 @@ These are transport-fidelity guarantees, not a provider-billing guarantee. + + Eligible complete-input creates can retain a canonical upstream socket within + one selected account, credential, thread and turn. Model/tier and immutable +-handshake headers must also match. Turn-state and turn-metadata headers are ++handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are + projected into their same-name per-frame metadata slots; explicit body values win. + The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and + retires a socket after five minutes or 32 successful exchanges (after active work +@@ -644,7 +644,11 @@ the upgrade with 426 so Codex falls back to HTTP cleanly. + + That setting controls the client-facing upgrade only. The transparent upstream + ChatGPT WS optimization described above is selected independently and still +-returns the same downstream SSE contract. ++returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the ++first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not ++route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the ++existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket ++egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY. + + The endpoint handles `response.create`, ignores `response.processed`, supports warmup + `generate: false`, and feeds the same request pipeline as HTTP/SSE. +diff --git a/tests/responses/ws-upstream-reuse.test.ts b/tests/responses/ws-upstream-reuse.test.ts +index fd0a8fb5a..b957fdb31 100644 +--- a/tests/responses/ws-upstream-reuse.test.ts ++++ b/tests/responses/ws-upstream-reuse.test.ts +@@ -6,6 +6,8 @@ import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-reque + + const URL = "https://chatgpt.com/backend-api/codex/responses"; + const realWebSocket = globalThis.WebSocket; ++const proxyEnvKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; ++let savedProxyEnv: Record; + let sequence = 0; + + class Socket extends EventTarget { +@@ -13,7 +15,7 @@ class Socket extends EventTarget { + static onSend: (socket: Socket, frame: Record) => void = (socket) => socket.complete(); + readyState = 0; + frames: Record[] = []; +- constructor(readonly url: string) { ++ constructor(readonly url: string, readonly options?: { proxy?: string }) { + super(); + Socket.all.push(this); + queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.dispatchEvent(new Event("open")); } }); +@@ -58,7 +60,11 @@ function bodyWith(fields: Record) { + options.body = JSON.stringify({ ...JSON.parse(options.body as string), ...fields }); + return options; + } +-beforeEach(() => { globalThis.WebSocket = Socket as unknown as typeof WebSocket; }); ++beforeEach(() => { ++ globalThis.WebSocket = Socket as unknown as typeof WebSocket; ++ savedProxyEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])); ++ for (const key of proxyEnvKeys) delete process.env[key]; ++}); + + afterEach(() => { + runOptionalShutdownHooks(); +@@ -67,6 +73,25 @@ afterEach(() => { + Socket.onSend = socket => socket.complete(); + sequence = 0; + globalThis.WebSocket = realWebSocket; ++ for (const key of proxyEnvKeys) delete process.env[key]; ++ for (const key of proxyEnvKeys) { ++ if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; ++ } ++}); ++ ++test("proxy changes and NO_PROXY retire the old route while unchanged routes reuse", async () => { ++ for (const proxy of ["http://proxy-a.example:8080", "http://proxy-b.example:8080"]) { ++ process.env.HTTPS_PROXY = proxy; ++ await drain(); ++ await drain(); ++ } ++ process.env.NO_PROXY = "chatgpt.com:443"; ++ await drain(); ++ await drain(); ++ expect(Socket.all.map(socket => socket.options?.proxy)) ++ .toEqual(["http://proxy-a.example:8080", "http://proxy-b.example:8080", undefined]); ++ expect(Socket.all.map(socket => socket.frames.length)).toEqual([2, 2, 2]); ++ expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 3, 1]); + }); + + test("same account/thread/turn reuses one socket without trimming either HTTP input", async () => { +diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts +index fd0951307..cfb087a4b 100644 +--- a/tests/responses/ws-upstream.test.ts ++++ b/tests/responses/ws-upstream.test.ts +@@ -1,4 +1,4 @@ +-import { afterEach, describe, expect, jest, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; + import { providerFetch } from "../../src/server/responses/fetch-helpers"; + import { handleResponses } from "../../src/server/responses"; + import { isEagerRelaySseResponse } from "../../src/server/relay"; +@@ -162,18 +162,24 @@ describe("shouldUseCodexWsUpstream", () => { + }); + + type Listener = (event: unknown) => void; ++type FakeWebSocketOptions = { ++ headers?: Record; ++ proxy?: string; ++}; + + /** Minimal scriptable stand-in for Bun's WebSocket. */ + class FakeWebSocket { + static instances: FakeWebSocket[] = []; + static script: (ws: FakeWebSocket) => void = () => {}; + url: string; ++ options?: FakeWebSocketOptions; + sent: string[] = []; + closed = false; + listeners = new Map(); + +- constructor(url: string) { ++ constructor(url: string, options?: FakeWebSocketOptions) { + this.url = url; ++ this.options = options; + FakeWebSocket.instances.push(this); + queueMicrotask(() => FakeWebSocket.script(this)); + } +@@ -205,12 +211,23 @@ class FakeWebSocket { + + const RealWebSocket = globalThis.WebSocket; + const RealFetch = globalThis.fetch; ++const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] as const; ++let savedProxyEnv: Record; ++ ++beforeEach(() => { ++ savedProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.map(key => [key, process.env[key]])); ++ for (const key of PROXY_ENV_KEYS) delete process.env[key]; ++}); + + afterEach(() => { + globalThis.WebSocket = RealWebSocket; + globalThis.fetch = RealFetch; + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; ++ for (const key of PROXY_ENV_KEYS) delete process.env[key]; ++ for (const key of PROXY_ENV_KEYS) { ++ if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; ++ } + }); + + function installFake(script: (ws: FakeWebSocket) => void) { +@@ -525,6 +542,41 @@ describe("codexWsUpstreamFetch", () => { + expect(text).not.toContain("must-not-leak"); + }); + ++ test("passes the selected proxy without changing handshake headers", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ installFake(ws => { ++ ws.emit("open", {}); ++ ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); ++ }); ++ ++ await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { ++ throw new Error("fallback must not run"); ++ }) as unknown as typeof fetch); ++ ++ const options = FakeWebSocket.instances[0]!.options; ++ expect(options?.proxy).toBe("http://proxy.example:8080"); ++ expect(options?.headers?.authorization).toBe("Bearer test"); ++ expect(options?.headers?.["openai-beta"]).toContain("responses_websockets"); ++ expect(options?.headers?.["content-type"]).toBeUndefined(); ++ }); ++ ++ test.each([ ++ ["unsupported protocol", "socks5://proxy.example:1080"], ++ ["invalid URL", "not a proxy URL"], ++ ])("falls back once without dialing for an %s", async (_label, proxy) => { ++ process.env.HTTPS_PROXY = proxy; ++ const sentinel = new Response("sse-fallback"); ++ let fallbackCalls = 0; ++ const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { ++ fallbackCalls += 1; ++ return sentinel; ++ }) as typeof fetch); ++ ++ expect(response).toBe(sentinel); ++ expect(fallbackCalls).toBe(1); ++ expect(FakeWebSocket.instances).toHaveLength(0); ++ }); ++ + test("relays event frames as an SSE response and sends one response.create frame", async () => { + installFake(ws => { + ws.emit("open", {}); +@@ -654,6 +706,7 @@ describe("codexWsUpstreamFetch", () => { + }); + + test("falls back to the HTTP fetch when the upgrade is rejected before open", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; + installFake(ws => ws.close()); + const sentinel = new Response("sse-fallback", { status: 429 }); + let fallbackCalls = 0; +@@ -666,6 +719,7 @@ describe("codexWsUpstreamFetch", () => { + expect(response).toBe(sentinel); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(fallbackCalls).toBe(1); ++ expect(FakeWebSocket.instances[0]!.options?.proxy).toBe("http://proxy.example:8080"); + }); + + test("falls back to the HTTP fetch when the upgrade deadline elapses without open or close", async () => { +@@ -800,15 +854,17 @@ describe("codexWsUpstreamFetch", () => { + }); + + test("preserves caller headers on the handshake without fabricating an originator", async () => { +- const seen: Record[] = []; ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ process.env.NO_PROXY = "chatgpt.com:443"; ++ const seen: FakeWebSocketOptions[] = []; + FakeWebSocket.script = ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); + }; + class HeaderCapturingWebSocket extends FakeWebSocket { +- constructor(url: string, options?: { headers?: Record }) { +- super(url); +- seen.push(options?.headers ?? {}); ++ constructor(url: string, options?: FakeWebSocketOptions) { ++ super(url, options); ++ seen.push(options ?? {}); + } + } + globalThis.WebSocket = HeaderCapturingWebSocket as unknown as typeof WebSocket; +@@ -817,18 +873,19 @@ describe("codexWsUpstreamFetch", () => { + await codexWsUpstreamFetch(CODEX_URL, streamingInit(), fallback); + // Without a caller originator none is invented: pool/forward traffic must + // not impersonate Codex CLI (metadata-integrity contract). +- expect(seen[0].originator).toBeUndefined(); +- expect(seen[0]["openai-beta"]).toContain("responses_websockets"); +- expect(seen[0].authorization).toBe("Bearer test"); ++ expect(seen[0].proxy).toBeUndefined(); ++ expect(seen[0].headers?.originator).toBeUndefined(); ++ expect(seen[0].headers?.["openai-beta"]).toContain("responses_websockets"); ++ expect(seen[0].headers?.authorization).toBe("Bearer test"); + // HTTP body-framing headers do not belong on a WS handshake. +- expect(seen[0]["content-type"]).toBeUndefined(); ++ expect(seen[0].headers?.["content-type"]).toBeUndefined(); + + // A genuine caller originator is forwarded verbatim. + await codexWsUpstreamFetch(CODEX_URL, { + ...streamingInit(), + headers: { ...streamingInit().headers as Record, originator: "codex_cli_rs" }, + }, fallback); +- expect(seen[1].originator).toBe("codex_cli_rs"); ++ expect(seen[1].headers?.originator).toBe("codex_cli_rs"); + }); + + test("aborting before open rejects like an aborted fetch", async () => { +@@ -1194,6 +1251,8 @@ describe("oversized Codex create frames", () => { + }); + + test("dials the configured provider's own wss URL for an opt-in upstream", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ process.env.NO_PROXY = "sub2api.example.com:443"; + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r-ws" } }) }); +@@ -1206,6 +1265,7 @@ describe("oversized Codex create frames", () => { + ); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(FakeWebSocket.instances[0]!.url).toBe("wss://sub2api.example.com/v1/responses"); ++ expect(FakeWebSocket.instances[0]!.options?.proxy).toBeUndefined(); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(await response.text()).toContain("response.completed"); + }); +diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts +index e43ad2d9b..c795c6cf2 100644 +--- a/tests/server/proxy-env.test.ts ++++ b/tests/server/proxy-env.test.ts +@@ -1,8 +1,10 @@ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { createServer } from "node:http"; + import { applyProxyEnv } from "../../src/config"; ++import { resolveProxyRoute } from "../../src/lib/proxy-env"; + import type { OcxConfig } from "../../src/types"; + +-const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; ++const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; + let saved: Record; + + beforeEach(() => { +@@ -30,6 +32,128 @@ function configWithRawProxy(proxy: unknown, noProxy?: unknown): OcxConfig { + return { proxy, noProxy, providers: {} } as unknown as OcxConfig; + } + ++describe("resolveProxyRoute", () => { ++ test("wss uses HTTPS_PROXY and never HTTP_PROXY", () => { ++ const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); ++ expect(resolveProxyRoute(target, { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ HTTP_PROXY: "http://plain-proxy.example:8080", ++ })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); ++ expect(resolveProxyRoute(target, { ++ HTTP_PROXY: "http://plain-proxy.example:8080", ++ })).toEqual({ kind: "direct" }); ++ }); ++ ++ test.each([ ++ ["exact host", "wss://chatgpt.com/path", "chatgpt.com", "direct"], ++ ["domain suffix", "wss://api.chatgpt.com/path", ".chatgpt.com", "direct"], ++ ["wildcard suffix", "wss://api.chatgpt.com/path", "*.chatgpt.com", "direct"], ++ ["wss default port", "wss://chatgpt.com/path", "chatgpt.com:443", "direct"], ++ ["ws default port", "ws://chatgpt.com/path", "chatgpt.com:80", "direct"], ++ ["port mismatch", "wss://chatgpt.com/path", "chatgpt.com:80", "proxy"], ++ ["bracketed IPv6", "wss://[2001:db8::1]/path", "[2001:db8::1]:443", "direct"], ++ ["URL-style entry", "wss://chatgpt.com/path", "https://chatgpt.com/ignored", "direct"], ++ ] as const)("honors NO_PROXY for %s", (_label, target, noProxy, expectedKind) => { ++ expect(resolveProxyRoute(new URL(target), { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ NO_PROXY: noProxy, ++ }).kind).toBe(expectedKind); ++ }); ++ ++ test("uses stable proxy precedence and fails closed on the first unusable proxy", () => { ++ const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); ++ const route = (env: Record) => resolveProxyRoute(target, env); ++ expect([ ++ route({ HTTPS_PROXY: "http://upper-https:1", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), ++ route({ HTTPS_PROXY: " ", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3" }), ++ route({ ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), ++ route({ all_proxy: "https://lower-all:4" }), ++ route({ HTTPS_PROXY: "socks5://unsupported:1080", ALL_PROXY: "http://must-not-win:3" }), ++ route({ HTTPS_PROXY: "not a proxy URL", ALL_PROXY: "http://must-not-win:3" }), ++ route({}), ++ ]).toEqual([ ++ { kind: "proxy", proxy: "http://upper-https:1" }, ++ { kind: "proxy", proxy: "http://lower-https:2" }, ++ { kind: "proxy", proxy: "http://upper-all:3" }, ++ { kind: "proxy", proxy: "https://lower-all:4" }, ++ { kind: "fallback" }, ++ { kind: "fallback" }, ++ { kind: "direct" }, ++ ]); ++ }); ++ ++ test("preserves uppercase NO_PROXY precedence when it is explicitly empty", () => { ++ expect(resolveProxyRoute(new URL("wss://chatgpt.com/path"), { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ NO_PROXY: "", ++ no_proxy: "chatgpt.com", ++ })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); ++ }); ++ ++ test("Bun WebSocket sends WSS through an HTTP CONNECT proxy", async () => { ++ let resolveConnect!: (target: string) => void; ++ const connected = new Promise(resolve => { resolveConnect = resolve; }); ++ const proxy = createServer(); ++ proxy.on("connect", (request, socket) => { ++ resolveConnect(request.url ?? ""); ++ socket.end("HTTP/1.1 502 Probe Complete\r\nContent-Length: 0\r\n\r\n"); ++ }); ++ await new Promise((resolve, reject) => { ++ proxy.once("error", reject); ++ proxy.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = proxy.address(); ++ if (!address || typeof address === "string") throw new Error("proxy did not bind a TCP port"); ++ const socket = new WebSocket("wss://proxy-probe.invalid/backend-api/codex/responses", { ++ proxy: `http://127.0.0.1:${address.port}`, ++ } as unknown as string[]); ++ try { ++ expect(await Promise.race([ ++ connected, ++ new Promise((_, reject) => setTimeout(() => reject(new Error("CONNECT was not observed")), 5_000)), ++ ])).toBe("proxy-probe.invalid:443"); ++ } finally { ++ try { socket.close(); } catch { /* probe is already complete */ } ++ await new Promise(resolve => proxy.close(() => resolve())); ++ } ++ }, 10_000); ++ ++ test.skipIf(process.platform !== "win32")("Bun fetch honors NO_PROXY on Windows", async () => { ++ let providerRequests = 0; ++ let proxyRequests = 0; ++ const provider = createServer((_request, response) => { ++ providerRequests += 1; ++ response.end("direct"); ++ }); ++ const proxy = createServer((_request, response) => { ++ proxyRequests += 1; ++ response.end("proxied"); ++ }); ++ const listen = async (server: typeof provider): Promise => { ++ await new Promise((resolve, reject) => { ++ server.once("error", reject); ++ server.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = server.address(); ++ if (!address || typeof address === "string") throw new Error("server did not bind a TCP port"); ++ return address.port; ++ }; ++ const [providerPort, proxyPort] = await Promise.all([listen(provider), listen(proxy)]); ++ process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`; ++ process.env.NO_PROXY = "127.0.0.1"; ++ try { ++ expect(await (await fetch(`http://127.0.0.1:${providerPort}/models`)).text()).toBe("direct"); ++ expect(providerRequests).toBe(1); ++ expect(proxyRequests).toBe(0); ++ } finally { ++ await Promise.all([ ++ new Promise(resolve => provider.close(() => resolve())), ++ new Promise(resolve => proxy.close(() => resolve())), ++ ]); ++ } ++ }); ++}); ++ + describe("applyProxyEnv with values the schema does not constrain", () => { + test("warns once per discarded proxy setting without exposing its raw value", () => { + const secret = "raw-proxy-credential-sentinel-2947"; +@@ -122,6 +246,14 @@ describe("applyProxyEnv", () => { + expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080"); + }); + ++ test.each(["ALL_PROXY", "all_proxy"])("config fills a scheme proxy ahead of %s for WSS", key => { ++ process.env[key] = "http://fallback-proxy.example:8081"; ++ applyProxyEnv(configWithProxy("http://configured-proxy.example:8080")); ++ expect(process.env[key]).toBe("http://fallback-proxy.example:8081"); ++ expect(resolveProxyRoute(new URL("wss://chatgpt.com/backend-api/codex/responses"))) ++ .toEqual({ kind: "proxy", proxy: "http://configured-proxy.example:8080" }); ++ }); ++ + test("appends loopback entries to an existing NO_PROXY without duplicating", () => { + process.env.NO_PROXY = "internal.corp,localhost"; + applyProxyEnv(configWithProxy("http://proxy.corp:8080")); +@@ -217,4 +349,3 @@ describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => { + expect(process.env.HTTP_PROXY).toBeUndefined(); + }); + }); +- +``` diff --git a/devlog/_fin/260906_a_runtime_stack/021_ws_refresh.md b/devlog/_fin/260906_a_runtime_stack/021_ws_refresh.md new file mode 100644 index 0000000000..f6de6471c3 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/021_ws_refresh.md @@ -0,0 +1,3 @@ +# WebSocket layer P refresh + +Consume 020 above prepared SSE parent 4b34cbb8d, with source #3679 b05cccf264b4ab61db5d8dee8232c2f89bb1b541. Public author updated the old head and resolved the three original review threads. Retain Clive Rosfield attribution and -x source identity. Existing shared proxy-formats documentation contains SSE paragraph; preserve both sections. B owns concurrent providerContextLimits config changes; A updates only applyProxyEnv. This layer stays independently verified and draft while full CI runs; main merges only after full required gates. No local project checks. diff --git a/devlog/_fin/260906_a_runtime_stack/030_recovery.md b/devlog/_fin/260906_a_runtime_stack/030_recovery.md new file mode 100644 index 0000000000..92accfca44 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/030_recovery.md @@ -0,0 +1,208 @@ +# 030 — Native MESSAGE recovery and cached replay (#3568) + +Status: candidate implementation plan, researched 2026-09-06 KST. This is a +docs-only deliverable. Revalidate during this layer's P after preceding layers +land; no implementation or verification pass is claimed here. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Loop specification and scope + +- Class: C4 for the existing recovery admission boundary; C3 for destination + normalization. Archetype: spec-satisfaction repair, one implementation PABCD + cycle for this decade document. +- Trigger: native parent MESSAGE delivery or replayed encrypted task history on + an opted-in routed child. Goal: preserve the admitted plaintext assignment and + deliver supported plaintext Go Responses agent messages. +- Non-goals: #3571 catalog/effort ordering, multipart recovery, native-backend + retry policy, new credential sources, recovery enabled by default, new routing + metadata protocol, deployment/release work, or a general solution to #3661. +- Verifier: exact-layer remote focused regressions, full Cross-platform CI, + privacy and type gates, and independent recovery-boundary review. Commands + below are planned for remote execution only; none ran during planning. +- Stop condition: reviewed prepared draft and exact-head remote focused/type evidence; full CI/dev inclusion are required by 080 before feature completion. Partial #3661 stays open. +- Memory artifact: this file and main-owned `000` roadmap/evidence ledger. +- Outcomes: DONE only with the evidence above; NOOP only if current dev already + contains equivalent behavior and regressions; BLOCKED for external CI/review + dependencies; UNSAFE/NEEDS_HUMAN for a necessary expansion of admission policy. +- Delegation: inherited parallel read-only reviewers authorized. Downward scope + changes require a P amendment; main reclaims a packet after two distinct worker + failures. Main owns FSM, implementation, commits and stack integration. +- Resource scope: existing gh credentials; later writes restricted to own stack + branches and scoped PR administration. This worker writes only this plan and + `040_affinity.md`. No explicit user token/cost cap; a 2-hour checkpoint triggers + reassessment, not an automatic success or exhaustion claim. No local tests, + typecheck, build, Git mutation or GitHub mutation in this planning task. +- Public record rule: this file describes already-public PR behavior and general + integration requirements. Any new security investigation belongs in `.tmp/`. + +## Provenance and current source + +Live GitHub dev and local HEAD both resolve to +`81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Original PR +[3568](https://github.com/lidge-jun/opencodex/pull/3568) head is +`036a9321788464fdf33a387c9f44a834a844bdc1`, retained as +`refs/codex/a-original/3568`. The earlier `origin/a-original-*` refs were pruned; +do not depend on them. Read the complete feature diff using +`git diff origin/dev...refs/codex/a-original/3568`, not `HEAD^..HEAD` (the last +two commits are documentation corrections). + +Original author: `voiys ` (GitHub `voiys`). Preserve these +commits in order when carrying the work: + +1. `e8f8726040dbc45b1e946d59db6b9c477459b8d7` — recovery implementation. +2. `4464892336c75b8861ee4caeddfd97d6c4e0e6ab` — canonical Go destination docs. +3. `036a9321788464fdf33a387c9f44a834a844bdc1` — forward-auth exception docs. + +A rewritten/squashed carrying commit and final squash body must contain +`Co-authored-by: voiys `; cite the original PR in the new PR. +Do not force-push the contributor branch. + +Source anchors at the inspected dev SHA: + +- `src/server/responses/agent-task-recovery.ts:61`: envelope type; line 72 + accepts only NEW_TASK; line 74 selects the supported tail envelope; line 180 + injects validated plaintext; line 277 performs admission and line 287 creates + the existing cache key, including message type and parent scope. +- `src/server/responses/agent-task-recovery-cache.ts:23`: existing deletion/byte + accounting; line 43 sets original expiry; line 117 owns resolving cache/flight + behavior. Reuse these owners instead of adding another cache. +- `src/server/responses/core.ts:3233`: final-route recovery gate currently also + requires an unreadable current task. Lines 3261–3282 own reparsing, preserved + continuation fields and the existing non-persistable-body handling. +- `src/adapters/openai-responses.ts:2354`: body expansion/previous-response + handling before effort mapping is the original insertion point. +- `structure/10_adapter-registry.md:5`: adapter factory authority remains the + registry. `opencode-go.ts` below is a destination helper, not a new adapter id. + +Owner search used `isOpenCodeGo`, `normalizeOpenCodeGoAgentMessages`, +`recoverEncryptedAgentTask` and recovery-cache exports. No equivalent Go helper +exists in current dev. Doing nothing retains the public regression; configuration +alone cannot admit MESSAGE or restore history. Reuse admission, injection, cache +deletion and Responses construction; do not duplicate them. + +## Exact implementation change map + +| Action / path | Before → planned after | +|---|---| +| MODIFY `src/server/responses/agent-task-recovery.ts` | Widen `AgentEnvelope.messageType` and the local parse variable to `"NEW_TASK" \| "MESSAGE"`; ROUTING_HEADER captures either and assigns the captured value. Add `restoreCachedEncryptedAgentTasks(req,input,config,{parentThreadId})`: scan only agent_message entries, reuse `admittedRecovery` on each singleton, read the existing cache, and call `injectAssignment` only for a valid hit; return restored count. Fresh recovery continues to handle only the supported tail. | +| MODIFY `src/server/responses/agent-task-recovery-cache.ts` | Export `cachedAgentTaskRecovery(key): string \| null`; return null on miss; delete expired entries with existing `deleteRecoveryCacheEntry`; return live assignment without extending TTL, creating a flight or performing network I/O. | +| MODIFY `src/server/responses/core.ts` | Import restoration helper. Retain Responses/spawn/opt-in/final-route/combo/pass-through exclusions, remove only the outer unreadable-tail prerequisite, restore history first, recompute unreadability, and attempt fresh recovery only when still needed. Feed actual successful restoration/recovery into the existing reparse/route-selection path; preserve continuation fields and existing non-persistence handling. | +| NEW `src/adapters/opencode-go.ts` | Add `isOpenCodeGo(baseUrl)` using URL origin `https://opencode.ai` and normalized path `/zen/go/v1`; malformed/other URLs return false. Add `normalizeOpenCodeGoAgentMessages(body)` with unchanged-reference no-op; convert only nonempty agent_message content arrays entirely composed of input_text/input_image/input_file into user messages; preserve original content parts and add readable author/recipient context. No encrypted/unknown-part conversion. | +| MODIFY `src/adapters/openai-responses.ts` | Import helpers; after `stripPreviousResponseId`, apply normalization only for `!forward && isOpenCodeGo(provider.baseUrl)`, before effort mapping. Preserve raw replay body and existing session headers. | +| NEW `tests/providers/opencode-go-agent-messages.test.ts` | Carry original provider tests and add canonical-Go forward-auth, renamed-provider/trailing-slash URL, malformed/other URL and input_file/empty/mixed unknown-part cases. Assert adapter output and source-body identity, not helper existence. | +| NEW `tests/server/server-agent-task-recovery-replay.test.ts` | Carry original replay/MESSAGE/mixed-history tests. Extend real handler coverage for known history plus a fresh tail and for cache-only continued turns. Check outbound body and recovery fetch counts, not just helper return values. | +| MODIFY `tests/server/agent-task-recovery-cache.test.ts` | Exercise the new read-only accessor on hit, miss and exact expiry; assert repeated reads do not extend lifetime or create recovery flights and expiry uses existing byte-accounting deletion. Reuse existing clock isolation. | +| MODIFY `scripts/test-layout/layout.json` | Register `opencode-go-agent-messages.test.ts` under providers and `server-agent-task-recovery-replay.test.ts` under server in `explicit`. Preserve other registrations. | +| MODIFY `tests/fixtures/test-layout-expected.json` | Add the same two basename/domain mappings. | +| MODIFY `docs-site/src/content/docs/reference/adapters.md` | Carry original non-forward canonical-Go conversion paragraph and recovery link. | +| MODIFY `docs-site/src/content/docs/reference/configuration/providers.md` | Carry original Go section specifying URL, adapter, forward exclusion, cached history versus fresh-tail behavior and context-only identities. | + +No DELETE paths. Existing tests/security/fallback/combo helpers are read/reused; +extend an existing test file only by a documented P amendment if its fixture is +the right home for an uncovered acceptance row. No catalog files in this layer. + +The enum chain is complete: creation is ROUTING_HEADER capture in +`findEnvelope`; serialization is `recoveryPayload` at line 303 plus the existing +message-type cache-key hash at line 292; deserialization/unknown handling remains +the strict envelope matcher and assignment validation at line 171; consumers are +admission, fresh recovery, cache restoration and injection. There is no persisted +enum migration. Recipient consistency remains enforced by existing envelope +validation; do not claim a new independent recipient cache-key field. + +## Activation and independent acceptance + +| Trigger | Observable acceptance | +|---|---| +| Opted-in valid MESSAGE on routed spawned Responses | One recovery request containing MESSAGE; provider receives recovered text; response succeeds. NEW_TASK remains equivalent. | +| Previously admitted ciphertext replayed after tool output or user continuation | Restored plaintext reaches actual provider body; recovery-call count does not increase. | +| Cached NEW_TASK + cached MESSAGE + distinct uncached current MESSAGE | Each known entry restores its own payload; only tail creates one fresh recovery; later replay creates no further recoveries. | +| Unknown historical ciphertext and a recoverable tail | Historical entry remains unchanged; do not claim batch history recovery. Keep existing terminal decision behavior when unsupported unreadability remains. | +| Miss, exact expiry, repeated reads before expiry | No replacement/fetch on read miss; unchanged original expiry and bounded accounting. | +| Other parent/caller/account/message type, malformed envelope or unsupported type | No cache restoration; original input remains unchanged. Existing admission negative suite stays green. | +| Recovery absent/disabled, native forward, trusted pass-through, combo attempt | Existing routing/admission behavior remains; opt-out makes no newly introduced recovery request. | +| Canonical Go non-forward plaintext text/image/file message | Public user message with original parts and readable identities; raw replay input not mutated. | +| Go forward, another destination, unknown/encrypted part, empty content | No Go conversion. Test canonical Go forward directly, not only ChatGPT forward. | +| Recovery success followed by reparse | Continuation fields survive; current route/selection and existing non-persistable-body treatment remain correct. | + +This layer must pass without #3581 or #3571. Main integrates this core change +before #3581 and coordinates any C-lane #3576 core edits. Do not use stack order +to invent a dependency on unrelated SSE/WebSocket changes; revalidate shared +core and documentation context after their integration. + +## Reviews, drift and landing handoff + +Live PR is non-draft, MERGEABLE, REVIEW_REQUIRED. GraphQL returned two resolved +threads, zero unresolved. Preserve both corrections: +[canonical destination](https://github.com/lidge-jun/opencodex/pull/3568#discussion_r3939042861) +and [forward exception](https://github.com/lidge-jun/opencodex/pull/3568#discussion_r3939864549). +The earlier four-topic maintainer review was addressed by moving catalog work to +#3571; do not restore those removed hunks. Its mixed-history concern is represented +in current original tests and the acceptance table. Sender/recipient text is +model context only. The original author reports 19,287 full-suite passes and a +live test on an equivalent local release patch; neither proves the new stack head. + +At later P, compare original feature patch against the actual parent tree, +refresh PR head/reviews and identify new exact-path overlap. Carry all three +original commits, preserve authorship and review corrections, then add focused +integration corrections separately. Main may push own stack branches with +`--no-verify` as authorized. Parent merge/squash requires child replay onto the +new dev ancestry and new head evidence; retarget children before deleting parent +branches. Close carried #3568 only after dev contains the result. Reference +#3661 as partial coverage, never `Closes #3661` for this slice. + +## Remote-only verification plan + +Planning exception to PLAN-VERIFIER-REAL-01: user forbids running tests, +typecheck/build locally and requests static workflow inspection now. Every +command here has execution status **NOT RUN**, exit code **N/A**. Later main +records remote command, exact checkout SHA, result and log URL/receipt. + +Remote execution handoff (main verified): `the isolated remote verification host` has +`REMOTE_SOURCE_CHECKOUT` and Bun 1.3.14. Main creates an isolated remote clone +and checks out the exact carried SHA; the existing checkout is a source for +setup, not a shared mutable test directory. Implementation C runs focused +activation tests and typecheck there. Carry PR remains draft until full +current-head GitHub CI is green; final landing cycle requires every full gate. +The local package pins Bun 1.4.0, so the Bun 1.3.14 focused result is supplemental +and cannot replace the workflow's configured-runtime full gates. + +In that isolated remote checkout, focused C commands are: + +```sh +bun test tests/server/server-agent-task-recovery-replay.test.ts tests/providers/opencode-go-agent-messages.test.ts tests/server/agent-task-recovery-cache.test.ts +bun test tests/server/agent-task-recovery.test.ts tests/server/agent-task-recovery-security.test.ts tests/server/agent-task-recovery-fallback.test.ts tests/server/agent-task-recovery-combo.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts +bun run typecheck +``` + +Full landing gates, on remote runners only: + +```sh +bun run test +bun run privacy:scan +bun --cwd docs-site run build +``` + +Direct test arguments observe the named target/imports; layout guards observe +both manifests. `package.json:43` defines the full test script, +`scripts/test.ts:321` adds `./tests/`, and `tsconfig.json:15` includes `src`. +The docs build is a separate remote requirement; ordinary runtime CI does not +prove prose accuracy. Review the two docs against actual adapter conditions. + +Statically verified CI coverage: `.github/workflows/ci.yml:7` has no PR-base +filter, so child PRs qualify; lines 182–186 match `src/**`, `tests/**` and +`scripts/**`. Linux line 316 calls `scripts/ci/run-bun-test-batches.sh`, whose +line 197 enumerates tests recursively and line 58 accepts `.test.ts` files. +macOS line 532 and Windows line 754 run the tests directory in shards. Lines +422–431 run typecheck and privacy. Require actual producer jobs to succeed; +green intake/aggregate checks with skipped tests are insufficient. + +Main's alternative manual CI invocation is +`gh workflow run ci.yml --repo lidge-jun/opencodex --ref OWN_LAYER_BRANCH -f lane=all`. +The workflow supports lane, not an invented expected-SHA input. Capture the run's +headSha and checkout provenance and reject stale results; PR workflows normally +test the synthetic merge ref, so record both PR head and tested merge SHA. +No workflow or runner approval was issued by this planner. diff --git a/devlog/_fin/260906_a_runtime_stack/031_recovery_refresh.md b/devlog/_fin/260906_a_runtime_stack/031_recovery_refresh.md new file mode 100644 index 0000000000..9112a2c8d7 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/031_recovery_refresh.md @@ -0,0 +1,3 @@ +# Recovery layer P refresh + +Consume 030 above prepared WS parent10fbda2e0. Original #3568 remains open at036a9321788464fdf33a387c9f44a834a844bdc1; carry all three voiys commits in order. No catalog/effort hunks from #3571. Add the planned cache exact-expiry/no-TTL-extension and canonical-Go conversion negatives, with a scoped inherited worker owning only the named three regression files after original carry. Main owns production integration, author commits and review. Runtime correction: isolated checks now invoke repository node_modules/.bin/bun and assert package.json dependencies.bun=1.4.0 before any execution. Full per-head CI remains mandatory before landing. #3661 remains partial, with no automatic close reference. diff --git a/devlog/_fin/260906_a_runtime_stack/040_affinity.md b/devlog/_fin/260906_a_runtime_stack/040_affinity.md new file mode 100644 index 0000000000..8c1380a72e --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/040_affinity.md @@ -0,0 +1,210 @@ +# 040 — Command Code conversation affinity (#3581) + +Status: candidate implementation plan, researched 2026-09-06 KST. Depends on the +verified `030_recovery.md` layer for stack integration into its reparse owner. +This first-cycle artifact is docs only; re-read current source at this layer's P. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Loop specification and scope + +- Class: C4 for conversation/cohort isolation; archetype: spec-satisfaction + repair. One implementation PABCD cycle owns this document. +- Trigger: repeated Command Code requests from the same identifiable conversation. + Goal: stable opaque session affinity without treating shared cache cohorts as + individual conversations; enable API-key provider cache-key forwarding. +- Non-goals: Hermes #3433 diagnosis, measured cache-hit/cost promises, OAuth + refresh changes, a global session registry, prompt-text-derived identity, + default trust for unclassified cache keys, or extra OAuth cache-key forwarding. +- Verifier: remote identity/forwarding/reparse regressions, full current-head CI, + privacy/type gates and independent boundary review. No local verifier runs. +- Stop: reviewed prepared draft atop recovery, with exact-head remote focused/type evidence. Full current-head gates and dev ancestry remain required in 080. +- Memory artifact: this file plus main-owned roadmap/ledger. Main alone owns + FSM, goal, implementation, Git and stack integration. +- Resources: existing gh credentials and later own-branch writes only. Inherited + parallel reviewers authorized; downward changes are a P amendment and main + reclaims after two distinct worker failures. No explicit user token/cost cap; + 2-hour checkpoint triggers reassessment. This planner writes only the two + assigned documents; no Git/GitHub mutations or tests/typecheck/build. +- Public scope: already-public patch behavior and general integration plan only; + new security investigation notes belong in `.tmp/`. + +## Provenance and source anchors + +Live dev/local HEAD: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +[Original PR #3581](https://github.com/lidge-jun/opencodex/pull/3581) head and its +single feature commit: `f60397d3408e0339ffc66acdcaca8133e40866c2`, retained at +`refs/codex/a-original/3581`. Author: `SB Yoon +<44089734+yansigit@users.noreply.github.com>` (GitHub `yansigit`), original +authored date 2026-09-05T01:58:50Z. Preserve original author on carry and include +`Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com>` in any +rewritten/squashed landing. Do not rewrite the contributor's branch. + +Current source still uses `randomUUID()` unconditionally at +`src/adapters/command-code.ts:528`. The helper insertion owner is the same file +after `projectSlug` at line 212. Current `src/server/responses/core.ts:2954` +parses the request, line 2987 assigns inbound thread id, lines 2990–3004 classify +the separate replay scope, and line 3262 preserves fields after recovery. +`src/types/request.ts:71` holds `_clientThreadId` without the proposed cohort +field. `src/providers/registry.ts:2169` is API-key `commandcode`; line 1332 is +OAuth `command-code`. They are separate transport contracts. + +Owner searches: `commandCodeSessionId`, `promptCacheKeyIsSharedCohort`, +`prompt_cache_key`, `_clientThreadId`, `_reasoningReplayScope`. Existing +classification, provider derivation and Chat serialization already exist; reuse +them. `src/providers/xai-transport.ts:101` has a different provider's derivation; +do not reuse its namespace/contract for Command Code. Doing nothing retains +random affinity, configuration cannot change the header builder, and no matching +Command Code helper exists. No new cache/service dependency is warranted. + +## Exact implementation change map + +| Action / path | Before → planned after | +|---|---| +| MODIFY `src/adapters/command-code.ts` | Import `createHash` alongside `randomUUID`; add exported `commandCodeSessionId(parsed)`. Select trimmed `_clientThreadId`, else trimmed replay `clientThreadId`, else trimmed `options.promptCacheKey` only when cohort marker is exactly false. With no identity return randomUUID. Hash `command-code:${kind}\0${identity}` with SHA-256 and form the original opaque UUID-shaped value, preserving explicit version/variant nibble comment. Use helper for x-session-id. | +| MODIFY `src/types/request.ts` | Add optional internal `_promptCacheKeyIsSharedCohort?: boolean` beside `_clientThreadId`; document true=shared, false=explicitly conversation-scoped, absent=unclassified. Do not expose it as a client JSON input field. | +| MODIFY `src/server/responses/core.ts` | Immediately after initial `parseRequest(body)`, copy `options.promptCacheKeyIsSharedCohort` onto parsed internal marker. Add marker to the existing `kept` list in recovery reparse, now containing #3568 restoration. Preserve all sibling fields and both true and false values (undefined-only filtering). | +| MODIFY `src/providers/registry.ts` | Add `promptCacheKey: true` only to `commandcode` API-key provider. Leave OAuth `command-code` transport setting unchanged. | +| MODIFY `tests/providers/command-code-provider.test.ts` | Carry stable/opaque identity, precedence, different-identity, UUID shape and random fallback tests; add whitespace-only fallback and same literal under different identity-kind cases. Assert actual built x-session-id as well as helper output. | +| MODIFY `tests/providers/commandcode-provider.test.ts` | Extend registry expectation and construct real Chat request with promptCacheKey, asserting prompt_cache_key body forwarding. Retain explicit disabled-provider override behavior. | +| MODIFY `tests/claude-integration/claude-code-thought-signature-scope.test.ts` | Carry true/false/undefined propagation assertions in existing drive helper; retain the independent replay-scope expectations. | +| MODIFY `tests/server/server-agent-task-recovery-replay.test.ts` | Parent-layer test file exists after 030. Add real handler/adaptor-boundary observation of marker preservation for recovery and cache-only restoration, with true/false/undefined cases. Use the existing fixture/post helper; no source-text assertion as a substitute for executing reparse. | +| MODIFY `docs-site/src/content/docs/reference/adapters.md` | Add a concise Command Code subsection describing OAuth x-session-id priority/random fallback and API-key commandcode prompt_cache_key forwarding separately; no cache-performance promise. This is a docs-sync addition beyond the original seven-file patch. | + +No NEW or DELETE production/test files. The modified replay test is owned by the +parent layer and already registered there. No layout manifest update is needed +for modifying it. Keep the new helper in its existing adapter: no parallel +factory registration or session cache. `structure/10_adapter-registry.md:5` +remains authoritative and needs no factory-policy change; adapters.md is the +user-visible contract sync target. + +## Explicit handler-fixture amendment + +MODIFY `tests/helpers/agent-task-recovery.ts:144-159`: extend the sixth `post` options argument with `promptCacheKeyIsSharedCohort?: boolean`, and forward it to the fourth `handleResponses` options argument alongside abortSignal and translatorBudget. Do not put this internal field in the JSON request body. Existing callers default to undefined and remain unchanged. + +MODIFY `tests/server/server-agent-task-recovery-replay.test.ts`: parameterize true/false/undefined, use the extended `post` helper for an initial admitted recovery and a continued cache-only replay, and observe the parsed request at the real selected adapter buildRequest boundary via a temporary spy restored after each test. Assert the exact internal marker and existing thread/replay metadata on both calls; assert only one recovery backend call. The later P must bind the spy to the actual exported adapter selector in that carried tree. A source-text assertion is not an alternative to the real reparse execution. + +## Complete field and value chain + +1. Creation: `src/server/claude-messages.ts:836` passes + `promptCacheKeyIsSharedCohort: cacheKeySource === "system"` into + `HandleResponsesOptions` (`core.ts:1548`). The new initial-parse assignment + carries true/false/undefined unchanged. `_clientThreadId` and replay scope use + their existing ingress owners; do not infer new trust from request content. +2. Internal transfer: `OcxParsedRequest` optional field and the `kept` list copy + it across `parseRequest` after both fresh and cached recovery. It is process + request metadata, not persisted configuration or continuation data. +3. Serialization/deserialization: the internal marker has no wire representation + and no persisted migration (N/A intentionally). `parseRequest` at + `src/responses/parser.ts:526` already maps public prompt_cache_key into options; + clients cannot supply the internal classification through that mapping. +4. Consumers: `commandCodeSessionId` permits the cache-key fallback only for + `=== false`; true/undefined both fail closed. Existing replay/cohort consumers + at `core.ts:2990`, `core.ts:3560` and + `src/oauth/anthropic-routing.ts:781` keep their distinct semantics; do not + broaden/rewrite those predicates as incidental cleanup. +5. Provider capability chain: registry promptCacheKey → + `src/providers/derive.ts:252` defaults and line 512 reconciliation → routed + provider config → `src/adapters/openai-chat.ts:1573` serialization (and raw + body forwarding at line 156). Original API-key regression observes the wire + body, rather than only asserting registry metadata. + +## Activation and independent acceptance + +| Trigger | Required observation | +|---|---| +| Same trimmed explicit thread, differing replay/cache values | Same opaque x-session-id in actual built requests; thread wins. | +| No explicit thread, same trimmed replay identity | Stable header; changing replay identity changes it. | +| Neither thread nor replay, nonempty key with marker false | Stable cache-derived header; whitespace trimmed. | +| Same literal in thread/replay/cache namespaces | Different opaque values by kind; preserve original hash namespace. | +| Shared=true or unclassified marker, only cache key/prompt text | Fresh UUID each request; no prompt/body-derived identity. | +| Empty/whitespace identity or no identity | Random fallback, no accidental stable empty-string cohort. | +| Explicit thread with shared=true | Explicit thread remains valid; shared classification disqualifies only cache fallback. | +| Initial parse then successful fresh or cache-only recovery reparse | Adapter observes original true/false/undefined marker and original thread/replay metadata; stable affinity semantics survive. | +| API-key commandcode using route-derived config | Chat body carries prompt_cache_key when present/enabled; absent key or explicit disabled capability omits it. | +| OAuth command-code | Uses proprietary x-session-id builder; this patch does not opt its registry entry into Chat cache-key forwarding. | +| Synthetic raw identity strings | Header matches UUID-shaped contract and contains no raw identity. No added identity logging. | + +C must drive both the helper and real adapter/handler paths. This plan claims a +stable request header, not proven provider cache savings or a provider guarantee +that distinct sessions receive distinct workers. Any credentialed live provider +smoke needs main's chosen authorized runtime scope; a synthetic wire test is not +misreported as real upstream acceptance. + +## Review disposition, drift and stack order + +Live PR is non-draft, MERGEABLE, REVIEW_REQUIRED. GraphQL has zero review threads; +there is no current formal approval. The author already incorporated UUID +nibble explanation and retained API-key-only forwarding/unclassified-key +fallback in the original head. Latest +[author update](https://github.com/lidge-jun/opencodex/pull/3581#issuecomment-5549518114) +reports 18,244 passes on `be81013fa` base; those historical results do not validate +the current parent tree. Older draft/failure commentary is superseded. + +The original patch context predates the current core: original initial-parse +line 2896 is now 2954 and original reparse area around 3210 is now 3262. Carry by +function/field ownership; never replace current core with the older file. Refresh +onto the completed 030 layer and preserve both restoration behavior and the new +cohort marker. Coordinate the shared core with C-lane #3576 through main. This +is not a fix for #3433 and must not close that issue. + +Main publishes a child PR targeting the recovery branch if that PR is still +open; after parent squash/merge, replay only this layer onto dev and retarget. +Revalidate exact diff, review and CI for every new head. Original contributor +credit survives cherry-pick/reimplementation/squash. Own-branch `--no-verify` +pushes are authorized; local prepush hooks must not start a suite. Close original +#3581 once the equivalent change is proven on dev; do not close merely because +a carrying child PR exists. No Git/GitHub action is performed by this planner. + +## Remote-only verification and CI coverage + +All commands below: **NOT RUN, exit N/A during planning**, per explicit user +instruction. Later main runs them only in the remote checkout of the exact layer +and records SHA, command result and artifact/CI URL. + +Main verified `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun 1.3.14. Use an isolated +remote clone at the exact carried SHA for focused activation tests/typecheck; +do not mutate the existing remote checkout for this layer. Its Bun version +differs from package.json's 1.4.0 pin, so this is supplemental evidence. Carry +PR stays draft until full current-head GitHub CI is green. Final landing cycle +requires every full gate on the configured remote runners. + +Implementation C, in the isolated remote clone: + +```sh +bun test tests/providers/command-code-provider.test.ts tests/providers/commandcode-provider.test.ts tests/claude-integration/claude-code-thought-signature-scope.test.ts tests/server/server-agent-task-recovery-replay.test.ts +bun run typecheck +``` + +Full landing gates, remote only: + +```sh +bun run test +bun run privacy:scan +bun --cwd docs-site run build +``` + +Focused direct arguments cover identity selection, actual request headers/body, +cohort propagation and parent recovery interaction. `tsconfig.json:15` includes +src; `package.json:43` maps full suite to `scripts/test.ts`, whose line 321 adds +`./tests/`. No claim that typecheck covers prose. Docs require remote build plus +manual comparison of actual transport semantics. + +Static workflow proof: `.github/workflows/ci.yml:7` permits child PR bases; +lines 182–186 select runtime/tests, line 316 runs Linux batches, and +`scripts/ci/run-bun-test-batches.sh:197` recursively enumerates tests (accepted +suffixes at line 58). macOS line 532 and Windows line 754 cover tests shards. +Lines 422–431 run typecheck/privacy. Thus this runtime layer should activate +real jobs even though its parent is not dev. Runtime CI does not guarantee the +new documentation subsection's accuracy; review it explicitly. + +Optional later manual dispatch: +`gh workflow run ci.yml --repo lidge-jun/opencodex --ref OWN_LAYER_BRANCH -f lane=all`. +Record run headSha and actual checkout SHA; this workflow exposes only lane, +not expected-SHA pinning. For PR CI record current PR head and synthetic merge +SHA. Require completed successful producer jobs and independent review of the +current patch; author-reported tests, skipped producers, stale green heads and +hygiene checks cannot complete this layer. diff --git a/devlog/_fin/260906_a_runtime_stack/041_affinity_refresh.md b/devlog/_fin/260906_a_runtime_stack/041_affinity_refresh.md new file mode 100644 index 0000000000..38515e7742 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/041_affinity_refresh.md @@ -0,0 +1,5 @@ +# Affinity layer P refresh + +Consume 040 on prepared recovery parent332a30e6d. Original #3581 remains f60397d3408e0339ffc66acdcaca8133e40866c2, with SB Yoon attribution preserved. Retain new recovery cache/history logic and termination WeakMap rebind when applying the two core hunks. The new cohort flag must survive initial parse and both fresh/cache-only reparse; true/undefined never authorize cache-key-based session identity. No changes to OAuth command-code cache-key forwarding; enable the existing API-key commandcode registry capability only. + +Scoped regression worker after carry owns tests/helpers/agent-task-recovery.ts, tests/server/server-agent-task-recovery-replay.test.ts and tests/providers/command-code-provider.test.ts. Use the actual ADAPTER_REGISTRY openai-chat create seam already proven in the parent regression to observe parsed fields at real buildRequest. Main owns production and adapters documentation. Remote helper asserts project Bun1.4.0; no local suites/typecheck/build. Full exact-head CI and --admin integration remain final gates. diff --git a/devlog/_fin/260906_a_runtime_stack/050_capabilities.md b/devlog/_fin/260906_a_runtime_stack/050_capabilities.md new file mode 100644 index 0000000000..51514b6215 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/050_capabilities.md @@ -0,0 +1,684 @@ +# 050 — Effective provider capabilities (#3671) + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Candidate implementation contract + +Status: candidate planning, not implementation or merge approval. Revalidate at this layer's later P after its lower stack layer lands. This document is the delegated docs-only deliverable; the main agent owns roadmap registration, FSM, goal state, branch integration, CI dispatch and closure. + +- Class: C4 for the policy-boundary slice, based on the public PR's requested security review. Archetype: spec-satisfaction repair. +- Trigger: routing policy capability evidence must describe the effective provider dispatch uses, including unavailability. +- Goal: runtime selection and ordinary management dry-run agree on effective transport capabilities and exclude unresolved, missing, or disabled providers before scoring. +- Non-goals: new provider metadata, registry precedence redesign, catalog UI, OAuth refresh, request transport changes, Lab activation changes, release operations, or changing caller-supplied synthetic dry-run evidence semantics. +- Verifier: remote focused routing/API regressions plus exact-head full Cross-platform CI and a remote documentation build. No local tests, typecheck, builds, or verifier execution in this planning assignment. +- Stop: independently working reviewed draft with original authorship and exact-head remote focused/type evidence. Full current-head gates/dev ancestry remain required by 080. +- Memory artifact: this document and the main-owned roadmap/evidence ledger. +- Outcomes: DONE only after verified dev integration; NOOP only if current dev independently contains all behavior and regressions; BLOCKED for unavailable external CI/credentials; NEEDS_HUMAN/UNSAFE for a policy decision outside authorization; a resource checkpoint is reassessment, never fabricated completion. +- Delegation: inherited parallel read-only reviewers are authorized. Main reclaims a packet after two distinct failed workers; further write delegation requires a P amendment with exact ownership. +- Resources: existing gh credentials; future writes confined to the main's own stack branches and explicitly authorized PR/issue integration. This worker writes only this document. No explicit user token/cost cap. A two-hour checkpoint triggers reassessment and an evidence update. No deployment, account-state operation or provider request is necessary. + +## Provenance and refresh gate + +Inspected September 6, 2026 KST using read-only `gh pr view`, `gh api` reviews/workflow runs, `git show`, and `git diff`. + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3671 +- Exact original head: `7b1beb9c5eacd8dde22681a5df26804be52380b8`. +- Stable source ref: `refs/codex/a-original/3671`; old `origin/a-original-*` refs were pruned by parallel workers and must not be relied upon. +- Original base: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +- Inspected current dev/tree: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +- Original commits, oldest first: `2b1e0e00c12d7287f9324a4a39ec7e966712affe` (effective capability evidence); `7b1beb9c5eacd8dde22681a5df26804be52380b8` (unresolved transport exclusion). +- Both commits authored by **Hako <25837994+devswha@users.noreply.github.com>**, GitHub `@devswha`. Preserve those authors when carrying commits. Any squash/reimplementation and the carrying PR must retain `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>` so attribution survives integration. +- A read-only diff of original base against inspected dev shows no drift in the eight original touched files. This is a snapshot, not a promise about the later stack parent. +- #3679's refreshed source head is `b05cccf264b4ab61db5d8dee8232c2f89bb1b541`; it does not replace #3671 provenance. Re-read parent changes and resolve integration ownership at later P. + +At later P, compare live PR head, stable ref, actual stack parent and dev tip. Inspect each named source hunk and public review again. If any changed, amend this document before carrying the patch. Treat stacked ordering as a user-requested integration constraint; #3671 does not need #3568/#3581 runtime code to function and must be independently testable. + +## Source ownership and before/after map + +Reuse the existing `routedProviderConfig` callback seam; no new resolver, registry, server endpoint or config option is required. Doing nothing leaves policy and effective transport divergent; changing configured URLs or deleting capability checks does not fix the contract; duplicating registry logic creates drift. + +| Operation | Exact path | Before → after | +|---|---|---| +| MODIFY | `src/routing/capability.ts` | Lines 153–160 read raw config plus registry by name → optional resolved-provider argument is authoritative; name-only registry fallback applies only to legacy three-argument callers. Add provider-wide reasoning ladder at lines 223–227, retaining no-reasoning precedence. | +| MODIFY | `src/routing/compatibility/assemble.ts` | Lines 52–60 derive capabilities directly → resolve each active configured candidate through the supplied callback, emit bounded unavailability state on missing/disabled/throw, and compute capabilities only from a resolved provider. | +| MODIFY | `src/routing/evaluator.ts` | Evidence type near line 54 and eligibility lines 280–313 lack transport status → optional `routeResolutionFailed`, `route-unavailable` exclusion and hard eligibility gate independent of unknown policy. | +| MODIFY | `tests/routing/routing-capability-model-matching.test.ts` | Existing model-family tests → retain them and add the complete original effective-transport regression group plus missing/disabled selection regressions below. | +| MODIFY | `tests/routing/routing-profile.test.ts` | Existing management dry-run parity fixture at line 446 → add ordinary dry-run missing/disabled candidate matrix without injected `candidates`. | +| MODIFY | `docs-site/src/content/docs/guides/routing-profile-editor.md` | Dry-run section near line 39 lacks effective transport contract → original explanation plus explicit missing/disabled exclusion. | +| MODIFY | `docs-site/src/content/docs/fr/guides/routing-profile-editor.md` | Same change in French near line 38, preserving corrected typographic apostrophe. | +| MODIFY | `docs-site/src/content/docs/tr/guides/routing-profile-editor.md` | Same change in Turkish near line 53. Original trailing blank-line removal is incidental. | +| MODIFY | `docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md` | Same change in Traditional Chinese near line 33. | +| MODIFY | `structure/01_runtime.md` | Router ownership row at line 18 says selection only → describe shared effective-provider evidence and hard unavailable-candidate exclusion. | +| NEW | None in production/tests | Existing test files already have layout entries; do not add layout manifest churn. | + +Only this plan file is created now. The future layer has ten MODIFY paths. General SOT follows `structure/01_runtime.md`; user-facing truth remains the routing guide. No unpublished investigation details belong in this public unit. + +Read-only caller proof: `src/router.ts:299` owns effective registry transport/metadata; `src/router.ts:622` supplies it to assembly and line 625 evaluates; lines 626–635 route the selected provider or throw. `src/server/management/routing-profile-routes.ts:100` supplies the same resolver; lines 384–390 use assembly when `body.candidates` is absent. Preserve the synthetic-evidence branch. `src/routing/capability.ts:130` classifies effective locality; lines 179–193 preserve no-vision precedence. Core/Lab imports remain behind the existing provider slot (`assemble.ts:45`), with no new import of router from assembly. + +## Public review disposition + +Two prior findings are resolved in original head: French typography and thrown route resolution under permissive unknown policy. One remains open: https://github.com/lidge-jun/opencodex/pull/3671#discussion_r3941006079 . At original-head `assemble.ts:57`, missing/disabled providers skip the resolver but leave failure false. Set the initial state to `!provider || provider.disabled === true` and prove both ordinary dry-run and runtime selection. Do not resolve the review on the basis of this plan. + +Current original-head Cross-platform CI run `33973108478` and React Doctor run `33973108496` have conclusion `action_required`; label/hygiene/target success is not product verification. The PR body reports focused successes and a timeout-adjusted affected run, but explicitly does not claim a green default full suite. No such reported run is accepted as this carried layer's verification. Maintainer approval and explicit security review remain pending under `MAINTAINERS.md:57–61`. + +## Exact original carry diff + +Apply this public source patch as one coherent layer, preserving both original commits/author identity. The subsequent corrections below are required in the same layer before review readiness. This is recorded patch text, not an instruction to run local Git mutations during planning. + +````diff +diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +index b437c28b3..84b4410f7 100644 +--- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +@@ -37,6 +37,13 @@ résultat du plafond. + + ## Simuler un profil enregistré + ++Les capacités des candidats utilisent la configuration effective du fournisseur, ++après application du registre. Les exigences de localité (`localOnly` et ++`remoteAllowed`) utilisent donc l’adresse amont effective. Si elle ne peut pas être ++classée, `unknownEvidence.capability` détermine l’admissibilité du candidat. ++Une configuration de fournisseur invalide qui ne peut pas être résolue est toujours ++exclue avec `route-unavailable`, même si les capacités inconnues sont autorisées. ++ + Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. + + Les modifications non enregistrées ne sont pas prises en compte par la simulation. Enregistrez d’abord le profil afin que la révision et l’évaluation affichées correspondent à la même configuration. +diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md +index 5cf5fc6d7..7931f29ad 100644 +--- a/docs-site/src/content/docs/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/guides/routing-profile-editor.md +@@ -38,6 +38,13 @@ cap outcome. + + ## Dry-run a saved profile + ++Candidate capabilities use the effective provider configuration after registry ++overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) ++therefore use the effective upstream address. If that address cannot be classified, ++the profile's `unknownEvidence.capability` setting decides eligibility. ++An invalid provider configuration that cannot be resolved is always excluded with ++`route-unavailable`, even when unknown capabilities are allowed. ++ + Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. + + Unsaved edits are not used by dry-run. Save the profile first so the displayed revision and evaluation refer to the same configuration. +diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +index dd7aa50d7..ec75bdd17 100644 +--- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +@@ -52,6 +52,13 @@ ayrıdır. + + ## Kaydedilmiş bir profilde deneme çalıştırması (dry-run) yapma + ++Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlayıcı ++yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) ++bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, ++adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. ++Çözümlenemeyen geçersiz sağlayıcı yapılandırmaları, bilinmeyen yeteneklere izin ++verilse bile `route-unavailable` ile her zaman dışlanır. ++ + Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel + girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme + çalıştırması değerlendirmesi (Dry-run evaluation)**'ı kullanın. Deneme +@@ -99,5 +106,3 @@ Düzenleyici şu uç noktaları kullanır: + } + } + ``` +- +- +diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +index e6ae93a76..0b54e70d5 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +@@ -32,6 +32,9 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi + + ## 試跑已儲存的設定檔 + ++候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 ++無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 ++ + 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 + + 未儲存的編輯不會被試跑使用。請先儲存設定檔,讓顯示的 revision 與評估參照同一份設定。 +diff --git a/src/routing/capability.ts b/src/routing/capability.ts +index 8495951a0..7f26e8bbd 100644 +--- a/src/routing/capability.ts ++++ b/src/routing/capability.ts +@@ -10,7 +10,7 @@ + * how that affects eligibility. + */ + +-import { modelInList, type OcxConfig } from "../types"; ++import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types"; + import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; + import { serviceTierSupportForModel } from "../providers/service-tier"; + import { PROVIDER_REGISTRY } from "../providers/registry"; +@@ -149,14 +149,20 @@ function localRemoteEvidence(baseUrl: string | undefined): Pick entry.id === providerName); ++ const provider = resolvedProvider ?? config.providers[providerName]; ++ const registryEntry = resolvedProvider === undefined ++ ? PROVIDER_REGISTRY.find(entry => entry.id === providerName) ++ : undefined; + const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId); + const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/"); + +@@ -224,6 +230,7 @@ export function candidateCapabilityEvidence( + ? [] + : modelRecordValue(provider?.modelReasoningEfforts, modelId) + ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId) ++ ?? provider?.reasoningEfforts + ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); + + const tierSupport = provider +diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts +index 1d543690a..d7cebc94b 100644 +--- a/src/routing/compatibility/assemble.ts ++++ b/src/routing/compatibility/assemble.ts +@@ -52,11 +52,26 @@ export function assemblePolicyCandidateEvidence( + return profile.candidates.map(candidate => { + const key = `${candidate.provider}/${candidate.model}`; + const compatibility = compatibilityByCandidate?.get(key); ++ const provider = config.providers[candidate.provider]; ++ let routed: OcxProviderConfig | undefined; ++ let routeResolutionFailed = false; ++ if (provider && provider.disabled !== true) { ++ try { ++ routed = options.routedProviderConfig(candidate.provider, provider); ++ } catch { ++ // This is known unavailability, not unknown capability evidence. Keep ++ // the failure separate so permissive unknown policies cannot select it. ++ routeResolutionFailed = true; ++ } ++ } + + return { + provider: candidate.provider, + model: candidate.model, +- capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model), ++ ...(routeResolutionFailed ? { routeResolutionFailed: true } : {}), ++ capability: routed ++ ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) ++ : undefined, + health: policyCandidateHealthEvidence(config, candidate, now), + quota: quotaEvidenceForCandidate({ + provider: candidate.provider, +diff --git a/src/routing/evaluator.ts b/src/routing/evaluator.ts +index a07b83306..7cf801bfe 100644 +--- a/src/routing/evaluator.ts ++++ b/src/routing/evaluator.ts +@@ -54,6 +54,8 @@ export interface PolicyCandidateEvidence { + accountRef?: string; + /** Codex pool account id (provider "openai"); used to derive account-scoped quota evidence. */ + codexAccountId?: string; ++ /** A failed effective-transport resolution excludes the candidate under every unknown policy. */ ++ routeResolutionFailed?: boolean; + capability?: RouteCapabilityEvidence; + health?: RouteHealthEvidence; + quota?: RouteQuotaEvidence; +@@ -278,6 +280,8 @@ export function evaluatePolicyProfile( + ...requestRequirementFor(requestEvidence, evidence.capability), + ]; + const exclusions: RouteExclusionReason[] = []; ++ const routeUnavailable = evidence.routeResolutionFailed === true; ++ if (routeUnavailable) exclusions.push({ code: "route-unavailable" }); + const bad = unsatisfiedOrUnknown(requirements); + for (const requirement of bad) { + if (requirement.outcome === "unsatisfied") { +@@ -310,7 +314,7 @@ export function evaluatePolicyProfile( + if (unknownCostBlocked) { + exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" }); + } +- let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; ++ let eligible = !routeUnavailable && !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; + + // Trace/dry-run copy only: report the profile cap that was applied and the + // operator-visible outcome. Do not feed this copy into costScore() — that +diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts +index bb956c2d8..509eeec2e 100644 +--- a/tests/routing/routing-capability-model-matching.test.ts ++++ b/tests/routing/routing-capability-model-matching.test.ts +@@ -1,10 +1,19 @@ +-import { describe, expect, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { mkdtempSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { validateConfigCandidate } from "../../src/config"; ++import { NoEligiblePolicyCandidateError, routeModel, routedProviderConfig } from "../../src/router"; + import { candidateCapabilityEvidence } from "../../src/routing/capability"; ++import { assemblePolicyCandidateEvidence } from "../../src/routing/compatibility/assemble"; + import { evaluatePolicyProfile } from "../../src/routing/evaluator"; ++import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; ++import { getRoutingProfile } from "../../src/routing/profile"; + import { PROVIDER_REGISTRY } from "../../src/providers/registry"; + import { modelRecordValue } from "../../src/reasoning-effort"; + import { isModelTextOnly } from "../../src/vision"; +-import type { OcxConfig, OcxProviderConfig } from "../../src/types"; ++import type { OcxConfig, OcxProviderConfig, OcxRoutingProfileConfig } from "../../src/types"; ++import { removeTreeWithRetry } from "../helpers/remove-tree"; + + /** + * `candidateCapabilityEvidence` describes what the resolver will do with a candidate, +@@ -35,6 +44,224 @@ function configFor(provider: OcxProviderConfig): OcxConfig { + return { providers: { custom: provider } } as unknown as OcxConfig; + } + ++describe("policy capability evidence uses the effective provider", () => { ++ let testDir: string; ++ let previousHome: string | undefined; ++ ++ beforeEach(() => { ++ previousHome = process.env.OPENCODEX_HOME; ++ testDir = mkdtempSync(join(tmpdir(), "ocx-effective-capability-")); ++ process.env.OPENCODEX_HOME = testDir; ++ }); ++ ++ afterEach(() => { ++ closeRequestHistoryIndex(); ++ if (previousHome === undefined) delete process.env.OPENCODEX_HOME; ++ else process.env.OPENCODEX_HOME = previousHome; ++ removeTreeWithRetry(testDir); ++ }); ++ ++ function policyConfig( ++ name: string, ++ provider: OcxProviderConfig, ++ model: string, ++ require: OcxRoutingProfileConfig["require"], ++ ): OcxConfig { ++ const result = validateConfigCandidate({ ++ port: 10100, ++ defaultProvider: name, ++ providers: { [name]: provider }, ++ routingProfiles: { guarded: { candidates: [{ provider: name, model }], require } }, ++ }); ++ if (!result.ok) throw new Error(result.error); ++ return result.config; ++ } ++ ++ const localOnly = { localOnly: true, remoteAllowed: false }; ++ const loopback = "http://127.0.0.1:11434/v1"; ++ ++ test("a loopback URL discarded by registry routing cannot satisfy a local-only policy", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "deepseek-v4-flash", localOnly); ++ const before = structuredClone(config); ++ ++ expect(routeModel(config, "deepseek/deepseek-v4-flash").provider.baseUrl) ++ .toBe("https://api.deepseek.com"); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ expect(config).toEqual(before); ++ }); ++ ++ test.each(["custom-local", "ollama"])("a genuine local %s endpoint remains eligible", name => { ++ const config = policyConfig(name, { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", localOnly); ++ const before = structuredClone(config); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe(name); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.requirements).toEqual([ ++ { id: "local-only", expected: true, actual: true, outcome: "satisfied" }, ++ { id: "remote-allowed", expected: false, actual: false, outcome: "satisfied" }, ++ ]); ++ expect(config).toEqual(before); ++ }); ++ ++ test("an explicitly public endpoint remains ineligible for a local-only policy", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", ++ }, "deepseek-v4-flash", localOnly); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("a local candidate is selected after excluding a registry-pinned remote candidate", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "deepseek-v4-flash", localOnly); ++ config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; ++ config.routingProfiles!.guarded!.candidates.push({ provider: "local", model: "local-model" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); ++ }); ++ ++ test("registry no-vision defaults participate before policy image requirements", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", ++ modelInputModalities: { "deepseek-v4-flash": ["text", "image"] }, ++ }, "deepseek-v4-flash", { imageInput: true }); ++ const routed = routeModel(config, "deepseek/deepseek-v4-flash"); ++ expect(isModelTextOnly(routed.provider, routed.modelId)).toBe(true); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("the effective model context ceiling gates a policy requirement", () => { ++ const config = policyConfig("openai-apikey", { ++ adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", ++ modelContextWindows: { "gpt-6-astra": 2_000_000 }, ++ }, "gpt-6-astra", { minContextWindow: 1_500_000 }); ++ const routed = routeModel(config, "openai-apikey/gpt-6-astra"); ++ expect(routed.provider.modelContextWindows?.["gpt-6-astra"]).toBe(1_050_000); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("canonical forward auth filled by routing satisfies the encrypted-task requirement", () => { ++ const config = policyConfig("openai", { ++ adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", ++ }, "gpt-5.5", { encryptedCodexTasks: true }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.provider.authMode).toBe("forward"); ++ expect(route.routeDecision?.candidates[0]?.capability?.encryptedCodexTasks).toBe(true); ++ expect(config.providers.openai!.authMode).toBeUndefined(); ++ }); ++ ++ test("the effective provider-wide reasoning ladder participates in policy selection", () => { ++ const config = policyConfig("xiaomi-mimo", { ++ adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", ++ }, "mimo-v2.5", { reasoningEffort: "high" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.provider.reasoningEfforts).toEqual(["low", "medium", "high"]); ++ expect(route.routeDecision?.candidates[0]?.capability?.reasoningEfforts) ++ .toEqual(["low", "medium", "high"]); ++ expect(config.providers["xiaomi-mimo"]!.reasoningEfforts).toBeUndefined(); ++ }); ++ ++ test("a same-named custom transport does not inherit an unrelated registry model map", () => { ++ const config = policyConfig("meta-model", { ++ adapter: "openai-responses", baseUrl: "https://custom.example/v1", ++ }, "muse-spark-1.3", { reasoningEffort: "high" }); ++ const routed = routeModel(config, "meta-model/muse-spark-1.3"); ++ expect(routed.provider.baseUrl).toBe("https://custom.example/v1"); ++ expect(routed.provider.modelReasoningEfforts).toBeUndefined(); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("an invalid unselected transport cannot prevent a healthy sibling from routing", () => { ++ const config = policyConfig("local", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", {}); ++ config.providers.ollama = { adapter: "openai-chat", baseUrl: " " }; ++ config.routingProfiles!.guarded!.candidates.push({ provider: "ollama", model: "local-model" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.candidates[1]?.capability).toBeUndefined(); ++ }); ++ ++ test("an unresolved transport contributes no positive capability evidence", () => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ modelInputModalities: { "local-model": ["text", "image"] }, ++ }, "local-model", { imageInput: true }); ++ config.providers.ollama!.baseUrl = " "; ++ ++ const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { ++ routedProviderConfig, ++ }); ++ expect(evidence[0]?.capability).toBeUndefined(); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("missing and disabled providers are not resolved for capability evidence", () => { ++ const config = policyConfig("local", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", { tools: true }); ++ config.providers.disabled = { ...config.providers.local!, disabled: true }; ++ config.routingProfiles!.guarded!.candidates.push( ++ { provider: "missing", model: "model" }, ++ { provider: "disabled", model: "model" }, ++ ); ++ const resolved: string[] = []; ++ const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { ++ routedProviderConfig: (name, provider) => { ++ resolved.push(name); ++ return routedProviderConfig(name, provider); ++ }, ++ }); ++ ++ expect(resolved).toEqual(["local"]); ++ expect(evidence[0]?.capability?.tools).toBe(true); ++ expect(evidence[1]?.capability).toBeUndefined(); ++ expect(evidence[2]?.capability).toBeUndefined(); ++ }); ++ ++ test.each(["allow", "penalize", "exclude"] as const)( ++ "an unresolved first candidate is excluded when unknown capabilities are %s", ++ capability => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ }, "local-model", {}); ++ config.providers.ollama!.baseUrl = " "; ++ config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; ++ const profile = config.routingProfiles!.guarded!; ++ profile.candidates.push({ provider: "local", model: "local-model" }); ++ profile.unknownEvidence = { ...profile.unknownEvidence, capability }; ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); ++ expect(route.routeDecision?.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); ++ expect(JSON.stringify(route.routeDecision)).not.toContain("Invalid baseUrl"); ++ }, ++ ); ++ ++ test("all unresolved candidates produce a policy exclusion while explicit routing keeps validation", () => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ }, "local-model", {}); ++ config.providers.ollama!.baseUrl = " "; ++ ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ expect(() => routeModel(config, "ollama/local-model")).toThrow('Invalid baseUrl for provider "ollama"'); ++ }); ++}); ++ + describe("candidateCapabilityEvidence model matching", () => { + test("a family entry covers its tagged siblings, as the resolver does", () => { + const provider = providerWithFamilyEntries(); + +```` + +## Required correction on top of the original head + +In `src/routing/compatibility/assemble.ts`, change exactly: + +```diff +- let routeResolutionFailed = false; ++ let routeResolutionFailed = !provider || provider.disabled === true; +``` + +Keep the active-provider `if` and catch intact. Missing/disabled providers must never invoke the resolver. Exception messages must not be copied into evidence or traces. + +Append this test inside the original effective-provider describe block in `tests/routing/routing-capability-model-matching.test.ts`, using its `policyConfig`, `loopback` and cleanup fixtures: + +```ts + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `${unavailable} first candidate is excluded under %s unknown policy`, + capability => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + const profile = config.routingProfiles!.guarded!; + profile.candidates.unshift({ provider: unavailable, model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence( + config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }, + ); + expect(resolved).toEqual(["local"]); + expect(evidence[0]?.routeResolutionFailed).toBe(true); + expect(evidence[0]?.capability).toBeUndefined(); + const evaluation = evaluatePolicyProfile(config, "guarded", {}, evidence); + expect(evaluation.selectedIndex).toBe(1); + expect(evaluation.candidates[0]?.eligible).toBe(false); + expect(evaluation.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + expect(routeModel(config, "policy/guarded").providerName).toBe("local"); + profile.candidates.pop(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }, + ); + } +``` + +Append inside the existing describe in `tests/routing/routing-profile.test.ts`. Imports and `baseConfig`/ManagementRequest fixtures already exist: + +```ts + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `API dry-run excludes ${unavailable} provider under %s unknown policy`, + async capability => { + const config = baseConfig({ + providers: { + local: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true }, + }, + defaultProvider: "local", + routingProfiles: { + guarded: { + candidates: [ + { provider: unavailable, model: "local-model" }, + { provider: "local", model: "local-model" }, + ], + require: {}, + unknownEvidence: { capability }, + }, + }, + }); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + for (const withSibling of [true, false]) { + if (!withSibling) config.routingProfiles!.guarded!.candidates.pop(); + const req = new ManagementRequest("http://localhost/api/routing-profiles/dry-run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: "guarded", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + expect(response!.status).toBe(200); + const body = await response!.json() as { + selectedIndex: number | null; + candidates: Array<{ eligible: boolean; exclusions: Array<{ code: string }> }>; + }; + expect(body.selectedIndex).toBe(withSibling ? 1 : null); + expect(body.candidates[0]?.eligible).toBe(false); + expect(body.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + } + }, + ); + } +``` + +The empty `require` is deliberate: it activates the bug even when capability unknown handling has no unsatisfied requirement to mask it. Existing synthetic `candidates` fixtures retain their meaning. No upstream request is needed for these scenarios. + +After each original guide paragraph, add the corresponding exact sentence: + +| Path | Added text | +|---|---| +| `docs-site/src/content/docs/guides/routing-profile-editor.md` | Missing or disabled providers are also excluded with `route-unavailable` before scoring. | +| `docs-site/src/content/docs/fr/guides/routing-profile-editor.md` | Les fournisseurs absents ou désactivés sont également exclus avec `route-unavailable` avant le calcul des scores. | +| `docs-site/src/content/docs/tr/guides/routing-profile-editor.md` | Eksik veya devre dışı sağlayıcılar da puanlama öncesinde `route-unavailable` ile dışlanır. | +| `docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md` | 缺少或停用的供應商也會在評分前以 `route-unavailable` 排除。 | + +SOT edit: + +```diff +-| `src/router.ts` | Provider/model selection before adapter dispatch. | ++| `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | +``` + +## Activation and independent acceptance + +| Trigger | Observable proof, required remotely | +|---|---| +| Canonical DeepSeek with configured loopback URL overridden by registry | Direct resolution shows canonical remote destination; local-only policy rejects it. Genuine custom-local and Ollama loopback remain eligible; config snapshots are unchanged. | +| Registry no-vision defaults, capped native API context, filled forward auth and provider-wide reasoning ladder | Policy sees exactly the effective dispatch values; no-reasoning empty ladder remains a known negative. Same-name custom transport does not gain unrelated registry metadata. | +| Resolver throws for first candidate under allow/penalize/exclude | First candidate has `route-unavailable`, no positive capability and `eligible=false`; healthy sibling wins. All invalid candidates produce NoEligiblePolicyCandidateError; explicit routing preserves its validation error. | +| Missing/disabled first candidate; empty requirements under each unknown policy | Resolver spy only sees active sibling, unavailable evidence true; evaluator and runtime choose sibling, all-unavailable policy fails. | +| Ordinary management dry-run without supplied candidate evidence | Missing/disabled candidates remain excluded; selectedIndex is sibling index or null with no sibling. This proves the public review fix at the actual API owner. | +| Core-only runtime and compatibility provider slot | Core/Lab boundary tests pass; no new import chain, timer or asynchronous activation is introduced. | +| Existing three-argument capability helper callers and synthetic dry-run fixtures | Existing model-family/catalog/profile tests remain green; optional argument is backward compatible. | + +This layer is independently acceptable only with the original carry plus the review correction and all regression cases together. The predecessor contributes the integration baseline, not deferred tests. Main must independently review the public policy-boundary change; do not rely on the original CodeRabbit status alone. + +## Remote verifiers and static workflow coverage + +Main verified `the isolated remote verification host`, its existing `REMOTE_SOURCE_CHECKOUT` checkout, and Bun `1.3.14`. Implementation C uses a separate isolated remote clone at the exact carried SHA; do not alter or run checks in that existing checkout. Record the clone path and resolved SHA with every receipt. No local project command execution is permitted at any point. + +Implementation C runs focused activation regressions and typecheck remotely. The carrying PR stays draft until full current-head GitHub CI is green; focused success alone does not authorize readiness or landing. The final landing cycle requires every full gate, including the separately verified documentation build and required reviews. Deeper implementation review belongs to the next cycle, after candidate-plan revalidation. + +These commands are plans for an isolated remote checkout of the exact carried commit, **not commands to execute on the local Mac**. Record host, exact SHA, command, exit status and full output artifact. Use fixture homes and no real provider traffic. + +```sh +# REMOTE ONLY: exact-head focused behavior and core/Lab boundaries +bun test tests/routing/routing-capability-model-matching.test.ts tests/routing/routing-capability-catalog.test.ts tests/routing/policy-execution.test.ts tests/routing/routing-profile.test.ts tests/routing/routing-compatibility.test.ts tests/routing/compatibility-provider-equivalence.test.ts tests/lab/core-lab-boundary.test.ts +# REMOTE ONLY: complete source checks; hosted CI may supply this evidence instead +bun run typecheck +bun run test +bun run privacy:scan +# REMOTE ONLY: docs build in an isolated verification checkout, without publication +cd docs-site +bun install --frozen-lockfile +bun run build +``` + +For red/green proof, use the remote original carry head without the one-line correction but with the new regression cases: new missing/disabled cases must fail. Add correction on the remote verification candidate and show the same cases green. Do not run this experiment by rewriting this shared worktree. + +Static inspection at `81871b3fa` confirms: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base filter, so a child PR targeting a parent branch is covered. Push alone to `codex/*` does not trigger it (`:26–27`); an opened PR or authorized workflow_dispatch is necessary. +- `ci.yml:182–185` includes `src/**` and `tests/**`; this layer must produce `changes.ci=true`. Original workflows awaiting approval provide no test evidence. +- `ci.yml:254–316` runs four Linux shards with `scripts/ci/run-bun-test-batches.sh`. Inspect logs for actual file execution and all shards, including split API/storage jobs, rather than only aggregate status. +- `ci.yml:422–431` covers typecheck and privacy. macOS and Windows suites are configured at `:451`, `:532`, `:625`, and `:754`; inspect the actual selected lane and successful test jobs, not skipped jobs. +- `ci.yml:906` aggregate permits intentionally skipped jobs. An aggregate green with skipped required product jobs is insufficient for this source-changing layer. +- `.github/workflows/deploy-docs.yml:3–9` triggers on main/docs changes or manual dispatch, and contains deployment. It is **not** a PR docs-build verifier. Do not dispatch publication to obtain validation. Use the remote build-only commands above. No CI workflow edits are needed in this layer. + +The roadmap cycle changes documentation only; expensive tests may correctly skip there. That success cannot be reused for the later implementation head. Each restack or review fix requires new exact-head evidence. + +## Main-owned stack delivery and closure + +Carry original commits in order, then commit the targeted review correction and regressions. Publish only own branches with the user's authorized `--no-verify` push policy. Do not rewrite the contributor branch. Populate every repository PR-template section, stack parent link and source attribution. No local gate bypass can substitute for remote product CI. + +Merge bottom-up once each layer's exact current head meets review/CI gates. When a lower PR is squash-merged, restack/retarget descendants before deleting lower branches; verify the new parent ancestry and each child diff. Preserve author trailers in the final squash body. + +After main verifies the carrying merge SHA is an ancestor of freshly fetched `origin/dev`, immediately close original #3671 as superseded if it did not auto-close, linking the carrying PR/merge. If original #3671 itself is merged, verify its merged state. Do not close it on branch push, PR creation, CI success alone, or merge only into a stack parent. No additional issue is identified as fully resolved by this unit. + +## Planning proof + +Only this document was written by this worker. The embedded original patch was read directly from the pinned original objects. Source/caller/workflow checks are static; no local tests, typecheck, build, Git mutation, GitHub mutation, goal or FSM transition was performed. The main-owned later P must revalidate all candidate hunks and review state. diff --git a/devlog/_fin/260906_a_runtime_stack/051_capability_refresh.md b/devlog/_fin/260906_a_runtime_stack/051_capability_refresh.md new file mode 100644 index 0000000000..190122e007 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/051_capability_refresh.md @@ -0,0 +1,5 @@ +# Effective-capability layer P refresh + +Consume050 on prepared affinity6b00fa8d6. Original #3671 remains7b1beb9c5, with two Hako commits. Carry both; fix the remaining public review by initializing routeResolutionFailed to !provider || provider.disabled===true before resolving enabled candidates. Preserve synthetic caller-supplied dry-run evidence semantics and the core/Lab slot boundary. Explicitly test missing and disabled candidates under allow/penalize/exclude, with healthy sibling and with none, at both runtime/evaluator and ordinary management dry-run. + +Regression worker owns only tests/routing/routing-capability-model-matching.test.ts and tests/routing/routing-profile.test.ts. Main owns production, four existing guide locales and runtime SOT. No local tests/typecheck/build; pinned remote Bun1.4 --isolate focused checks, actual API assertions, privacy/docs/fullCI before landing. Windows verifier repair is registered as an additional required cycle and does not weaken any earlier gate. diff --git a/devlog/_fin/260906_a_runtime_stack/070_windows_fixtures.md b/devlog/_fin/260906_a_runtime_stack/070_windows_fixtures.md new file mode 100644 index 0000000000..daab52ed55 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/070_windows_fixtures.md @@ -0,0 +1,123 @@ +# 070 — Deterministic Windows shutdown-spill fixtures + +Status: P amendment; documentation only. Future implementation is a separate C2 test-harness cycle after 050, before 080. C confirmed no ownership collision. Main owns the FSM, implementation, remote execution and insertion of this foundation beneath the runtime stack. + +## Evidence and boundary + +[Windows job 101339545421](https://github.com/lidge-jun/opencodex/actions/runs/33978547130/job/101339545421), head `4b34cbb8d3f308cd2b01e8d87784c65afb50a40f`, Bun 1.4.0: 3048 pass, 39 skip, 2 fail, 1 unhandled error. The two failures are in `tests/responses/responses-state.test.ts`: + +- Stable-tail (1297): the 500 ms drain timer selected synchronous fallback; its unmocked ACL runner failed with EICACLS. The actual async-delay cause is unmeasured. Global ACL call 7 is also an unreliable publication marker: snapshot and directory hardening share this runner. +- Reserved budget (1438): only the ACL clock is synthetic. `spill-store.ts:232` charged real serialization/filesystem elapsed time and exhausted the deadline before temp-file hardening. The expensive operation is not identified. + +Local evidence inputs: `.tmp/a-runtime-stack/ci-triage/report.md`, `sse-windows5.log:4038–4217`, and `prior-101262480176.log:3894–3897` in that same scratch directory. The earlier job passed the two cases; its overall run was not green. Unchanged source on the sampled dev is not an independently reproduced current-dev failure. Do not describe this as an SSE regression, a proven harmless transient, or a green Windows gate. + +Future edit set: **only `tests/responses/responses-state.test.ts`**. No production, workflow, manifest, shared fixture or budget changes. Reuse `forceWindowsAclLane`, `isSpillAclTarget`, `ICACLS_OK`, existing clock/runner setters, and spill event recording. Keep existing deadline, fallback, exhaustion and watchdog tests. No sleeps for synchronization, timeout increases, skips or relaxed assertions. + +Read-only owners inspected: + +| Owner | Contract retained | +|---|---| +| `src/responses/state.ts:595` | Drain races the observed tail against a real timer; `Date.now` alone cannot freeze that timer. | +| `src/responses/state.ts:771` and `:813` | Separate fallback reserve, remaining-budget forwarding, and repeated observation until the publication tail is stable. | +| `src/responses/spill-store.ts:100`, `:153`, `:225` | Existing I/O events and injectable spill clock; each harden gets min(per-call cap, remaining whole-write budget). | +| `src/lib/windows-secret-acl.ts:360`, `:410`, `:589` | Async runner timer; injected ACL clock; grant/inheritance/remove calls consume one harden deadline. | +| `tests/responses/ws-upstream.test.ts:725` | Existing Bun `jest.useFakeTimers` / `advanceTimersByTime` / `useRealTimers` convention. | + +## Hunk 1 — Stable-tail ordering, not elapsed disk time + +At the test import, add `jest`. Retain 1000/500 budgets. Use fake timers **only within this test**, with `Date.now` fixed to a captured real epoch and ACL/spill clocks fixed consistently. Capture native `setImmediate` before enabling fake timers for an event-loop checkpoint; this drains runnable promise work without a sleep or timer advance. No new shared helper. + +Replace the global `aclCalls === 1/7` runner with gates on the first two distinct spill temp paths at `/grant:r`: + +```ts +const gatedTemps = new Set(); +setAsyncIcaclsRunnerForTests(async args => { + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; // includes snapshot, directory and later ACL steps + } + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { firstEntered(); await firstGate; } + if (gatedTemps.size === 2) { secondEntered(); await secondGate; } + } + return ICACLS_OK; +}); +let syncSpillCalls = 0; +setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls++; + return ICACLS_OK; +}); +``` + +Both principal resolvers remain synthetic through `forceWindowsAclLane`. Both ACL runners cover **every** target; filtering controls gating/counting, never whether a real subprocess is used. A fallback must fail the ordering oracle (`syncSpillCalls === 0`), rather than being hidden by the successful mock. + +Replace the current orchestration and 25 ms sleep with this exact ordering: + +1. Enter `try/finally` before the first enqueue/await. Enable fake timers and fixed epoch clock; install both clock setters. Enqueue first response and await its temp gate. +2. Start `flushResponseState`, immediately attach both settlement handlers, recording `flushed` and any error in a resolved outcome object. This avoids an unhandled rejection if an earlier assertion fails. +3. Enqueue second response **after** starting flush, then release first. Await second temp gate. Await a native `setImmediate` checkpoint, advance fake timers by 25 ms, then another native checkpoint. The drain timer stays below 500 ms; no real elapsed filesystem time can fire it. +4. Assert flush is still pending, exactly two distinct temp paths were gated, and no synchronous spill ACL calls occurred. Record `setSpillIoForTest({ record })` events and assert exactly one `stub-swap` so the first publication actually installed while the second is gated. +5. Release second, await the handled flush outcome and rethrow any captured error. Retain `{ residentCount: 0, spillStubCount: 2 }`; add pending `{ count: 0, bytes: 0 }`, two `stub-swap` events, and zero synchronous spill calls. Both stored response IDs must still expand to their distinct payloads. +6. `finally`: release **both** gates, await any started flush outcome and `flushPendingResponseSpillsForTests()` while mocks/clocks remain installed, then restore the Date spy and real timers in a nested `finally`. Existing `afterEach` restores setters. Never restore mocks while a gated async operation still owns work. + +Use a discriminated outcome (`{ ok: true } | { ok: false; error: unknown }`) rather than an undefined-error sentinel. Keep cleanup valid when either startup await/assertion fails. Fake-timer compatibility and the native checkpoint are remote Windows acceptance items, not assumed proof. Do not solve a failed fixture by globally suppressing timers or adding a production seam. + +## Hunk 2 — One logical fallback budget, actual drain timer + +At 1438, preserve `totalMs = 500`, `fallbackReserveMs = 300` and the pending async spill gate. Add the missing spill clock; scope a Date spy to the flush so outer fallback accounting and nested ACL accounting advance together. Keep native timers in this test: the unchanged 200 ms drain timer must expire while the async gate remains held. + +```diff + let aclClock = 0; + setNowForTests(() => aclClock); ++setResponseSpillNowForTests(() => aclClock); +``` + +Record `{ target, timeoutMs, spentBefore }` for **spill** synchronous ACL calls. Snapshot ACL calls return `ICACLS_OK` without charging the spill clock. For each spill call, record before incrementing `aclClock += 20`; preserve successful command results. + +```ts +const epoch = Date.now(); +const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); +// Start only after the async spill gate announces entry. +try { + await flushResponseState(); // native 200 ms drain timer selects sync fallback +} finally { + release(); + try { await flushPendingResponseSpillsForTests(); } + finally { nowSpy.mockRestore(); } +} +``` + +An enclosing `try/finally` must also cover enqueue and `await started`, releasing the gate on early failure. Preserve all three original assertions: at least six spill commands, maximum deadline <= 150, and `200 + aclClock <= 500`. Add: + +- Every timeout is positive and <= `300 - spentBefore` (independent literal budget oracle). +- Within each target's grant/inheritance/remove sequence, each next timeout is exactly 20 ms smaller; do **not** assert global monotonicity across targets because a new harden has its own per-call cap. +- The async gate has not been released when synchronous spill work begins; fallback actually ran, pending count/bytes become zero, one spill stub remains, and replay contains the original payload. + +The Date spy prevents unmeasured real disk latency from consuming this logical-budget fixture. It does not disable the native drain timer. Real-time termination coverage remains in the unchanged cap-expiry test (1339) and `shutdown fallback budget exhaustion is contained by a child watchdog` (1613), using `tests/helpers/responses-state-shutdown-budget-child.ts`. Do not claim this test measures OS elapsed latency. + +## Windows red, control and proof + +Main executes these later on real Windows with the repository-pinned Bun, in isolated remote checkouts. Nothing below authorizes local tests in this documentation task. + +1. Preserve the failed root-head job/logs above. Run the original two tests on the pinned pre-fix baseline; record actual results, including a pass. Do not require random failure or accept retries as a fix. +2. In remote scratch only, force the old stable-tail drain to expire by holding the second gate until a recorded fallback entry. Use a counted synchronous sentinel that reports EICACLS instead of invoking native ACL tools. Confirm rejection and the fallback call; never infer the missing-mock path from elapsed time alone. This is a controlled mechanism probe, not proof that the same delay happened in CI. +3. In remote scratch only, use the existing spill `record("write")` event to advance a separate wall clock by 301 ms once synchronous fallback has begun. On the original reserved-budget fixture, spill uses that clock and fails before temp hardening; with the proposed shared logical spill clock, the same wall-clock perturbation cannot consume the ACL budget. Record entry and clock values. Keep this probe separate from production and from the committed passing fixture. +4. Prove oracle sensitivity with isolated remote mutations: (a) stop drain after its first observed tail, expecting the revised stable-tail pending/zero-fallback oracle to fail; (b) reset the ACL deadline for each command, expecting per-target 20 ms decrease assertions to fail. Separately advance the **injected spill clock** beyond 300 at the write event and require ETIMEDOUT, proving deadline enforcement remains active. Restore every mutation before green verification; retain diff and failing assertion for each probe. +5. Run the unchanged named cap-expiry and child-watchdog controls, then the whole focused file on the new exact head: + +```sh +# Remote Windows only; these commands are a future verifier recipe. +bun test --isolate --timeout 60000 tests/responses/responses-state.test.ts +bun run typecheck +``` + +6. Dispatch the actual Windows full-suite workflow on that exact head, including `bun test --isolate --timeout 60000 tests --shard=5/6` and every other required shard. Inspect job execution, not aggregate success with skipped tests. Record head SHA, Bun version, commands, job URLs, counts and absence of unhandled errors. Run current-head Linux/macOS gates and required scans as well. + +Implementation D means an independently reviewed prepared foundation draft with exact-head focused Windows evidence and remote typecheck; it is **not landing**. Main inserts the verified foundation beneath the stack, refreshes descendants bottom-up with original attribution intact, obtains required current-head gates, then admin-merges in dependency order. Verify each landed SHA is an ancestor of freshly fetched dev before closing a superseded PR or fully resolved issue. Partial issues retain their residual scope. See `080_landing.md`. + +Documentation acceptance: this file names both failed fixtures, all clock/timer boundaries, complete runner/cleanup coverage, executable negative controls, one-file implementation scope and separate landing gates. No test execution or implementation success is claimed here. + +## Remote execution fallback amendment + +The existing direct Windows SSH endpoint is unavailable; the reachable auxiliary host is Linux without Windows interop. Use GitHub Actions for actual Windows proof. If the existing full-suite workflow cannot execute focused causal probes, a separate owner-only `codex/a-verify-windows` branch may hold a temporary verification workflow triggered only by pushes to that exact branch. This workflow is never included in a product PR or merged to dev. It uses `windows-latest`, read-only contents permission, pinned checkout with `persist-credentials: false`, the existing pinned-Bun setup, fixed repository test commands and the exact carried fixture commit. No secrets, untrusted command inputs, self-hosted runner access or release permissions. It may execute the narrowly specified scratch mutations with guaranteed source restoration and upload logs. Independent security audit of the concrete workflow is required before pushing it. Standard per-head full CI remains the final gate; the temporary verifier cannot mark those checks green. diff --git a/devlog/_fin/260906_a_runtime_stack/080_landing.md b/devlog/_fin/260906_a_runtime_stack/080_landing.md new file mode 100644 index 0000000000..760192edb8 --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/080_landing.md @@ -0,0 +1,23 @@ +# Verified stacked integration and closeout + +## Changes + +No production changes planned. MODIFY phase records with evidence; move this completed public unit from `devlog/_plan/260906_a_runtime_stack/` to `_fin/` only after all units have a terminal outcome. Private security evidence remains ignored. + +## Procedure + +1. Refresh each original and replacement PR head, base, unresolved reviews, required checks, and live dev. If source author advanced, compare unique new commits before disposition. +2. Require actual current-head full cross-platform tests and typecheck/privacy gates, not intake-only green or old author attestations. Record remote focused triggers as supplementary proof. Repair test failures by cause; never claim retry alone fixed a failure. +3. Verify each child contains its current parent's head and its base points at the parent. Merge the bottom with source author's account-linked Co-authored-by trailer preserved in squash body, or use merge commits to retain ancestry. Push owned branches with --no-verify; any necessary rewrite uses explicit --force-with-lease on owned refs only. +4. Retarget the next child to dev, rebase/merge as needed after a squash, verify its diff and fresh checks. Do not delete a parent branch before child retargeting. +5. Immediately fetch dev and prove `git merge-base --is-ancestor origin/dev`. Close the superseded source PR with the replacement PR and landing SHA. For original PRs directly merged, record merged state. +6. Read linked issue acceptance scope. Close only fully solved issues; #3661 MESSAGE recovery is partial and must retain the residual issue. Do not use automatic Closes for partial work. +7. Check final dev CI on its exact current SHA. Record all original/replacement PR URLs, contributor trailers, merge SHAs and issue dispositions. Complete goal only when every criterion has captured evidence and all FSM cycles closed. + +## Activation and failure cases + +Source-author update: compare the new head, carry any still-needed change and reverify. Concurrent dev movement: integrate it without losing another lane's changes. Squash changes parent identity: cascade child before any merge. Failed CI: inspect exact job/step logs, fix the failing scope, re-run on new head. Unresolved issue scope: leave open with specific residual, never close to improve counts. + +## Verifiers + +Run read-only `gh pr view`, `gh pr checks`, `gh run view` and `git merge-base --is-ancestor` at the real current heads. Local git ancestry and static diff checks are allowed. Runtime/typecheck/build verification occurs on GitHub Actions or isolated the isolated remote verification host checkouts only. Full CI pending means this landing cycle remains active. diff --git a/devlog/_fin/260906_a_runtime_stack/090_outcome.md b/devlog/_fin/260906_a_runtime_stack/090_outcome.md new file mode 100644 index 0000000000..0534bb8ecb --- /dev/null +++ b/devlog/_fin/260906_a_runtime_stack/090_outcome.md @@ -0,0 +1,29 @@ +# A runtime and routing outcome + +All five assigned originals are closed and their credited changes are on dev. The final stack entered dev through [#3716](https://github.com/lidge-jun/opencodex/pull/3716), merge [`a2f69c8aa`](https://github.com/lidge-jun/opencodex/commit/a2f69c8aa60976345740ae6f3d2301f89297328e). GitHub automatically recognized the folded review PRs as merged; their actual dev integration is recorded below. + +| Original | Reviewed carry | Dev integration | Full CI before dev integration | +| --- | --- | --- | --- | +| [#3672](https://github.com/lidge-jun/opencodex/pull/3672) | [#3683](https://github.com/lidge-jun/opencodex/pull/3683) | [#3683](https://github.com/lidge-jun/opencodex/pull/3683) · [`c6d8678f7`](https://github.com/lidge-jun/opencodex/commit/c6d8678f73ce6e1ae9df004ab032af09837b5b45) | [33981578769](https://github.com/lidge-jun/opencodex/actions/runs/33981578769) | +| [#3679](https://github.com/lidge-jun/opencodex/pull/3679) | [#3686](https://github.com/lidge-jun/opencodex/pull/3686) | [#3686](https://github.com/lidge-jun/opencodex/pull/3686) · [`a6d1065cf`](https://github.com/lidge-jun/opencodex/commit/a6d1065cfbadc7d8f9c02e17549908b42d2bfd7a) | [33981581047](https://github.com/lidge-jun/opencodex/actions/runs/33981581047) | +| [#3568](https://github.com/lidge-jun/opencodex/pull/3568) | [#3690](https://github.com/lidge-jun/opencodex/pull/3690) | [#3690](https://github.com/lidge-jun/opencodex/pull/3690) · [`6e15dad6a`](https://github.com/lidge-jun/opencodex/commit/6e15dad6a42682d5dbf3e61c51493385091e37a6) | [33981582675](https://github.com/lidge-jun/opencodex/actions/runs/33981582675) | +| [#3581](https://github.com/lidge-jun/opencodex/pull/3581) | [#3692](https://github.com/lidge-jun/opencodex/pull/3692) | [#3716](https://github.com/lidge-jun/opencodex/pull/3716) · [`a2f69c8aa`](https://github.com/lidge-jun/opencodex/commit/a2f69c8aa60976345740ae6f3d2301f89297328e) | [33991642514](https://github.com/lidge-jun/opencodex/actions/runs/33991642514) | +| [#3671](https://github.com/lidge-jun/opencodex/pull/3671) | [#3694](https://github.com/lidge-jun/opencodex/pull/3694) | [#3716](https://github.com/lidge-jun/opencodex/pull/3716) · [`a2f69c8aa`](https://github.com/lidge-jun/opencodex/commit/a2f69c8aa60976345740ae6f3d2301f89297328e) | [33991642514](https://github.com/lidge-jun/opencodex/actions/runs/33991642514) | + +Original author identities and account-linked Co-authored-by trailers were retained: Hako, Clive Rosfield, voiys and SB Yoon. The carried contributor commits remain ancestors of the final integration. Each source head was checked again before closure; the original WebSocket author rebase had an identical verified patch. + +## Additional verified repairs + +- #3696 stabilized Windows shutdown-spill fixtures with controlled clocks and complete ACL mocks; hosted Windows causal and negative controls passed before integration. +- #3708 kept Unix probe cleanup bounded and fail-closed while allowing the existing observation interval to confirm disappearance after transient EPERM. Replay fixtures now keep one caller credential snapshot across a forced second boundary. +- #3716 budgeted transition-probe startup from the two bounded Windows identity lookups, reported early exits, and joined children before cleanup. Quota fixtures now join their ordered observation/forget queue instead of guessing completion after five milliseconds. + +The final candidate passed all 24 actual cross-platform producers and the aggregate CI check in run33991642514. Remote regression controls covered delayed process startup, direct early exit, delayed quota delivery and deliberately suppressed delivery. Reverting the quota fixture reproduced the exact three historical failures; the repaired fixture passed all sixteen cases under the controlled delay. No local product tests, typechecks or builds ran. + +## Scope and verification record + +[#3661](https://github.com/lidge-jun/opencodex/issues/3661) remains open: this work covers the proven native MESSAGE recovery slice, not its remaining multipart/backend/caller cases. The five source PRs expose no additional closing-issue links. + +Historical failed CI jobs were preserved. One earlier Cursor echo/close timeout has no established cause; the same source subsequently passed all final cross-platform checks. It was not claimed to be fixed by the fixture changes. + +Integrated dev `a2f69c8aa60976345740ae6f3d2301f89297328e` passed [run33993960826](https://github.com/lidge-jun/opencodex/actions/runs/33993960826): all17 applicable producers and the aggregate succeeded. Windows suite and unsharded macOS control are dispatch-only and were correctly skipped on this push; both were included in the successful24-producer final candidate run above. diff --git a/devlog/_fin/260906_a_windows_fixture/000_plan.md b/devlog/_fin/260906_a_windows_fixture/000_plan.md new file mode 100644 index 0000000000..b858312f61 --- /dev/null +++ b/devlog/_fin/260906_a_windows_fixture/000_plan.md @@ -0,0 +1,9 @@ +# A Windows verifier foundation + +This is the independently reviewed foundation required to unblock A runtime-stack current-head verification. Failed Windows job101339545421 exposed non-hermetic shutdown spill fixtures; no production regression is inferred. The full diff-level specification is010_fixture_plan.md, copied from the audited A roadmap amendment and rechecked against currentdev. + +Loop: spec-satisfaction repair. Test-only fixture classC2; temporary CI verification workflow classC4 with explicit independent security audit. Main owns this branch, commits, --no-verify pushes, source preservation and owner-authorized --admin merges. No local suites/typecheck/build; remote pinnedBun1.4 only. Existing credentials permit repository GitHubActions, read-only-content hostedWindows verification and ownbranch writes; no release/service/account changes. No new credential inputs or command injection. Token/cost cap not specified;2h checkpoint reassesses evidence and progress. + +One workphase owns fixture correctness plus its verified testing foundation; no A feature implementation is included. Only tests/responses/responses-state.test.ts and numberedunitdocs belong in the product PR. A separate owner-only codex/a-verify-windows branch may add the concrete audited probe workflow, never merged todev. Feature/runtime/test blobs must match the product candidate; probe-only workflow excluded by explicit path equality proof. Final full CI on the exact product candidate remains mandatory. + +P refreshes failedfixtures and clock hooks. A audits both minimalfix and concrete CI workflow beforepush. B changes ordering/budget fixtures, pinsverificationartifacts and commits. C executes realWindows controls and fullrelevantgates, plus independent review. D records a preparedverified foundation; the next landing phase inserts/merges this foundation below A layers, refreshesheads and obtains allrequiredCI before eachadminmerge. No test skip, broader production budget, or unverified retry. Existing source and issue disposition criteria remainunchanged. diff --git a/devlog/_fin/260906_a_windows_fixture/010_fixture_plan.md b/devlog/_fin/260906_a_windows_fixture/010_fixture_plan.md new file mode 100644 index 0000000000..d5f6bb017f --- /dev/null +++ b/devlog/_fin/260906_a_windows_fixture/010_fixture_plan.md @@ -0,0 +1,123 @@ +# 010 — Deterministic Windows shutdown-spill fixtures + +Status: P amendment; documentation only. Future implementation is a separate C2 test-harness cycle after 050, before 080. C confirmed no ownership collision. Main owns the FSM, implementation, remote execution and insertion of this foundation beneath the runtime stack. + +## Evidence and boundary + +[Windows job 101339545421](https://github.com/lidge-jun/opencodex/actions/runs/33978547130/job/101339545421), head `4b34cbb8d3f308cd2b01e8d87784c65afb50a40f`, Bun 1.4.0: 3048 pass, 39 skip, 2 fail, 1 unhandled error. The two failures are in `tests/responses/responses-state.test.ts`: + +- Stable-tail (1297): the 500 ms drain timer selected synchronous fallback; its unmocked ACL runner failed with EICACLS. The actual async-delay cause is unmeasured. Global ACL call 7 is also an unreliable publication marker: snapshot and directory hardening share this runner. +- Reserved budget (1438): only the ACL clock is synthetic. `spill-store.ts:232` charged real serialization/filesystem elapsed time and exhausted the deadline before temp-file hardening. The expensive operation is not identified. + +Local evidence inputs: `.tmp/a-runtime-stack/ci-triage/report.md`, `sse-windows5.log:4038–4217`, and `prior-101262480176.log:3894–3897` in that same scratch directory. The earlier job passed the two cases; its overall run was not green. Unchanged source on the sampled dev is not an independently reproduced current-dev failure. Do not describe this as an SSE regression, a proven harmless transient, or a green Windows gate. + +Future edit set: **only `tests/responses/responses-state.test.ts`**. No production, workflow, manifest, shared fixture or budget changes. Reuse `forceWindowsAclLane`, `isSpillAclTarget`, `ICACLS_OK`, existing clock/runner setters, and spill event recording. Keep existing deadline, fallback, exhaustion and watchdog tests. No sleeps for synchronization, timeout increases, skips or relaxed assertions. + +Read-only owners inspected: + +| Owner | Contract retained | +|---|---| +| `src/responses/state.ts:595` | Drain races the observed tail against a real timer; `Date.now` alone cannot freeze that timer. | +| `src/responses/state.ts:771` and `:813` | Separate fallback reserve, remaining-budget forwarding, and repeated observation until the publication tail is stable. | +| `src/responses/spill-store.ts:100`, `:153`, `:225` | Existing I/O events and injectable spill clock; each harden gets min(per-call cap, remaining whole-write budget). | +| `src/lib/windows-secret-acl.ts:360`, `:410`, `:589` | Async runner timer; injected ACL clock; grant/inheritance/remove calls consume one harden deadline. | +| `tests/responses/ws-upstream.test.ts:725` | Existing Bun `jest.useFakeTimers` / `advanceTimersByTime` / `useRealTimers` convention. | + +## Hunk 1 — Stable-tail ordering, not elapsed disk time + +At the test import, add `jest`. Retain 1000/500 budgets. Use fake timers **only within this test**, with `Date.now` fixed to a captured real epoch and ACL/spill clocks fixed consistently. Capture native `setImmediate` before enabling fake timers for an event-loop checkpoint; this drains runnable promise work without a sleep or timer advance. No new shared helper. + +Replace the global `aclCalls === 1/7` runner with gates on the first two distinct spill temp paths at `/grant:r`: + +```ts +const gatedTemps = new Set(); +setAsyncIcaclsRunnerForTests(async args => { + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; // includes snapshot, directory and later ACL steps + } + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { firstEntered(); await firstGate; } + if (gatedTemps.size === 2) { secondEntered(); await secondGate; } + } + return ICACLS_OK; +}); +let syncSpillCalls = 0; +setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls++; + return ICACLS_OK; +}); +``` + +Both principal resolvers remain synthetic through `forceWindowsAclLane`. Both ACL runners cover **every** target; filtering controls gating/counting, never whether a real subprocess is used. A fallback must fail the ordering oracle (`syncSpillCalls === 0`), rather than being hidden by the successful mock. + +Replace the current orchestration and 25 ms sleep with this exact ordering: + +1. Enter `try/finally` before the first enqueue/await. Enable fake timers and fixed epoch clock; install both clock setters. Enqueue first response and await its temp gate. +2. Start `flushResponseState`, immediately attach both settlement handlers, recording `flushed` and any error in a resolved outcome object. This avoids an unhandled rejection if an earlier assertion fails. +3. Enqueue second response **after** starting flush, then release first. Await second temp gate. Await a native `setImmediate` checkpoint, advance fake timers by 25 ms, then another native checkpoint. The drain timer stays below 500 ms; no real elapsed filesystem time can fire it. +4. Assert flush is still pending, exactly two distinct temp paths were gated, and no synchronous spill ACL calls occurred. Record `setSpillIoForTest({ record })` events and assert exactly one `stub-swap` so the first publication actually installed while the second is gated. +5. Release second, await the handled flush outcome and rethrow any captured error. Retain `{ residentCount: 0, spillStubCount: 2 }`; add pending `{ count: 0, bytes: 0 }`, two `stub-swap` events, and zero synchronous spill calls. Both stored response IDs must still expand to their distinct payloads. +6. `finally`: release **both** gates, await any started flush outcome and `flushPendingResponseSpillsForTests()` while mocks/clocks remain installed, then restore the Date spy and real timers in a nested `finally`. Existing `afterEach` restores setters. Never restore mocks while a gated async operation still owns work. + +Use a discriminated outcome (`{ ok: true } | { ok: false; error: unknown }`) rather than an undefined-error sentinel. Keep cleanup valid when either startup await/assertion fails. Fake-timer compatibility and the native checkpoint are remote Windows acceptance items, not assumed proof. Do not solve a failed fixture by globally suppressing timers or adding a production seam. + +## Hunk 2 — One logical fallback budget, actual drain timer + +At 1438, preserve `totalMs = 500`, `fallbackReserveMs = 300` and the pending async spill gate. Add the missing spill clock; scope a Date spy to the flush so outer fallback accounting and nested ACL accounting advance together. Keep native timers in this test: the unchanged 200 ms drain timer must expire while the async gate remains held. + +```diff + let aclClock = 0; + setNowForTests(() => aclClock); ++setResponseSpillNowForTests(() => aclClock); +``` + +Record `{ target, timeoutMs, spentBefore }` for **spill** synchronous ACL calls. Snapshot ACL calls return `ICACLS_OK` without charging the spill clock. For each spill call, record before incrementing `aclClock += 20`; preserve successful command results. + +```ts +const epoch = Date.now(); +const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); +// Start only after the async spill gate announces entry. +try { + await flushResponseState(); // native 200 ms drain timer selects sync fallback +} finally { + release(); + try { await flushPendingResponseSpillsForTests(); } + finally { nowSpy.mockRestore(); } +} +``` + +An enclosing `try/finally` must also cover enqueue and `await started`, releasing the gate on early failure. Preserve all three original assertions: at least six spill commands, maximum deadline <= 150, and `200 + aclClock <= 500`. Add: + +- Every timeout is positive and <= `300 - spentBefore` (independent literal budget oracle). +- Within each target's grant/inheritance/remove sequence, each next timeout is exactly 20 ms smaller; do **not** assert global monotonicity across targets because a new harden has its own per-call cap. +- The async gate has not been released when synchronous spill work begins; fallback actually ran, pending count/bytes become zero, one spill stub remains, and replay contains the original payload. + +The Date spy prevents unmeasured real disk latency from consuming this logical-budget fixture. It does not disable the native drain timer. Real-time termination coverage remains in the unchanged cap-expiry test (1339) and `shutdown fallback budget exhaustion is contained by a child watchdog` (1613), using `tests/helpers/responses-state-shutdown-budget-child.ts`. Do not claim this test measures OS elapsed latency. + +## Windows red, control and proof + +Main executes these later on real Windows with the repository-pinned Bun, in isolated remote checkouts. Nothing below authorizes local tests in this documentation task. + +1. Preserve the failed root-head job/logs above. Run the original two tests on the pinned pre-fix baseline; record actual results, including a pass. Do not require random failure or accept retries as a fix. +2. In remote scratch only, force the old stable-tail drain to expire by holding the second gate until a recorded fallback entry. Use a counted synchronous sentinel that reports EICACLS instead of invoking native ACL tools. Confirm rejection and the fallback call; never infer the missing-mock path from elapsed time alone. This is a controlled mechanism probe, not proof that the same delay happened in CI. +3. In remote scratch only, use the existing spill `record("write")` event to advance a separate wall clock by 301 ms once synchronous fallback has begun. On the original reserved-budget fixture, spill uses that clock and fails before temp hardening; with the proposed shared logical spill clock, the same wall-clock perturbation cannot consume the ACL budget. Record entry and clock values. Keep this probe separate from production and from the committed passing fixture. +4. Prove oracle sensitivity with isolated remote mutations: (a) stop drain after its first observed tail, expecting the revised stable-tail pending/zero-fallback oracle to fail; (b) reset the ACL deadline for each command, expecting per-target 20 ms decrease assertions to fail. Separately advance the **injected spill clock** beyond 300 at the write event and require ETIMEDOUT, proving deadline enforcement remains active. Restore every mutation before green verification; retain diff and failing assertion for each probe. +5. Run the unchanged named cap-expiry and child-watchdog controls, then the whole focused file on the new exact head: + +```sh +# Remote Windows only; these commands are a future verifier recipe. +bun test --isolate --timeout 60000 tests/responses/responses-state.test.ts +bun run typecheck +``` + +6. Dispatch the actual Windows full-suite workflow on that exact head, including `bun test --isolate --timeout 60000 tests --shard=5/6` and every other required shard. Inspect job execution, not aggregate success with skipped tests. Record head SHA, Bun version, commands, job URLs, counts and absence of unhandled errors. Run current-head Linux/macOS gates and required scans as well. + +Implementation D means an independently reviewed prepared foundation draft with exact-head focused Windows evidence and remote typecheck; it is **not landing**. Main inserts the verified foundation beneath the stack, refreshes descendants bottom-up with original attribution intact, obtains required current-head gates, then admin-merges in dependency order. Verify each landed SHA is an ancestor of freshly fetched dev before closing a superseded PR or fully resolved issue. Partial issues retain their residual scope. See `080_landing.md`. + +Documentation acceptance: this file names both failed fixtures, all clock/timer boundaries, complete runner/cleanup coverage, executable negative controls, one-file implementation scope and separate landing gates. No test execution or implementation success is claimed here. + +## Remote execution fallback amendment + +The existing direct Windows SSH endpoint is unavailable; the reachable auxiliary host is Linux without Windows interop. Use GitHub Actions for actual Windows proof. If the existing full-suite workflow cannot execute focused causal probes, a separate owner-only `codex/a-verify-windows` branch may hold a temporary verification workflow triggered only by pushes to that exact branch. This workflow is never included in a product PR or merged to dev. It uses `windows-latest`, read-only contents permission, pinned checkout with `persist-credentials: false`, the existing pinned-Bun setup, fixed repository test commands and the exact carried fixture commit. No secrets, untrusted command inputs, self-hosted runner access or release permissions. It may execute the narrowly specified scratch mutations with guaranteed source restoration and upload logs. Independent security audit of the concrete workflow is required before pushing it. Standard per-head full CI remains the final gate; the temporary verifier cannot mark those checks green. diff --git a/devlog/_fin/260906_a_windows_fixture/011_review_resolution.md b/devlog/_fin/260906_a_windows_fixture/011_review_resolution.md new file mode 100644 index 0000000000..5b230ea087 --- /dev/null +++ b/devlog/_fin/260906_a_windows_fixture/011_review_resolution.md @@ -0,0 +1,3 @@ +# Fixture review correction + +Independent implementation reviewer found that full flush settlement includes later snapshot I/O, which could hide an incorrect first-tail drain return. Accepted: the ordering test now starts the existing drain-only helper before appending the second publication and observes its settlement independently. Both drain and full-flush promises attach rejection handlers immediately and are joined during cleanup. Existing publication, replay and zero-fallback assertions remain. No production changes. A remote first-tail mutation must fail this direct drain oracle before completion. diff --git a/devlog/_fin/260906_c_lane/000_plan.md b/devlog/_fin/260906_c_lane/000_plan.md new file mode 100644 index 0000000000..5abf70bf69 --- /dev/null +++ b/devlog/_fin/260906_c_lane/000_plan.md @@ -0,0 +1,11 @@ +# C-lane integration coordination + +Scope: carry public PRs #3638, #3536, #3631, #3576, #3658 with original-author attribution and user-authorized stacked PR integration into dev. No local tests, typechecks or builds. + +The user explicitly requires security working plans and reviews to stay in gitignored scratch. Full numbered diff-level roadmap and evidence live in `.tmp/c-lane/` of the bound d778 checkout; this neutral index is the PABCD plan-unit anchor. This storage override follows AGENTS.md and does not weaken any implementation or verification criterion. + +Order: roadmap → service scheduler → account persistence → OAuth configuration → Antigravity refresh/replay → quota diagnostics → final stack integration. OAuth refresh consumes the configuration layer; other layers retain the user-requested stack order. Each layer is independently reviewed and tested on a remote host before cycle close. Hosted full CI runs at each PR head and gates final bottom-up merges. + +Original PR and fully solved linked issues close immediately after the matching change is proven on dev. Partial diagnostic work does not close a broader unresolved report. Release branches and live account settings are out of scope. + +Completed: see [010_result.md](010_result.md) for published landings, verification and residual scope. diff --git a/devlog/_fin/260906_c_lane/010_result.md b/devlog/_fin/260906_c_lane/010_result.md new file mode 100644 index 0000000000..2dcb6a338b --- /dev/null +++ b/devlog/_fin/260906_c_lane/010_result.md @@ -0,0 +1,20 @@ +# C-lane delivery result + +All five assigned code changes are merged into `dev`. Original-author credit survives in the landed history. This record contains published outcomes only; working security notes remain in scratch. + +| Source | Landed PR | Scope | Merge commit | CI | +|---|---|---|---|---| +| [#3638](https://github.com/lidge-jun/opencodex/pull/3638) | [#3682](https://github.com/lidge-jun/opencodex/pull/3682) | Windows scheduler priority | `9b3955a3345561b3310508793b40ac0813e26b78` | [run 33978490397](https://github.com/lidge-jun/opencodex/actions/runs/33978490397) | +| [#3536](https://github.com/lidge-jun/opencodex/pull/3536) | [#3687](https://github.com/lidge-jun/opencodex/pull/3687) | Account deletion persistence | `ed7ecc5780ea0bd936468aff3828e60c7d9d0d34` | [run 33978685977](https://github.com/lidge-jun/opencodex/actions/runs/33978685977) | +| [#3631](https://github.com/lidge-jun/opencodex/pull/3631) | [#3688](https://github.com/lidge-jun/opencodex/pull/3688) | OAuth provider configuration | `789f69ab1bf57d74dcdd0d658f1bc13d9d486e7b` | [run 33979181943](https://github.com/lidge-jun/opencodex/actions/runs/33979181943) | +| [#3576](https://github.com/lidge-jun/opencodex/pull/3576) | [#3691](https://github.com/lidge-jun/opencodex/pull/3691) | Antigravity OAuth 401 recovery | `7e7ab281cca35600b41f1f80222f3462a87dd4e1` | [run 33979752516](https://github.com/lidge-jun/opencodex/actions/runs/33979752516) | +| [#3658](https://github.com/lidge-jun/opencodex/pull/3658) | [#3693](https://github.com/lidge-jun/opencodex/pull/3693) | Bounded main quota diagnostics | `71edeec8807d99e8e56a8c093f74da27d163d47a` | [run 33985146886](https://github.com/lidge-jun/opencodex/actions/runs/33985146886) | + +Verification: + +- Each recorded code head passed the hosted Cross-platform CI with executed Linux/macOS suites and typecheck. Independent source/security reviews passed on those heads. +- The service change also passed native Linux, macOS and Windows lifecycle run [33978490408](https://github.com/lidge-jun/opencodex/actions/runs/33978490408). +- Focused remote checks passed for each layer; the top diagnostic head passed 554 tests across eleven files, including the bounded subprocess regression. Documentation built 425 pages at that head. +- No local product test suite, typecheck or build was run. Pushes used the maintainer-authorized `--no-verify` path; admin merges followed verified code gates and review evidence. +- Original PRs #3638, #3536, #3631, #3576 and #3658 were closed after dev ancestry was proven. Resolved issues #3634 and #3575 were closed at their respective landings. +- Issue [#3644](https://github.com/lidge-jun/opencodex/issues/3644) remains open: diagnostics were delivered, while its underlying Windows/WHAM failure is still a separate investigation. diff --git a/devlog/_fin/260906_d_integrations_delivery/000_plan.md b/devlog/_fin/260906_d_integrations_delivery/000_plan.md new file mode 100644 index 0000000000..57641fac6e --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/000_plan.md @@ -0,0 +1,46 @@ +# D delivery roadmap + +## Loop specification + +- Archetype: spec-satisfaction repair; C3 integration train. Alias routing is C4 where it affects upstream credential destinations; keep any undisclosed security analysis in ignored scratch. +- Trigger: owner assigned D: #3669, #3673, #3628, #3625, #3646. +- Goal: integrate these five bounded outcomes into dev with contributor attribution, current-head remote CI and immediate disposition of superseded originals/resolved issues. +- Non-goals: A/B/C implementation, main/preview/release, dogfood service, personal accounts/credentials, unrelated thinking/cache behavior. +- Verifier: GitHub Cross-platform CI gates and platform jobs for every layer; GUI lint/build plus real isolated browser smoke for Logs; targeted CI failures repaired without local tests. NEVER run local test suites, focused tests, test:changed, or typecheck. This owner instruction overrides local-gate defaults in AGENTS/skills. +- Stop: all five landed SHAs reachable from freshly fetched origin/dev, original PRs and genuinely resolved issues closed, independent reviews clear, evidence recorded. +- Memory: this unit, .tmp/d-delivery evidence and session-bound goalplan/ledger in this checkout. +- Tool/credential scope: local source/git, gh for this repository, inherited-model agents, isolated browser QA. No purchases or new credential/account actions. +- Bounds: no owner-set numerical token/cost cap or arbitrary delegation count; 12-hour per-phase wall-clock review bound, checkpoint and reassess if reached. Context compaction is not exhaustion. +- Escalation up: main reclaims any packet after two distinct agents fail it. Down: worker scopes must be fixed in the corresponding P document before B. No speculative next-phase implementation. +- Outcomes: DONE/NOOP only with fresh evidence; unresolved external conditions remain pending and do not weaken the final criteria. + +## Checkout and source snapshot + +Worktree is adopted in place. Initial dev is 81871b3fa7034250b8d5ba2cbbfde44e40f0e69c. Live source bodies/comments/commits and exact heads are saved in .tmp/d-delivery/pr-N.json. Source refs are origin/d-source-N. No source suites were executed during planning. + +## Structure and sequence + +1. roadmap: docs-only complete PABCD; lock 010–040 designs and the scratch-backed 050 work item. +2. toml / 010: config parse admission foundation; carry #3669. +3. toolalias / 020: stream argument identity; carry #3673. +4. cursor / 030: executable schema projection on current adapter layout; carry #3628. +5. logs / 040: expose existing filter predicate in actual UI; carry #3625. +6. remotealias / 050: bind generated client aliases to hub-owned routing; resolve #3646. +7. landing: bottom-up dev integration and original-item closeout. + +The five fixes are distinct functional units; the owner explicitly requested stacked PRs, so the delivery chain imposes an integration order, not a claim that TOML is a functional dependency of Cursor. Each layer has its own tests/docs and is independently reviewable. Create the documentation parent first, then stack the five item branches. Land eligible lower layers early when CI and review permit, immediately retarget remaining children and verify ancestry. Each implementation cycle certifies its current-head candidate; final landing criteria retain every dev-ancestry and closeout obligation. + +## Shared ownership + +- A owns shared Responses core integration; D #3673 modifies openai-chat.ts, not core.ts. +- B #3659 and D #3625 share locale modules; integrate both sets of keys. +- B #3649 and D #3646 may both touch Claude aliases/claude-messages.ts. Re-read remote dev before 050 and preserve Fable selector normalization. +- New tests must register both layout manifests where applicable. Existing test edits retain current paths. + +## Attribution and GitHub operations + +Original author commits or valid Co-authored-by trailers are retained. Every push uses --no-verify. Own rewritten stack refs use --force-with-lease only if required; never rewrite another active task branch. All PR bodies fill Summary/Verification/Checklist and show stack base, source PR, evidence and screenshot for visible GUI changes. Merge bottom-up; refresh head, CI, review and origin/dev immediately before each merge. After merge prove git merge-base --is-ancestor landed-sha origin/dev, then close superseded source PR and any fully solved issue. Partial issues stay open with exact remaining scope. + +## Verification route inspected + +.github/workflows/ci.yml uses pull_request without a base filter (stack support); src/tests/gui/docs changes are selected by changes job. gates executes Typecheck (lines 422–425), GUI tests (427–428), privacy (430–431), GUI lint/build when relevant; platform shards run the repository tests. Read-only git diff origin/dev...origin/d-source-3669 --check exited 0 and observes the source delta. Roadmap validation is a documentation-only Python check, not an application test suite. diff --git a/devlog/_fin/260906_d_integrations_delivery/001_roadmap_result.md b/devlog/_fin/260906_d_integrations_delivery/001_roadmap_result.md new file mode 100644 index 0000000000..ab86cff0e3 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/001_roadmap_result.md @@ -0,0 +1,9 @@ +# Roadmap lock result + +Independent reviewer 01a0726c-c782-7701-962f-2607911a33af returned VERDICT: PASS, no actionable blockers. All five implementation designs, provenance, CI targets and separate final landing obligations were checked. + +The documentation-only verifier passed. An initial whitespace check flagged two blank context lines inside the embedded TOML diff; the command sequence did not stop and the B-to-C narrative incorrectly said the whitespace check passed. The whitespace was removed and the final complete roadmap diff was checked again successfully before closeout. No production test or typecheck ran locally. + +Next: enter the TOML cycle, refresh 010 against the current parent, carry the original authored commit, add the architecture contract, publish as a child of the documentation PR and obtain current-head hosted CI. The full delivery goal remains open. + +External review subsequently required correcting planning-artifact placement and tightening two future tool-contract designs. The detailed review synthesis is retained in ignored scratch. The public 050 entry now contains only a work-item pointer; its implementation is still pending. The independent initial PASS did not detect these issues and does not substitute for the corrective review. diff --git a/devlog/_fin/260906_d_integrations_delivery/010_toml_guard.md b/devlog/_fin/260906_d_integrations_delivery/010_toml_guard.md new file mode 100644 index 0000000000..e7014cf8ca --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/010_toml_guard.md @@ -0,0 +1,122 @@ +# 010 — TOML rewrite admission + +Depends on: roadmap lock. Class C2/C3 parser-admission preservation. Owner: main; bounded implementation reviewer during B, no future-phase code writes. + +Source: PR #3669, commit f6db9cae8e8854c6df06087288a074d767f9787d, Hako <25837994+devswha@users.noreply.github.com>. Preserve this author via cherry-pick and add Co-authored-by on carry PR. Existing review has no unresolved threads; reported local results are not our current-head CI proof. + +## File change map + +MODIFY src/integrations/config-io.ts: replace direct Bun.TOML.parse return with iterative document walk. Reject non-array objects with prototypes other than Object.prototype or null before JSON cloning can coerce typed date/time scalars to strings. Scalars/quoted dates/plain objects/arrays remain accepted. +MODIFY tests/clients/integrations-state.test.ts: exercise all supported TOML temporal kinds at root, nested tables and inline arrays; quoted equivalents stay accepted. +MODIFY tests/clients/integrations-writer.test.ts: real temp Kimi config produces unsafe state; apply refuses without changing original bytes, operation journal or ownership records. +MODIFY docs-site/src/content/docs/{guides,fr/guides,tr/guides,zh-tw/guides}/integrations.md: carry the source commit descriptions of refused date/time rewrites. +MODIFY structure/09_client-integrations.md: add typed TOML temporal values to the existing round-trip refusal contract after the classifier paragraph. +No new fields, enums, serializers, dependencies, runtime options or management endpoints. The parser is the existing common admission point for status and writers. Bypass is explicit manual editing outside managed rewrite; this guard does not control that user action. + +## Exact source patch + +```diff +diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts +index 4f2a83482..9cb5f97ba 100644 +--- a/src/integrations/config-io.ts ++++ b/src/integrations/config-io.ts +@@ -162,7 +162,22 @@ export function parseConfig(text: string | null, format: ConfigFormat): unknown + * evidence is gone. + */ + if (/(^|[\s,[=])[-+]?(?:inf|nan)(?=[\s,\]]|$)/mi.test(text)) return PARSE_FAILED; +- return Bun.TOML.parse(text); ++ const document = Bun.TOML.parse(text); ++ // TOML date/time scalars are Temporal objects with toJSON methods. ++ // The merge layer JSON-clones documents, which silently turns these ++ // into strings. Refuse before either status or a writer can admit a ++ // lossy rewrite, including dates nested in arrays and inline tables. ++ const pending: unknown[] = [document]; ++ while (pending.length > 0) { ++ const value = pending.pop(); ++ if (value === null || typeof value !== "object") continue; ++ if (!Array.isArray(value)) { ++ const prototype = Object.getPrototypeOf(value); ++ if (prototype !== Object.prototype && prototype !== null) return PARSE_FAILED; ++ } ++ for (const child of Object.values(value)) pending.push(child); ++ } ++ return document; + } + } + } catch { +diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts +index 54ab80de1..872e9b382 100644 +--- a/tests/clients/integrations-state.test.ts ++++ b/tests/clients/integrations-state.test.ts +@@ -401,6 +401,25 @@ describe("classifier unit behavior", () => { + expect(parseConfig("{{{", "json")).toBe(PARSE_FAILED); + }); + ++ test("parseConfig refuses typed TOML dates before a JSON clone can turn them into strings", () => { ++ for (const literal of [ ++ "2026-09-05T10:00:00Z", ++ "2026-09-05T10:00:00-07:00", ++ "2026-09-05T10:00:00.123456", ++ "2026-09-05", ++ "10:00:00.123456", ++ ]) { ++ for (const text of [ ++ `expires = ${literal}\n`, ++ `[user]\nexpires = ${literal}\n`, ++ `items = [{ expires = ${literal} }]\n`, ++ ]) { ++ expect(parseConfig(text, "toml")).toBe(PARSE_FAILED); ++ } ++ expect(parseConfig(`expires = "${literal}"\n`, "toml")).toEqual({ expires: literal }); ++ } ++ }); ++ + test("parseConfig refuses json number literals a rewrite would change", () => { + // Overflow to Infinity — a rewrite would bake in null. + expect(parseConfig("{\"a\": 1e999}", "json")).toBe(PARSE_FAILED); +diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts +index 0bf81fdb5..de2f16471 100644 +--- a/tests/clients/integrations-writer.test.ts ++++ b/tests/clients/integrations-writer.test.ts +@@ -141,6 +141,24 @@ function reverseJsonObjectKeys(value: unknown): unknown { + } + + describe("apply", () => { ++ test("refuses Kimi TOML date rewrites without changing the file or ownership store", () => { ++ const spec = INTEGRATION_CLIENTS.kimi; ++ mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); ++ const configPath = spec.configPath(TEST_ENV, home); ++ mkdirSync(dirname(configPath), { recursive: true }); ++ const original = "[user]\nexpires = 2026-09-05T10:00:00Z\n"; ++ writeFileSync(configPath, original); ++ const request = input({ clientId: "kimi" }); ++ ++ expect(readIntegrationState(request).state).toBe("unsafe"); ++ const result = applyIntegration(request); ++ expect(result.ok).toBe(false); ++ if (!result.ok) expect(result.reason).toBe("unsafe"); ++ expect(readFileSync(configPath, "utf8")).toBe(original); ++ expect(store.listOperations()).toHaveLength(0); ++ expect(store.readRecords().kimi).toBeUndefined(); ++ }); ++ + test("refuses a client that is not installed, and writes nothing", () => { + const result = applyIntegration(input()); + expect(result.ok).toBe(false); +``` + +## Additional structure diff + +After “Status and mutation must use the same classifier” paragraph add: + +> TOML temporal scalars cannot survive the JSON-cloned merge representation with their types intact. The common parser refuses documents containing them before either status or mutation proceeds, including nested arrays and inline tables. Quoted date strings remain supported. + +## Acceptance and activation + +- Unquoted offset/local date-time, local date, local time at every tested nesting returns PARSE_FAILED. +- Identical quoted values remain plain strings and can be managed. +- Kimi apply on typed temporal input activates unsafe classification and writes nothing, including bookkeeping. +- Existing special-float admission and other formats are unchanged. +- C consumes hosted current-head CI actual gates/platform jobs; no local suites/typecheck. Original focused paths named above are included in the CI repository tests. +- Independently review prototype traversal and actual parser shapes; unexpected compatibility gaps change the plan before implementation. +- Once integrated, refresh dev ancestry and close source #3669 immediately with attributed carry PR evidence. diff --git a/devlog/_fin/260906_d_integrations_delivery/011_toml_refresh.md b/devlog/_fin/260906_d_integrations_delivery/011_toml_refresh.md new file mode 100644 index 0000000000..5c2850fa4d --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/011_toml_refresh.md @@ -0,0 +1,8 @@ +# TOML cycle P refresh + +Parent: cb75f49c9401e10f8bd37f4817cdef32b0a5cbe1, documentation PR #3681. +Source: f6db9cae8e8854c6df06087288a074d767f9787d by Hako. + +Read-only git comparison from source parent to the current parent returned no changes in config-io.ts and the two affected client regression files. The 010 diff remains applicable. The shared parser admits both status and writers; no caller-specific exception or new option is required. + +Implementation scope stays as 010. Main will cherry-pick the original commit and add the structure contract. An inherited independent reviewer audits the candidate; no local application tests or typecheck are permitted. Hosted CI supplies runtime verification; docs build may run in a fresh macmini-cf scratch checkout with no real credentials or service changes. diff --git a/devlog/_fin/260906_d_integrations_delivery/020_tool_aliases.md b/devlog/_fin/260906_d_integrations_delivery/020_tool_aliases.md new file mode 100644 index 0000000000..9d1b646314 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/020_tool_aliases.md @@ -0,0 +1,134 @@ +# 020 — Retain late Chat tool-call index aliases (#3673) + +## Loop specification + +- Class: C2 adapter repair; spec-satisfaction loop, one implementation PABCD cycle. +- Trigger: an upstream Chat stream introduces a call by ID, associates an index later, then sends index-only fragments. +- Goal: one complete call retains its original ID/name and argument budget ownership. +- Non-goals: guessing associations between unindexed calls, changing other malformed-field tolerance, changing budget limits, transport/core changes, unrelated adapter refactors. +- Verifier: exact-head hosted CI executes the focused cases below plus repository typecheck/full-suite gates. NO local tests, suites, typecheck, or test:changed; commands below are runner-only specifications. +- Stop: all acceptance rows and required hosted jobs pass on the delivered head, review findings resolved, and main proves delivery to dev. A docs-only result does not satisfy implementation criteria. +- Memory artifact: this decade document and main-owned 000/CI evidence ledger in the same unit. +- Outcomes: DONE after proof; NOOP only if current dev already has equivalent behavior and CI proof; otherwise BLOCKED/NEEDS_HUMAN with the concrete missing external evidence. Main controls orchestration and goals. +- Escalation: report upstream to main if the refreshed source no longer matches these contracts; main reclaims after two failed distinct delegates. Further delegated scope must be recorded during P, not improvised in B. +- Resources: local source/refs and supplied PR snapshot are read-only inputs; this planning delegate writes only this document and 030. Implementation write scope is the map below; main owns credentials, publication, CI dispatch, merge and its session-wide resource bound. No paid/provider calls are needed. + +## Provenance and stale check + +Baseline: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`, inspected 2026-09-06 KST. +Source ref `origin/d-source-3673` resolves to `c8240c51d664f7cfb790b6d60679adfe0490b5c9`. +Original author: **Hako <25837994+devswha@users.noreply.github.com>** (`devswha`). +Source patch parent: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +Snapshot: `.tmp/d-delivery/pr-3673.json` (`headRefOid`, body, comments, checks). +Read-only comparison `git diff c8240c51d^ 81871b3fa --` across the three source-PR paths returned no diff: source patch applies to the same relevant baseline. Recheck at this cycle's P because other lanes may land first. + +Preserve author identity when carrying the commit; include `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>` in the eventual squash description/commit. Main may carry with a cherry-pick or reimplementation; neither is performed by this document writer. + +## Exact change map + +| Operation | Path | Change | +|---|---|---| +| MODIFY | `src/adapters/openai-chat.ts` | Add first-observed index alias to pending call identity lookup; keep budget key immutable. | +| MODIFY | `tests/adapters/openai/openai-chat-parallel-stream.test.ts` | Port the complete original regression patch, extending T9b and adding collision/budget controls. | +| MODIFY | `docs-site/src/content/docs/reference/adapters.md` | Port the original five-line paragraph under openai-chat. | +| MODIFY | `structure/04_transports-and-sidecars.md` | Append the contract block below in C. | +| NEW | none | Existing test file already appears in both layout manifests; no new helper/module/manifest entry. | + +Read dependencies: `tests/helpers/translator-budget.ts`, `src/lib/translator-budget.ts`; reuse `createTestTranslatorBudget`, `withTestTranslatorBudget`, existing `collect`, `sse`, `chunkOf`, and `assembled`. No additional registry or identity map is necessary. Configuration cannot fix missing association state; deletion/NOOP would leave the observed sequence broken. + +## Concrete patch contract + +The exact original patch is the complete diff `git show c8240c51d664f7cfb790b6d60679adfe0490b5c9 -- src/adapters/openai-chat.ts tests/adapters/openai/openai-chat-parallel-stream.test.ts docs-site/src/content/docs/reference/adapters.md`. Preserve all hunks, including test import/helper changes; do not port just T9b. + +Current anchors: `src/adapters/openai-chat.ts:1661` pending interface, `:1856` identity lookup, `:1873` budget opening, `:1912` argument-byte accounting, `:1679` budget closing. Replace the lookup block with: + +```ts +if (rawIndex !== undefined && rawIndex !== null + && (typeof rawIndex !== "number" + || !Number.isSafeInteger(rawIndex) + || rawIndex < 0)) { + return yield* terminateWithError({ + ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), + message: "upstream response contained invalid tool calls (invalid index)", + }); +} +const indexKey = typeof rawIndex === "number" ? `i:${rawIndex}` : undefined; +const key = indexKey ?? (idDelta + ? `id:${idDelta}` + : pendingToolCalls[pendingToolCalls.length - 1]?.key); +let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined; +if (!call && indexKey !== undefined) call = pendingToolCalls.find(c => c.indexKey === indexKey); +if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta); +``` + +Add `indexKey?: string` after `PendingToolCall.key`. Immediately after the existing new-call allocation/openCall block, add the source comment and: + +```ts +if (indexKey !== undefined && call.indexKey === undefined) call.indexKey = indexKey; +``` + +Before: the ID+index delta finds the ID-owned call through ID fallback but does not retain its index; the next index-only delta allocates another unnamed call. After: direct key wins, then remembered index alias, then existing ID fallback. The original `call.key` never changes and alias registration does not call `budget.openCall` a second time. First observed index remains authoritative; no second alias is added for a repeated ID on a different index. Keep resolve-before-validation, `sawArgumentsString`, heartbeat emission, overflow conversion, flush and EOF logic unchanged. + +## Regression activation and oracle + +Port exact fixtures/assertions from the source commit; all paths are reachable through `createOpenAIChatAdapter(...).parseStream(new Response(sse(...)), budget)`. + +| Activation | Required observation | +|---|---| +| T9b ID-only `call_b/read/{"p"`, then index 0 + same ID + `:"x"`, then index 0 + `}` | Exactly `call_b/read/{"p":"x"}`; final done; budget activeCalls/currentBytes/overflows all zero. Current T9b at test line 214 ends at ID+index and misses the defect. | +| Two ID-only calls, learn indexes 9 and 4 in reverse order, index-only tails plus ID-only trailing space | Separate read/write calls and exact original fixture args; peak active calls 2, no duplicate owners, final zero retained bytes. | +| Two unindexed calls then unrelated index-only fragments without any ID/index association | Final error and no done; never guess by position. | +| Existing index with conflicting ID | Index ownership wins; neither call rebound. | +| Established indexed calls later share the same ID, followed by ID-only continuation | Existing first-match ID fallback stays intact. | +| Same ID repeats with a second index after index 0 was observed | Index 0 remains the alias; exact fixture completes one call. | +| `{"p":"é"}` split over ID/ID+index/index frames, maxCallArgumentBytes 9 then 10 | At 9: translation_buffer_limit, no tool_call_start, one overflow. At 10: exact completed args, done, zero overflow. Both release retained bytes/calls. | + +Optional additional mutation experiment (not a completion prerequisite): run the final regression file against the baseline adapter and observe T9b fail for split/unnamed calls; restore patched adapter and rerun the same file green. Store both outputs; until observed, describe RED as planned rather than proven. Do not disable original tests or change timeouts to mask failures. + +Runner-only focused command: + +```sh +bun test tests/adapters/openai/openai-chat-parallel-stream.test.ts tests/adapters/openai/openai-chat-hardening.test.ts tests/adapters/openai/openai-chat-eof.test.ts +``` + +Then hosted typecheck/full test jobs, privacy scan and docs build; `.github/workflows/ci.yml:255` owns test jobs, `:392` gates, `:422` typecheck. Record actual head SHA, run/job URLs and executed job conclusions; intake labels and skipped jobs do not prove tests. Main pushes with `--no-verify` as authorized, bypassing local prepush only. Do not attest that local CI ran. + +## Documentation and architecture sync + +Apply source paragraph before `## ollama-native` at adapters reference line 52. Reconcile this same-file edit with A's #3568 docs and 030's Cursor section without overwriting either. English is canonical; inspect translated adapter pages for contradictory identity claims, and enumerate any required locale changes in P before widening the map. + +Append to `structure/04_transports-and-sidecars.md`: + +```md +## Chat streamed tool-call identity + +`src/adapters/openai-chat.ts` retains a call's first observed numeric index as an +alias when the call started by ID. Lookup preserves direct-key precedence, then +index alias, then ID fallback. The initial key continues to own all translator +budget reservations and release; learning an alias creates no additional owner. +Unassociated index-only fragments are not guessed onto pending ID-only calls. +`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, +parallel/colliding identities and UTF-8 byte-limit boundaries. +``` + +## Review blockers and integration exit + +Snapshot says MERGEABLE. Source body leaves draft/readiness open: 124 focused passes and 6,152 affected passes are author-reported, not delivered-head evidence; full baseline has reported timeout failures and does not establish green. CodeRabbit's latest comment reports no actionable comments; its docstring coverage warning is not product execution proof. No independent approval or review-thread completeness can be inferred solely from the empty `reviews` array. Main must refresh threads and CI at the candidate head. + +This layer follows 010 in the D stack as an integration sequence, not a runtime dependency. After lower-layer edits, main cascades refreshed descendants and revalidates changed heads. Main merges bottom-up, proves merge-commit ancestry on fetched dev, and immediately closes superseded #3673 only after that proof. A new PR's squash must retain the original trailer. This docs-only delivery neither merges nor closes anything. + +## Roadmap lock clarification + +The implementation cycle certifies its published current-head candidate. Every dev-ancestry and original-closeout obligation remains mandatory in the separate landing work-phase, allowing the owner-requested stack to exist without treating publication as dev integration. Eligible lower layers may land early and are closed immediately after ancestry proof. + +## External review amendment: numeric index contract + +Only non-negative safe-integer indexes may become an alias. Immediately after reading rawIndex, if it is numeric but not an integer or is negative, terminate through the existing invalidToolCallsEvent/terminateWithError path; do not treat an invalid numeric index as absent and append its data to the last pending call. Other tolerated placeholder fields retain their existing rules. Add reachable negative/fractional numeric-index regressions with two distinct pending calls: one error, no done, no fragment reassignment, and all budget reservations released. Preserve all original positive and collision cases. This is an explicit source-patch amendment, not a claim the original commit already implements validation. + +## Safe-integer review repair + +The numeric guard uses Number.isSafeInteger: parsed indices beyond the safe range can already have lost identity precision. Add a raw-wire regression containing distinct large integer literals (not JS values rounded before serialization), and retain a positive MAX_SAFE_INTEGER boundary. Capture error/no tool success plus existing reservation-release coverage. The correction must be verified in this same unit; no original source tests are removed. + +## Claimed-type boundary update + +022 supersedes the earlier non-numeric-index tolerance assumption: only missing/null indexes are absent. Every other claimed value must be a non-negative safe integer; no coercion of strings/objects/bools/arrays. Repeated ID/name/argument-field tolerance is unchanged. diff --git a/devlog/_fin/260906_d_integrations_delivery/021_tool_alias_refresh.md b/devlog/_fin/260906_d_integrations_delivery/021_tool_alias_refresh.md new file mode 100644 index 0000000000..c8d03ce3f5 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/021_tool_alias_refresh.md @@ -0,0 +1,9 @@ +# Tool-call alias cycle P refresh + +Historical refresh: its non-numeric-index policy is superseded by the explicit null/missing boundary in 022_index_type_repair.md. + +Current parent: 22da7a4bc80040f66b819239c5028e578f9a1ede, after TOML delivery. Original source c8240c51d664f7cfb790b6d60679adfe0490b5c9 remains open and authored by Hako. Relevant baseline comparison is retained in scratch; implementation uses the current tree and preserves adjacent changes. + +Apply the original commit, then the independently reviewed 020 numeric-index amendment. Missing/non-numeric placeholders keep existing tolerance; negative/fractional numeric indexes terminate before matching. Preserve the immutable reservation key and first observed valid index alias. Add direct malformed-index activation coverage alongside all original positive/collision/UTF-8 budget cases. Update the transport structure contract as planned. + +Main owns cherry-pick/commits/PR/CI/merge. An inherited worker may edit only src/adapters/openai-chat.ts, tests/adapters/openai/openai-chat-parallel-stream.test.ts, and structure/04_transports-and-sidecars.md after A passes. Main owns this document and all other files. Independent reviewer checks resulting code; all tests/typechecks execute remotely or in GitHub Actions. Full-suite readiness remains remote; no local application checks. macmini shared test lock is respected. diff --git a/devlog/_fin/260906_d_integrations_delivery/022_index_type_repair.md b/devlog/_fin/260906_d_integrations_delivery/022_index_type_repair.md new file mode 100644 index 0000000000..704278ae34 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/022_index_type_repair.md @@ -0,0 +1,41 @@ +# 022 — Reject claimed invalid index types + +## Loop specification + +Class C2/C3 bounded parent repair. Source: current #3702 at d6bfb044a; late reviews PRRT_kwDOS-0Gi86fl4vM and fl4vC. Goal: a present invalid index cannot be mistaken for an absent index and routed to the last pending call. Non-goals: changing repeated ID/name/argument placeholder tolerance, parsing numeric strings, new adapters or unrelated Logs work. Remote/CI verification only; no local tests/typecheck. Same session resource bounds apply. Main owns Git/FSM/integration; one worker may edit only the adapter, its parallel-stream test and structure04. Main reclaims after two failed delegates. + +This additive repair preempts unfinished Logs planning. No previous work-phase completion marks or final criteria were removed. Detailed review synthesis is in scratch. Resume Logs after this full cycle and cascade. + +## Exact change map + +MODIFY src/adapters/openai-chat.ts, before all key matching: + +```ts +if (rawIndex !== undefined && rawIndex !== null + && (typeof rawIndex !== "number" + || !Number.isSafeInteger(rawIndex) + || rawIndex < 0)) { + return yield* terminateWithError({ + ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), + message: "upstream response contained invalid tool calls (invalid index)", + }); +} +``` + +Before: only invalid numbers reject; present strings/objects/bools become no indexKey and may select the last pending call. After: only missing/null is absent; every other claimed index must be a non-negative safe integer. The existing terminateWithError closes all budget reservations before the error is yielded. Keep the alias/key precedence and immutable reservation keys unchanged. No new fields/enums/dependencies. + +MODIFY tests/adapters/openai/openai-chat-parallel-stream.test.ts: retain all Hako and safe-integer cases. Update expected diagnostic wording. Add labeled table cases for numeric string, empty string, true/false, object and array, with pending complete JSON calls so a silent fallback could otherwise produce success; assert one terminal502, no tool/done event and released reservations. Include explicit missing/null positive continuation through a later valid numeric alias. Use tuple wrappers for array-valued cases so test.each cannot mistake an index array for argument tuples. + +MODIFY docs-site/src/content/docs/reference/adapters.md: specify non-negative safe integers; explicitly reject non-numeric values and negative/fractional/unsafe numbers; missing/null remain absent-index placeholders. Do not call valid JSON numbers malformed JSON. + +MODIFY structure/04_transports-and-sidecars.md: align the same index contract and source/test ownership. + +MODIFY 020_tool_aliases.md: carry the corrected guard and compatibility boundary. Annotate 021's former non-numeric-placeholder policy as superseded by this repair; retain its historical source snapshot. + +## Verification and exit + +- Independent plan and implementation review; original source authorship retained. +- Exact-head pinned remote typecheck/full suite/docs build, hosted CI registration and no unresolved findings. Full final integrated CI remains mandatory under c-2; build readiness is not merge permission. +- Existing numeric/unsafe/UTF-8/collision cases remain green; new claimed-type cases actually observe pending allocations before early failure, and null/missing positive cases still assemble one correct tool. +- Cascade new parent into Cursor with a merge preserving both authors' commits and both structure sections; fast-forward the still-unpublished Logs branch to updated Cursor. Verify both ancestry edges. Do not mark the updated Cursor head verified until its own new evidence exists. +- Main returns to parent for the repair receipt/D, then resumes original Logs planning. Shipping #3702 still requires strict merge verification and actual dev ancestry before source #3673 closes. diff --git a/devlog/_fin/260906_d_integrations_delivery/030_cursor_schemas.md b/devlog/_fin/260906_d_integrations_delivery/030_cursor_schemas.md new file mode 100644 index 0000000000..2150e7af91 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/030_cursor_schemas.md @@ -0,0 +1,151 @@ +# 030 — Preserve Cursor executable tool schemas (#3628) + +## Loop specification + +- Class: C3 adapter contract carry across the current module split; spec-satisfaction, one implementation PABCD cycle. +- Trigger: bare exec_command advertisement omits supported execution fields, and freeform tools advertise an empty parameter object. +- Goal: preserve shell fields and a required string freeform input through advertisement and argument normalization, with reserved shell-name rejection. +- Non-goals: executing commands, changing approval/sandbox policy, nativeLocalExec defaults, OAuth or transport changes, changing generated protobuf code, rejoining split modules. +- Verifier: exact delivered-head hosted focused Cursor regressions, typecheck/full-suite, privacy and docs build. NO local tests, suites, typecheck or test:changed. Commands in this document run only on CI runners. +- Stop/outcomes: DONE only after acceptance rows, current-head required jobs and independent review pass and main proves dev integration. NOOP requires equivalent current-dev implementation plus evidence; external validation/permission gaps are BLOCKED/NEEDS_HUMAN, never success. +- Memory: this document plus the main-owned research/CI ledger. Main owns goals and FSM; this planning delegate does not alter either. +- Escalation: changed contracts/conflicts return to main at P; two failed distinct worker packets cause main reclaim. Further downward delegation is a P amendment. +- Resource/write scope: read local refs and supplied PR JSON, write only the two delegated roadmap files during this task; later implementation is restricted to the exact map below. Main owns authorized GitHub credentials, publication/merge and session resource bounds. No paid endpoint probes or tool execution are required. + +## Provenance, owner migration and blockers + +Inspected baseline `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c` on 2026-09-06 KST. +Source `origin/d-source-3628` is `37e6115c8a2ad3ffe20fee1e5a1e79a054625a56`. +Carry both original commits, in order: + +1. `1b29236c5bee9dd166b9d23983a2f1f1c2f0b793` — preserve executable tool schemas. +2. `37e6115c8a2ad3ffe20fee1e5a1e79a054625a56` — reject reserved freeform shell names. + +Both are authored by **SB Yoon <44089734+yansigit@users.noreply.github.com>** (`yansigit`). Preserve original authorship and add `Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com>` to the eventual squash commit/description. + +`.tmp/d-delivery/pr-3628.json:133` records source head, `:134` CONFLICTING. Its body reports 32 focused tests/full-suite success on source head; this is not candidate CI proof. The earlier reviewer finding at old `tool-definitions.ts:429` rejects bare freeform exec_command/shell_command. The author comment references pre-rebase `ae871bd19`; the fetched source's actual second commit above contains the correction. Carrying only the first commit would reintroduce the finding. Refresh actual current threads at integration; the supplied reviews/comments snapshot is not a complete unresolved-thread query. + +Current schema owner is **`src/adapters/cursor/tool-schemas.ts`**, moved by `3435d03983fdec305c6f2f4633650a15699a28e0` (split S04 L1/5). `tool-definitions.ts:6` imports schemas and `:8` preserves the public re-export facade. Do not cherry-pick a whole stale file over the split. Translate original schema hunks by symbol, retain current helpers, and add the new constant to the facade. + +## Exact file change map + +| Operation | Path | Change | +|---|---|---| +| MODIFY | `src/adapters/cursor/tool-schemas.ts` | All original production schema additions and both freeform guards, adapted from old tool-definitions.ts. | +| MODIFY | `src/adapters/cursor/tool-definitions.ts` | Add CURSOR_FREEFORM_INPUT_SCHEMA to the existing line-8 re-export only. | +| MODIFY | `tests/providers/cursor/cursor-tool-definitions.test.ts` | Port both source commits' complete regression hunks, preserving current file additions. | +| MODIFY | `docs-site/src/content/docs/reference/adapters.md` | Add exact Cursor contract bullet below under existing cursor section. | +| MODIFY | `structure/04_transports-and-sidecars.md` | Add schema ownership/normalization contract below. | +| NEW | none | Reuse existing file and test registration; no dependency or generated protobuf changes. | + +Read-only consumers: `tool-naming.ts:76` isBareCodexShellBridgeTool (`!namespace` plus reserved name), `tool-definitions.ts:80` buildCursorToolDefinitions and `:92` schema encoding, `live-transport.ts:672` toolSchemas normalization map, `arg-normalize.ts:69` normalizeArgKeys. Existing tool choice filtering and namespaced names must remain unchanged. Configuration/NOOP cannot supply missing schema declarations; reuse existing schema owners rather than add a parallel abstraction. + +## Exact patch references and adaptation + +The authoritative complete patch is: + +```sh +git diff 6b85485f32f783bafc61c79185d0cb937848859d 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56 -- src/adapters/cursor/tool-definitions.ts tests/providers/cursor/cursor-tool-definitions.test.ts +``` + +Apply all production hunks from the old path to these current symbols in `tool-schemas.ts`: + +1. `CURSOR_EXEC_COMMAND_INPUT_SCHEMA` at line 4: after max_output_tokens, add the original sandbox_permissions string enum (`use_default`, `require_escalated`), justification string, prefix_rule string array, login boolean, including original descriptions. Preserve required `["cmd"]` and additionalProperties false. +2. Add immediately after that constant: + +```ts +/** Cursor represents a Responses freeform tool body as one string-valued input field. */ +export const CURSOR_FREEFORM_INPUT_SCHEMA = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, +} as const; +``` + +3. `CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA` at line 64: add the same four property shapes from the source diff, keeping command as the canonical fallback and preserving max_output_chars. +4. At the start of BOTH `cursorToolInputSchema` (line 80) and `cursorToolArgNormalizeSchema` (line 89), insert this complete block before the current shell/function fallback: + +```ts +if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return CURSOR_FREEFORM_INPUT_SCHEMA; +} +``` + +5. Add `CURSOR_FREEFORM_INPUT_SCHEMA` to the existing `export { ... } from "./tool-schemas"` facade at tool-definitions.ts:8. Existing tests import through that facade; do not introduce a second definition or silently change the public import surface. +6. Port original test imports for CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA and CURSOR_FREEFORM_INPUT_SCHEMA and the complete 94-line regression addition. Imports remain `../../../src/...` in the existing providers/cursor test directory. Existing manifests already register this file (layout.json:554, test-layout-expected.json:391). + +Before: bare exec advertisement lacks four fields; normal/freeform schema lookup falls through to `parameters ?? {}`. After: both normalization and advertisement use one input string for freeform; bare reserved freeform names are rejected before either can acquire shell semantics. Namespaced shell-like tools stay ordinary freeform. Ordinary shell_command converts cmd to command; caller-supplied cmd-only exec_command stays cmd-only via existing shellBridgeArgNormalizeSchema. Keep this helper and current required-command validation intact. + +## Regression activation scenarios + +| Constructible input | Observable assertion | +|---|---| +| Bare non-freeform exec_command passed to buildCursorToolDefinitions | Decode protobuf ValueSchema and verify cmd schema plus enum/string/array/boolean field shapes; required cmd and additionalProperties false remain. | +| Freeform apply_patch with parameters `{}` | Both schema functions and decoded protobuf require a string input. | +| Freeform bare exec code-mode tool without parameters | Both schema functions return the same required-input contract. | +| Bare freeform exec_command and shell_command, each through both schema functions and buildCursorToolDefinitions | Throw the explicit reserved-shell-name error; include both names, not one representative. | +| Namespaced freeform exec_command under mcp__custom | Accepted required-input schema; never interpreted as bare shell bridge. | +| Bare ordinary function exec_command with cmd-only parameters | Advertised Cursor exec schema; normalization retains original cmd-only schema. | +| shell_command declared with command, receive cmd plus sandbox_permissions=require_escalated, justification, prefix_rule, login=false | Only cmd rewrites to command; all four values survive exactly, especially false. | +| exec_command declared cmd-only, same fields | cmd remains cmd, other values survive; no added command key. | +| Existing canonical command and an alias simultaneously | Existing normalizeArgKeys canonical precedence remains covered by adjacent tests. | + +Use literal expected contracts and decoded protobuf values, not only equality against the newly added constant (both could be wrong together). Strengthen the ported freeform test with literal `{type:"object", properties:{input:{type:"string"}}, required:["input"], additionalProperties:false}`. Verify both ordinary shell directions already at current test lines 131 and 159. Existing code-mode/structured-edit tests later in the file protect unchanged routing and tool-choice behavior. + +Optional additional hosted mutation experiment (not a completion prerequisite): with final tests and baseline schema code, observe missing-property/freeform assertions fail; restore final schema code and obtain green. Separately remove only the reserved-name guard in an isolated runner checkout to prove both rejection tests fail, then restore and rerun. Do not claim RED before these logs exist. + +Runner-only commands: + +```sh +bun test tests/providers/cursor/cursor-tool-definitions.test.ts +bun test tests/providers/cursor +``` + +Follow with existing hosted typecheck, full-suite, privacy scan and docs build. Capture exact head and actual executed jobs; label/hygiene green or action_required does not establish validation. Preserve no-local policy even on failure; inspect CI artifacts and repair the specific defect. Main uses authorized --no-verify pushes to avoid local prepush, not server policy. No workflow edits are planned. + +## User and architecture documentation patch + +The source body's claim that no user documentation is needed is not adopted: the advertised tool contract changes and the root instructions require documentation sync. + +Append this bullet inside `## cursor` (`docs-site/src/content/docs/reference/adapters.md:304`), before `## azure-openai`: + +```md +- Codex-compatible shell schemas retain sandbox permissions, justification, reusable + prefix rules and login mode. Freeform tools expose one required string `input`; + bare `exec_command` and `shell_command` names are reserved for non-freeform shell + bridges. Namespace a custom freeform tool that uses either name. These schema + declarations do not grant approval or change execution policy. +``` + +Append this block to the existing transport SOT, preserving 020 and peer additions: + +```md +## Cursor executable tool schema ownership + +`src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization +schemas; `tool-definitions.ts` remains the public facade and protobuf encoder. +Advertisement and normalization intentionally differ for shell bridges: Cursor may +emit `cmd`, while the declared Responses contract decides whether it becomes +`command`. Both paths preserve execution-control fields. Freeform tools use one +required string `input`; bare shell bridge names are rejected on the freeform path. +Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in +`tests/providers/cursor/cursor-tool-definitions.test.ts`. +``` + +Inspect directly affected translated adapter sections at P; add exact locale paths to this map if they contradict the English contract. No locale edit is justified solely by adding optional detail. Docs build remains CI-only. + +## Integration handoff + +030 follows 020 in the requested D stack; their runtime paths are independent, but the adapter reference and SOT are shared. Cascade stack updates after lower-layer changes, preserve each layer's review delta and attribution, and do not overwrite new split-owner behavior while resolving source conflicts. Main refreshes reviews and exact-head CI, merges bottom-up, verifies the landed commit is an ancestor of fetched dev, then promptly closes superseded #3628. Do not close on carry creation or CI success alone. This planning task writes no production code and performs no Git/GitHub mutations. + +## Roadmap lock clarification + +The implementation cycle certifies its published current-head candidate. Every dev-ancestry and original-closeout obligation remains mandatory in the separate landing work-phase, allowing the owner-requested stack to exist without treating publication as dev integration. Eligible lower layers may land early and are closed immediately after ancestry proof. + +## External review amendment: closed freeform object + +The advertised freeform schema must include additionalProperties:false, matching the existing custom-tool compatibility envelope. Include this literal property in schema and protobuf assertions; preserve ordinary named function schemas and reserved-name guards. diff --git a/devlog/_fin/260906_d_integrations_delivery/031_cursor_input_guidance.md b/devlog/_fin/260906_d_integrations_delivery/031_cursor_input_guidance.md new file mode 100644 index 0000000000..6f206a0f57 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/031_cursor_input_guidance.md @@ -0,0 +1,103 @@ +# Cursor freeform input guidance — follow-up repair plan + +Status: dedicated repair P cycle; source still248177c9. Prior Logs D completed its candidate verification; shipping obligations remain separate. +Inspected current checkout HEAD: `248177c9eccc639557c1770c59384dd6a6e27934` (2026-09-06 KST), after the original Cursor delivery and with ongoing Logs work. This is newer than the task's shorthand `6005+Logs`; all line anchors below describe the inspected source. Recheck them at the repair cycle's P after Logs C. + +## Review disposition and delivery boundary + +Accept the metadata-loss finding in `PRRT_kwDOS-0Gi86fmCS9` as a source-confirmed P2 regression. GitHub's read-only GraphQL response shows the thread unresolved on `src/adapters/cursor/tool-schemas.ts:115`, reviewing `6005ea8017dc7d113bba0d8dcef061d4f677c60f`: +https://github.com/lidge-jun/opencodex/pull/3707#discussion_r3941721795 + +The actual loss is confirmed by inspecting parser and schema code, not by running a test or making a live Cursor request. The claim that losing guidance can increase rejected model calls is plausible, but its runtime frequency was not measured here. + +Original #3628 was integrated by `6dd23d6314c41f1113639e042353aae9e6614e62`, also recorded in `.tmp/d-delivery/cursor-admin-audit.json`. Main should implement this as a new follow-up PR after Logs C. Do not amend, reset, rebase, or replace landed commits. Preserve SB Yoon's existing authorship and cite #3707/#3628 as provenance; do not attribute this later repair to an unperformed original-author change. + +## Actual source path and loss point + +1. `src/responses/parser-tools.ts:41` exports `buildTools`; `pushCustom` at line 67 handles custom/freeform tools. Lines 82–84 select a tool-scoped input description: apply_patch gets exact Begin Patch envelope guidance, other custom tools get generic freeform guidance. Line 88 stores it in `parameters.properties.input.description`, and line 89 marks `freeform: true`. Namespace children route through this same function (lines 109–111), so the metadata also exists for namespaced custom tools. Reserved `functions` groups flatten to bare tools. +2. `src/responses/parser.ts:465` and `:466` call buildTools for declared and discovered tool specs. This is production ingress, not a test-only construction. +3. `src/adapters/cursor/request-builder.ts:476` createCursorRequest filters the request-visible tools then applies the existing byte/count budget at `:483`; returned tools enter the Cursor request at `:497`. `applyCursorToolBudget` (`:79`) copies/filter-selects tool objects and measures actual definitions via cursorMcpToolsEncodedSize. It does not strip the nested description. +4. `src/adapters/cursor/tool-schemas.ts:110` cursorToolInputSchema rejects reserved bare freeform shell names, but line 115 then unconditionally returns CURSOR_FREEFORM_INPUT_SCHEMA. The constant at `:37` has input.type=string, required input, and additionalProperties=false, but no input.description. This is the loss point. The normalization branch at `:125`–`:130` repeats the same replacement. +5. `src/adapters/cursor/tool-definitions.ts:80` buildCursorToolDefinitions copies only tool.description to the top-level protobuf description (`:91`), and encodes cursorToolInputSchema(tool) into inputSchema at `:92`. A top-level description of “Apply a patch” cannot replace the parser-generated nested envelope guidance. +6. Both outgoing callers use the same definitions: `src/adapters/cursor/live-transport.ts:645` stores them in execContext; `src/adapters/cursor/protobuf-request.ts:1594` constructs them for the Run request and `:1699` includes them in McpTools. The latter also decodes inputSchema for model-visible text measurement (`:1333`, consumed at `:1714`). Fixing the schema owner therefore updates both advertisement sites and their byte accounting. +7. `live-transport.ts:672` independently stores cursorToolArgNormalizeSchema(tool) in its normalization map. `src/adapters/cursor/arg-normalize.ts:69` normalizes property names; descriptions do not alter key normalization. Preserve the description in both schema selectors for a consistent per-tool schema, without changing normalization rules. + +Falsification checks: `tests/responses/responses-parser.test.ts:102` already proves the parser emits apply_patch guidance, but stops before Cursor encoding. Current Cursor tests (`tests/providers/cursor/cursor-tool-definitions.test.ts:147`, `:188`) use empty parameters or missing metadata and expect the generic schema, so they cannot catch this loss. `tool-guidance.ts:187` contains code-mode/nested-helper prose and structured-edit tools provide another editing path, but neither preserves the discarded per-tool input metadata. This finding is metadata loss, not a claim that every Cursor editing path lacks all patch guidance. + +## Minimal implementation scope + +Class C2 bounded adapter repair, one separate implementation PABCD cycle owned by main. No new dependencies, parser changes, transport changes, tool execution, approval policy changes, facade exports, or generated protobuf updates. + +| Operation | Path | Planned change | +|---|---|---| +| MODIFY | `src/adapters/cursor/tool-schemas.ts` | Add a private schema builder that copies only a string-valued input.description onto the canonical closed freeform envelope; use it after the existing reserved-name guard in both schema selectors. | +| MODIFY | `tests/providers/cursor/cursor-tool-definitions.test.ts` | Add actual buildTools-to-protobuf regressions and schema/isolation controls; retain every existing shell, reserved-name, login=false and closed-schema test. | +| MODIFY | `structure/04_transports-and-sidecars.md` | In the existing Cursor executable schema ownership section, state that tool-specific input descriptions survive canonicalization while structure remains closed. Main owns this doc during planning. | +| MODIFY | `docs-site/src/content/docs/reference/adapters.md` | Amend the existing Cursor freeform bullet to state that tool-specific input guidance is retained; no duplicate section or unrelated locales. | +| NEW | none | Reuse the current test file and registration; no new test-layout entries. | + +Necessity/owner search: searched buildTools, CURSOR_FREEFORM_INPUT_SCHEMA, input.description, cursorToolInputSchema, cursorToolArgNormalizeSchema and modelVisibleToolText. A configuration change cannot recover metadata that the adapter unconditionally drops. Reuse the existing schema constant, schema module, parser, and protobuf encoder; do not copy apply_patch prose into Cursor code or import the Responses parser's object guard into the runtime adapter leaf merely for this repair. + +### Proposed source patch + +Insert this private helper immediately after the freeform constant (name is new; no equivalent per-tool builder exists in the inspected schema module): + +```ts +function cursorFreeformInputSchema(tool: OcxTool): unknown { + const properties = tool.parameters?.properties; + const input = properties && typeof properties === "object" && !Array.isArray(properties) + ? (properties as Record).input + : undefined; + const description = input && typeof input === "object" && !Array.isArray(input) + ? (input as Record).description + : undefined; + if (typeof description !== "string") return CURSOR_FREEFORM_INPUT_SCHEMA; + return { + ...CURSOR_FREEFORM_INPUT_SCHEMA, + properties: { + input: { ...CURSOR_FREEFORM_INPUT_SCHEMA.properties.input, description }, + }, + }; +} +``` + +Replace exactly the two `return CURSOR_FREEFORM_INPUT_SCHEMA;` statements in cursorToolInputSchema/cursorToolArgNormalizeSchema with `return cursorFreeformInputSchema(tool);`. Do not replace the helper's fallback return. Keep both reserved-name guards before the call. + +Preserve empty descriptions as strings; do not trim or synthesize guidance. Copy no arbitrary input schema keys, sibling properties, required lists, additionalProperties flags, enums, or constraints from the input parameters. Never mutate the shared constant or tool.parameters. A freeform tool with no valid description still receives exactly the current closed canonical schema. Ordinary function/shell schemas remain untouched. + +## Regression cases and independent oracles + +Add an import of `buildTools` from `../../../src/responses/parser-tools` to the existing Cursor test file. Reuse existing fromBinary/toJson/ValueSchema and buildCursorToolDefinitions. Do not test only an invented OcxTool: the primary regression must run the real parser conversion. + +1. **Real apply_patch ingress → schema → encoded protobuf.** Feed `buildTools([{type:"custom", name:"apply_patch", description:"Apply a patch"}])`. Assert one freeform tool and a source input.description equal to this literal: + ``Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope.`` + Assert both schema selectors AND decoded `buildCursorToolDefinitions(tools)[0].inputSchema` equal an independently written object: type object; properties exactly `{input:{type:"string",description: }}`; required exactly `["input"]`; additionalProperties exactly false. Top-level protobuf description remains `Apply a patch`. This fails at the current schema replacement, even though the parser's own test passes. +2. **Generic and namespaced custom ingress.** Build custom exec plus one namespace-wrapped custom tool (e.g. mcp__custom/exec_command) through buildTools. Their input.description must equal the independent literal `Raw freeform input for this tool.`; protobuf names retain the established namespace convention and neither schema receives apply_patch text. The namespaced shell-like custom tool remains accepted, while bare freeform exec_command/shell_command still fail existing tests. +3. **Per-tool isolation/no shared mutation.** Build two freeform fixtures with different input descriptions, e.g. `guidance-A` and `guidance-B`, plus a third metadata-free tool. Invoke both selectors and encode all three in one batch. Each output must retain only its own literal description; the third and CURSOR_FREEFORM_INPUT_SCHEMA must remain equal to the original description-free closed literal. Freeze the supplied nested parameter objects or compare their before/after values so accidental mutation is detected. +4. **Metadata is copied, shape is not.** Supply a freeform tool whose parameters declare input.type=number, extra input constraints, sibling command, required command, and additionalProperties=true, but input.description=`guidance-A`. Expected schema is still exactly closed `{input:string}` with only guidance-A retained. This prevents “fixing” the regression by returning/spreading arbitrary tool.parameters. Parameters are a Record; this is reachable from direct integration callers and guards against widening their freeform contract. +5. **Absent or ill-typed description fallback.** Keep all current `{}`/missing-input positive cases. Add representative numeric/null description and non-object properties/input cases; expect the same description-free closed literal, no throw and no metadata bleed. Use labeled tuple wrappers if an array-valued case is included. An empty string description should be copied, not replaced. +6. **Existing behavior controls.** Preserve all original executable shell field assertions, both cmd/command normalization directions with login=false, reserved bare names, namespaced acceptance, code-mode and structured-edit tests. Do not refresh existing expected generic schemas into values derived from the implementation constant. + +The main test is a runtime-source-to-wire contract comparison, not a test for prose in a markdown file. Full-object literal assertions catch closure/type/extra-property drift, while source-to-wire assertions prove the actual description transport path. Do not add a parallel prose owner in production. + +## Verification plan (remote/CI only) + +No tests, typecheck, builds, commits, or GitHub writes were performed for this investigation. Read-only source inspection and a read-only GraphQL review fetch are the evidence so far. + +After Logs C, main should capture a new pinned head and run remotely: + +```sh +bun test tests/providers/cursor/cursor-tool-definitions.test.ts tests/responses/responses-parser.test.ts tests/providers/cursor/cursor-request-builder.test.ts +``` + +Then required pinned-head typecheck/full-suite/privacy/docs build and hosted CI under the existing workflow. Preserve evidence that the new primary parser-to-protobuf regression fails on the old schema owner and passes after the repair if main performs the isolated remote RED/GREEN check; do not claim that proof before it exists. + +Byte accounting risk: retaining descriptions increases encoded catalog size, so a catalog already near the cap may omit a lower-priority tool. Both budget and wire use buildCursorToolDefinitions and the same schema helper; do not bypass the cap to conceal this correction. Existing `cursor-request-builder.test.ts` budget controls around lines 537–612 must remain green. No new budget mechanism is needed. + +Re-request independent review for blocker closure on the follow-up head. Main owns PR publication, exact-head CI, final review, merge proof and resolving the original late review with a link to the landed follow-up. This scratch plan does not certify the old merge gates or authorize rewriting landed history. + +## Repair cycle binding + +Loop archetype: bounded adapter regression repair. Trigger: review thread PRRT_kwDOS-0Gi86fmCS9. Goal: retain parser-owned per-tool input descriptions in both Cursor schema consumers without widening the closed freeform contract. Non-goals: parser semantics, shell execution, transport or approval changes. Verifier: pinned remote regression tests/typecheck/full suite and hosted CI; no local application checks. Stop: reviewed correction published and proven on dev, then original review resolved with its commit. Memory: this031 record plus ignored execution receipts. Outcomes: DONE only with evidence; a failed or unresolved gate remains open. Escalation: main reclaims the packet after two distinct worker failures; all added write scopes require a P amendment. + +Main owns FSM, docs, GitHub and remote orchestration. Inherited Godel worker owns only tool-schemas.ts and cursor-tool-definitions.test.ts. Independent Nash audits this plan and a separate reviewer verifies implementation. No local test/typecheck; shared remote test lock remains respected. The repair is a child of open Logs3712, then retargets dev when its parent lands. Every original authored commit remains intact. Remotealias now depends on this correction; final landing still verifies all D changes. diff --git a/devlog/_fin/260906_d_integrations_delivery/031_cursor_refresh.md b/devlog/_fin/260906_d_integrations_delivery/031_cursor_refresh.md new file mode 100644 index 0000000000..599084c6e0 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/031_cursor_refresh.md @@ -0,0 +1,9 @@ +# Cursor schema cycle P refresh + +Parent: d6bfb044a5dc6494cba57c1238ded7c23faf5586, open PR #3702. Original #3628 remains at 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56, author SB Yoon (yansigit). + +The source commits 1b29236c5bee9dd166b9d23983a2f1f1c2f0b793 and 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56 are prepared as mailbox patches with only production diff paths mapped from tool-definitions.ts to current tool-schemas.ts. `git apply --check` accepted the first mapped patch. Apply both in order during B, retaining their original author/date/message. Main then adds the new constant to the existing public re-export, closes the freeform object with additionalProperties:false, strengthens literal/protobuf assertions and updates the planned docs/structure. + +The current naming path preserves namespaces through namespacedToolName; the existing bare-shell helper remains the authority for the original rejection. No tool execution or approval policy changes are introduced. Read current 030 for all activation cases and complete scope. + +Main owns authored patch application, public facade and documentation edits, commits and PR publication. An inherited worker may amend only tool-schemas.ts and cursor-tool-definitions.test.ts after A; no Git or local tests/typecheck. Independent review plus current-head remote full/typecheck/docs and hosted CI supply proof. The candidate can remain open in the stack while shipping/closure criteria remain separately pending. diff --git a/devlog/_fin/260906_d_integrations_delivery/040_logs_filters.md b/devlog/_fin/260906_d_integrations_delivery/040_logs_filters.md new file mode 100644 index 0000000000..574e864bd7 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/040_logs_filters.md @@ -0,0 +1,210 @@ +# 040 — Composable Logs filters (#3625) + +## Loop specification and scope + +- Class: C2 product slice, developer-console dashboard, global/i18n, existing dense visual language, feedback-only motion. This file is a docs-only deliverable in the main agent's roadmap P cycle; implementation is a later, separate PABCD work-phase. +- Archetype: spec-satisfaction repair and integration. Trigger: #3625 exposes the already-landed rich Logs predicate through usable controls. +- Goal: combine surface, intercepted-request, provider, exact model, time, speed, status and conversation filters over the loaded log ring, with clear result counts, reset, and accessible keyboard controls. +- Non-goals: new log API parameters, persistence/URL schema, retention/export, incremental polling #3250, request transport changes, provider/model configuration, new dependencies, redesign of the Logs table, unrelated locale cleanup. +- Verifier: current-head hosted CI covering the tests below, GUI lint/build/typecheck, privacy and repository gates; rendered screenshot/interaction evidence from an isolated same-head Vite preview, CI-built artifact or hosted preview. No local tests, suites, typecheck, or build that invokes typecheck. No verification was executed during this planning task. +- Stop: implemented behavior, all acceptance rows, docs sync, fresh screenshots, author credit, current-head CI and main-owned dev ancestry proof. An author comment or green intake check is not completion evidence. +- Outcomes: DONE only with those receipts; NOOP only if current dev independently contains equivalent behavior and evidence; BLOCKED for an unavailable CI/preview/required review; NEEDS_HUMAN for an unresolved external scope decision. Never mark an incomplete slice done. +- Memory artifact: this document plus main-owned 000 roadmap/evidence ledger. No goal or orchestration mutations by this document owner. +- Bounds: this delegate reads local source refs and the supplied metadata and writes only this document; zero paid provider requests, zero local test/build processes, zero Git/GitHub mutation. Implementation inherits main's resource bound and credentials; no separate cost allocation is invented here. +- Escalation: main reclaims a packet after two distinct failed workers. Further implementation delegation is a P amendment with inherited user model settings; no mid-B widening. + +## P stale check, provenance and exact source patch + +Planning tree: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c` (read 2026-09-06 KST). +Source ref: `origin/d-source-3625` = `4f79746b4cedffeb61700113977cd72adf25c51f`. +Source base: `be81013fab6d83ff630ca5f38e7881678a303871`. +Metadata: `.tmp/d-delivery/pr-3625.json`; recorded PR author `yansigit`, display name **SB Yoon**. The JSON head agrees with the source ref; its mergeable/readiness fields are a captured snapshot, not fresh merge authorization. Its body still cites `232e324...`; the later author comment cites `4f79746...`. Both test reports are contributor claims, not integration-head proof. + +The complete baseline implementation is the exact four-commit sequence below. Read/apply its patch at the later B, then apply the explicit amendments in this document. Do not restore whole historical files over current files. + +| Order | Source commit | Authored change | +| --- | --- | --- | +| 1 | `6602c5610c6d7d8a1179b05c9f86598c4acd8fee` | Initial composable controls, state wiring, locales, styles and tests | +| 2 | `e053045e9a2d49b8d70546223b0d02313d4031fe` | Exact identities, option invalidation, relative clock, keyboard navigation and review corrections | +| 3 | `232e324b45afa617ccabb97374137c9faf7654ae` | Turkish copy and assertion refinements | +| 4 | `4f79746b4cedffeb61700113977cd72adf25c51f` | Test-global cleanup in finally | + +All four commits identify `SB Yoon <44089734+yansigit@users.noreply.github.com>`. +Preserve authored commits where feasible. A carried/reimplemented or squash commit and its PR description must retain: + +```text +Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> +``` + +Read-only patch locator (not an instruction to execute tests or mutate Git): + +```sh +git diff be81013fab6d83ff630ca5f38e7881678a303871 4f79746b4cedffeb61700113977cd72adf25c51f -- gui +git log --format='%H %an <%ae> %s' be81013fab6d83ff630ca5f38e7881678a303871..4f79746b4cedffeb61700113977cd72adf25c51f +``` + +Current `Logs.tsx`, `logs-filter.ts`, `logs-auto-refresh.test.tsx`, and `logs-filter.test.ts` have no delta from that source base. All nine locale catalogs and `styles.css` do have intervening dev changes. Recheck these facts at implementation P and rebase the patch semantically against the actual stacked parent. Do not import source-base package versions, lockfiles, locale-wide rewrites or old CSS. + +## Exact implementation file map + +The immutable range above is the exact before/after source patch for all 17 carried files. “MODIFY” below means apply that path's hunks to the current parent, preserving unrelated edits; “NEW” means take the source blob and the test amendments specified below. + +| Path | Operation | Before → after and immutable source locator | +| --- | --- | --- | +| `gui/src/pages/Logs.tsx` | MODIFY | At current lines 376–380 replace five independent states with `filters: LogFilterState` and `filterClockNow`; import `useMemo`, bar and existing engine. Replace line 475's fresh empty array with module-level `EMPTY_LOGS`. Replace lines 502–521 with the source clock/hash/options/predicate block. Replace lines 600–659 toolbar with `LogsFilterBar`; distinguish filtered empty state at line 717; detail conversation action updates the shared state. Exact diff: the range above, this path. | +| `gui/src/pages/logs-filter-bar.tsx` | NEW | Source-head lines 1–127: controlled `LogsFilterBar`, no global store. Native labeled selects, intercepted checkbox, conversation input, active count/reset. Surface radios have roving tabIndex and keydown helper. Speed values map to `[−∞,15)`, `[15,50)`, `[50,+∞)` bounds. | +| `gui/src/pages/logs-surface-keydown.ts` | NEW | Source-head lines 1–24: ordered all/claude/codex/grok; wrapping ArrowLeft/Right/Up/Down, Home/End, preventDefault only for handled keys, select then focus matching radio id. | +| `gui/src/pages/logs-filter.ts` | MODIFY | Current lines 119–126: `value?.includes(modelQuery)` → `value === modelQuery` for requested, resolved and attempted model identities. Keep whitespace/case normalization. Standalone `logs-model-filter.ts` retains substring semantics. | +| `gui/src/styles.css` | MODIFY | Add source two selectors after current `.logs-toolbar` at 2145, then the bounded responsive amendment below. Keep table widths/clipping and all Models rules. | +| `gui/src/i18n/en.ts` | MODIFY | Add source's 20 keys after current `logs.filter.surface.label` at 700; English owns `TKey`. | +| `gui/src/i18n/de.ts` | MODIFY | Same 20 source locale keys after current line 667. | +| `gui/src/i18n/fr.ts` | MODIFY | Same keys after line 681; preserve final number-neutral `Affichage de {count} sur {total}`. | +| `gui/src/i18n/ja.ts` | MODIFY | Same keys after line 643. | +| `gui/src/i18n/ko.ts` | MODIFY | Same keys after line 686; source copy includes `필터 초기화`, `{total}개 중 {count}개 표시`. | +| `gui/src/i18n/ru.ts` | MODIFY | Same keys after line 684. | +| `gui/src/i18n/tr.ts` | MODIFY | Same keys after line 691, final `jeton/sn` speed wording; update `logs.metric.tokPerSecTitle` at 723 to `Tam istek süresince saniye başına çıktı jetonu`. | +| `gui/src/i18n/zh.ts` | MODIFY | Same keys after line 679 (GUI Simplified Chinese). | +| `gui/src/i18n/zh-TW.ts` | MODIFY | Same keys after line 536 (GUI Traditional Chinese). | +| `gui/tests/logs-filter.test.ts` | MODIFY | Source patch lines 49 onward replaces substring expectations with complete identities; adds partial/stale negative cases; preserve status/time/speed/malformed-attempt cases at current 80–143. | +| `gui/tests/logs-auto-refresh.test.tsx` | MODIFY | Source patch confines intercepted-row assertions at 542–570 to `.logs-table tbody`, since select options legitimately retain hidden model names. Add behavioral integration cases below using this file's existing renderer/cache/fake-clock harness. | +| `gui/tests/logs-filter-bar.test.ts` | NEW | Source-head 115-line file as baseline; replace its first three source-string “wiring” checks with observable controls/interaction coverage. Retain and expand the actual keyboard/reset tests, with cleanup on assertion failure. | + +New documentation changes beyond the source PR: `structure/05_gui-and-management-api.md`, and all eight existing `docs-site/src/content/docs/{,ko/,fr/,ja/,ru/,tr/,zh-cn/,zh-tw/}guides/web-dashboard.md` paths, specified below. No German dashboard page exists at this head; do not create an unrelated locale tree. No root test-layout manifest change is required for tests under `gui/tests/`; preserve the repository's `tests/` manifests unchanged. + +### Existing state and behavior to preserve + +- `gui/src/pages/Logs.tsx:463` owns the resource fetch, cache, 2-second poll and backoff. Filters consume this ring; they do not fetch a new dataset. Keep stale/cold/loading states at 482–499 and the table/details transport untouched. +- `Logs.tsx:526` virtualizes `filteredLogs`, rendering newest first by reverse indexing. Retain stable request keys, column schema and detail behavior. Do not sort the input merely for filter selection. +- `logs-filter.ts:95` remains the sole predicate. Model/provider option extraction at 162 includes failover attempts, normalized duplicate handling and stable code-point ordering. Options derive from the full loaded ring, not the filtered subset. +- All filters compose with AND at row level. A requested model and a provider appearing on another attempt can both match that same row; do not silently introduce same-attempt pairing. +- Clock refresh is 30 seconds only while `timeWindow !== 'all'` and tab is Logs. It is independent of auto-refresh, so paused network refresh does not freeze time-relative filtering. Cleanup on window changes, Debug tab and unmount. +- If the loaded ring loses the selected model/provider, clear only each vanished identity; leave status/time/conversation and still-present identities intact. No permanent state persistence is introduced. +- Conversation hashing retains cancellation against obsolete input; reset clears both input and hash. Preserve the existing opaque-id path, and verify delayed hash completion cannot restore a reset filter. + +### Responsive CSS amendment (bounded to this toolbar) + +The source's two rules alone do not address current `.logs-filter-field .input { min-width:220px; max-width:360px; }` at `styles.css:2174`, or the four 64px-minimum surface buttons. At narrow widths labels plus 220px controls can exceed the content area. Add these rules adjacent to the two source additions; never change global `.select-sm`, `.input` or `.btn`: + +```css +.logs-filter-container { min-width: 0; } +.logs-filter-container .logs-filter-field { min-width: 0; max-width: 100%; flex-wrap: wrap; } +.logs-filter-container .logs-filter-field .input { min-width: 0; max-width: 100%; } +.logs-filter-container .logs-filter-status { flex-wrap: wrap; max-width: 100%; } +.logs-filter-container .logs-segmented { max-width: 100%; flex-wrap: wrap; } +``` + +Keep source `.logs-filter-status` flex alignment/gap/margin-left and `.logs-toolbar-secondary` spacing. Controls may wrap by field and radios may wrap as a group; native select identity remains readable from its option menu. The table keeps its independent horizontal scroller and 1100px minimum width. Browser acceptance, not CSS-string presence, decides containment. If these narrowly scoped rules are insufficient in the measured screenshot, amend only this block and document the measured overflow before the repair. + +### Locale and B3659 ownership handshake + +D owns the 20 new Logs keys plus Turkish speed-title correction; B3659 owns its Models Hide/Delete/cleanup/sync keys. Exact D key set: + +```text +logs.filter.model.all +logs.filter.provider.label / .all +logs.filter.status.label / .all / .success / .errors +logs.filter.time.label / .all / .15m / .1h / .24h +logs.filter.speed.label / .all / .slow / .medium / .fast +logs.filter.reset +logs.filter.showingCount +logs.noMatchingRequests +``` + +The source range supplies the exact translated values for every key, including both `{count}` and `{total}` placeholders. Retain now-unused `logs.filter.model.placeholder` and conversation-clear keys; deleting them is unrelated churn. + +Before B starts, main exchanges the actual B3659 head and changed-key/selector inventory. No B3659 source ref was supplied to this delegate, so no fresh claim of hunk disjointness is made. B confirmed on this run that source #3659 changes nine locale files but no stylesheet, and its implementation has not started. Preserve both lanes by keys; recheck any later B style additions rather than assuming a current stylesheet overlap. B preserves `.logs-*`; D preserves `.models-*` and B's shared control fixes. Shared `.select-sm` or global token changes belong to main's integration review. Re-read the final union after either lower stack layer changes; CI must run against that union. Public dashboard docs can also overlap: D inserts the Logs subsection and preserves B's Models wording. + +## Behavioral regression amendments and acceptance + +Use existing `gui/tests/logs-auto-refresh.test.tsx:1–164`: Happy DOM, `mountLogs`, virtualizer layout stubs, isolated resource stores, mocked `/api/settings` and `/api/logs`, `act`, fake timers and explicit microtask settlement. All execution is CI-only. Do not add a new test runner or assert only source substrings. Expected row ids/counts must be hardcoded independently of `filterLogs`. + +| ID | Reachable activation | Required observable result / owner | +| --- | --- | --- | +| L01 defaults | Mock loaded ring containing Codex, Claude and Grok entries, no filters | All rows remain newest-first; no active count/reset; existing loading/error/detail tests still pass. `logs-auto-refresh.test.tsx`. | +| L02 composition | Rows differ independently in surface, provider, exact model, status and intercepted marker; select controls sequentially | Only the hand-selected intersection row remains; displayed count uses filtered length and unfiltered ring length. Model/provider options still include excluded rows. `logs-auto-refresh.test.tsx`. | +| L03 identities | Include `model-a`, `model-a-plus`, requested/resolved/fallback-only identities and case/space variants | `model-a` does not match `model-a-plus`; full fallback/resolved identity matches; normalized duplicates produce one stable option. Preserve standalone substring helper tests. `logs-filter.test.ts`. | +| L04 status/speed | Include 200, 299, 300, 400, 599 and malformed status; finite rates 14.99, 15, 49.99, 50 plus unavailable | Success only 2xx; errors only 4xx/5xx; slow <15, medium >=15 and <50, fast >=50; unavailable excluded only with speed bound. UI maps every speed option to these bounds. Engine file plus bar rendered events. | +| L05 time expiry | Fake now T; timestamp T−15m+1s; select 15m, disable auto-refresh, retain identical log snapshot | Row initially visible, disappears on first 30s clock tick; fetch count stays unchanged after pause. Repeat predicate boundaries for 1h and 24h with injected clock. No real sleep. `logs-auto-refresh.test.tsx` plus existing engine windows. | +| L06 clock lifecycle | Activate 15m then change 1h, switch Debug, return Logs, finally unmount | Track the 30,000ms interval handle via spies on window setInterval/clearInterval; old handle cleared on each deactivation; one live filter interval after reactivation; none after unmount. Do not count unrelated Happy DOM/virtualizer timers. | +| L07 ring rollover | Select model/provider from snapshot A; refresh with B lacking only selected model, then C lacking selected provider | Missing select resets to All; still-present selection and unrelated status/time/conversation remain. Labels never become blank while a hidden stale value excludes rows. | +| L08 reset/hash | Enter conversation, allow hash resolve; combine with status/time; click reset. Repeat with first hash resolution deferred until after reset | All controls default, full ring visible, count/reset disappear; late old hash does not resurrect filtering. Detail “filter conversation” action updates shared state and closes dialog. | +| L09 empty/error distinction | Cold empty API ring; separately populated ring excluded by status; separately cold failure and stale failed poll | Empty ring shows no-requests, filtered ring shows no-matching, cold failure retains error, stale failure keeps rows/banner. No empty-state flash during refresh. | +| L10 keyboard | Focus selected surface radio; ArrowRight from Grok, ArrowLeft from All, Up/Down, Home/End and unrelated key | Selection and focus wrap correctly; exactly one radio tab stop; unrelated key neither changes selection nor prevents default. Test actual rendered `aria-checked`/tabIndex plus helper; reset remains keyboard reachable. | +| L11 presentation | EN/KO/FR/DE, dark/light, widths 1440/768/390/320; long model and provider labels | Toolbar remains within page; count/reset wrap; focus visible; no clipped functional labels. Table scrolls inside its wrapper, not whole page. Browser receipts below. | + +In `logs-filter-bar.test.ts`, replace the source-oracle tests at source-head lines 10–36 with real rendered field/change assertions; move clock behavior proof to L05/L06. Expand reset fixture to several active fields rather than status only. Ensure every root unmounts in `finally` before restoring globals, including the existing rendered-reset test; restore property descriptors where practical. Keep the final source commit's keyboard-test `finally` fix. These changes strengthen observable oracles, not lower coverage to obtain green. + +## Documentation exact additions + +`docs-site/src/content/docs/guides/web-dashboard.md:87` retains the existing Logs overview row (it remains true); insert a new subsection immediately before `### Linking to a section` at line 92. Add the corresponding localized subsection immediately before the existing translated section-link heading in each of the seven translated guides. The following is the exact new English block: + +```md +### Filtering request logs + +Logs filters combine surface, intercepted requests, provider, exact model, status, time, +speed, and conversation ID over the currently loaded request ring. Provider and model +choices also include fallback attempts; model matching ignores case and surrounding spaces +but does not match partial names. Choices that disappear from the ring reset to All. + +Time windows cover the last 15 minutes, hour, or day and refresh every 30 seconds while the +Logs tab is active, even with auto-refresh off. Speed uses output tokens per second over the +full request duration: below 15, 15 to below 50, or at least 50. Unavailable speed values are +excluded when a speed filter is active. Success means 2xx; errors mean 4xx or 5xx. + +Active filters show the matching count out of the loaded total. Reset filters restores all +rows; “No matching requests” differs from an empty log ring. Use arrow keys or Home/End in +the surface selector. These controls do not query historical records beyond the loaded ring. +``` + +Translations must preserve every threshold, exact-identity rule and loaded-ring scope. Use the following exact localized summary blocks at the same insertion seam; they cover the same contract without rewriting the rest of each page: + +| Locale path segment | Heading and paragraph to insert | +| --- | --- | +| `ko/` | `### 요청 로그 필터` — `Logs에서 화면 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, 속도, 대화 ID를 함께 필터링합니다. 현재 불러온 로그만 대상이며 공급자·모델 선택지에는 폴백 시도도 포함됩니다. 모델명은 대소문자와 앞뒤 공백을 무시하지만 부분 이름은 일치하지 않습니다. 로그에서 사라진 선택지는 전체로 돌아갑니다. 시간 범위는 최근 15분·1시간·1일이며 Logs 탭에서는 자동 새로고침을 꺼도 30초마다 갱신됩니다. 속도는 전체 요청 시간 기준 초당 출력 토큰으로, 15 미만·15 이상 50 미만·50 이상입니다. 속도 필터를 켜면 측정값 없는 요청은 제외됩니다. 성공은 2xx, 오류는 4xx·5xx입니다. 일치 건수와 불러온 전체 건수를 표시하며 필터 초기화로 모든 행을 복원합니다. 일치하는 요청이 없는 상태와 빈 로그는 구분합니다. 화면 종류 선택은 방향키와 Home/End로 조작할 수 있습니다. 불러온 범위 밖의 과거 로그는 조회하지 않습니다.` | +| `fr/` | `### Filtrer les requêtes` — `Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. Les périodes de 15 minutes, une heure et un jour évoluent toutes les 30 secondes dans l’onglet Logs, même sans actualisation automatique. La vitesse mesure les jetons de sortie par seconde sur toute la durée : moins de 15, de 15 à moins de 50, ou au moins 50 ; les valeurs indisponibles sont exclues quand ce filtre est actif. Réussite : 2xx ; erreur : 4xx/5xx. Le compteur compare les résultats au total chargé ; la réinitialisation restaure toutes les lignes. Aucun résultat diffère d’un journal vide. Flèches et Home/End pilotent le sélecteur d’interface. Aucun historique au-delà du journal chargé n’est interrogé.` | +| `ja/` | `### リクエストログの絞り込み` — `Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。時間は直近15分・1時間・1日で、Logsタブでは自動更新をオフにしても30秒ごとに更新します。速度はリクエスト全体の時間あたりの毎秒出力トークン数で、15未満、15以上50未満、50以上です。速度フィルター中は測定不能な行を除外します。成功は2xx、エラーは4xx/5xxです。一致件数と読み込み総数を表示し、リセットで全行を復元します。一致なしと空ログを区別します。サーフェスは矢印キーとHome/Endで操作できます。読み込み範囲外の履歴は検索しません。` | +| `ru/` | `### Фильтрация запросов` — `Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. Периоды 15 минут, час и сутки обновляются каждые 30 секунд на вкладке Logs даже при выключенном автообновлении. Скорость — выходные токены в секунду за полную длительность запроса: меньше 15, от 15 до менее 50, не менее 50; недоступные значения исключаются при активном фильтре скорости. Успех — 2xx, ошибки — 4xx/5xx. Счётчик показывает совпадения из загруженного общего числа; сброс возвращает все строки. Нет совпадений и пустой журнал различаются. Источник выбирается стрелками и Home/End. История вне загруженного журнала не запрашивается.` | +| `tr/` | `### İstek günlüklerini filtreleme` — `Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. Son 15 dakika, saat ve gün pencereleri Logs sekmesinde otomatik yenileme kapalıyken de 30 saniyede bir güncellenir. Hız, tam istek süresindeki saniyelik çıktı jetonudur: 15 altı, 15 dahil 50 altı, en az 50; hız filtresi açıkken ölçülemeyenler dışlanır. Başarı 2xx, hata 4xx/5xx anlamındadır. Sayaç eşleşen ve yüklü toplam sayıları gösterir; sıfırlama tüm satırları geri getirir. Eşleşme olmaması boş günlükten ayrılır. Yüzey seçimi oklar ve Home/End ile çalışır. Yüklü günlüğün dışındaki geçmiş sorgulanmaz.` | +| `zh-cn/` | `### 筛选请求日志` — `Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。时间范围为最近 15 分钟、1 小时或 1 天;Logs 标签页每 30 秒更新一次,即使关闭自动刷新也会更新。速度按完整请求耗时计算每秒输出 token,分为小于 15、15 至小于 50、至少 50;启用速度筛选时排除无测量值的请求。成功为 2xx,错误为 4xx/5xx。显示匹配数与已加载总数;重置恢复全部行,并区分无匹配与空日志。界面选择支持方向键及 Home/End,不查询已加载范围之外的历史记录。` | +| `zh-tw/` | `### 篩選請求日誌` — `Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。時間範圍為最近 15 分鐘、1 小時或 1 天;Logs 分頁每 30 秒更新一次,即使關閉自動重新整理也會更新。速度按完整請求耗時計算每秒輸出 token,分為小於 15、15 至小於 50、至少 50;啟用速度篩選時排除無測量值的請求。成功為 2xx,錯誤為 4xx/5xx。顯示符合數與已載入總數;重設恢復全部列,並區分無符合結果與空日誌。介面選擇支援方向鍵及 Home/End,不查詢已載入範圍以外的歷史記錄。` | + +At `structure/05_gui-and-management-api.md:130`, in the Logs & Debug row replace only `Logs tab: request/runtime logs for local diagnosis.` with: + +```text +Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring without changing the log API. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. +``` + +Keep Debug/API/auth sections and Models content intact. The user guide is the behavior source of truth; the structure row records ownership rather than duplicating every label. + +## CI-only verification and screenshot receipt + +Commands below are a handoff to hosted CI, not permission to run locally. This roadmap task runs none of them. `gui build` runs `tsc -b`, so it is also prohibited locally. + +1. Current `.github/workflows/ci.yml:416–446` runs GUI lint, root typecheck, `cd gui && bun test --isolate tests`, privacy scan and GUI build. Require those steps to execute on the implementation head, plus required repository/platform jobs; an aggregate success with skipped tests is insufficient. +2. Focused CI receipts identify `gui/tests/logs-filter.test.ts`, `gui/tests/logs-filter-bar.test.ts`, `gui/tests/logs-auto-refresh.test.tsx`, `gui/tests/logs-model-filter.test.ts`, `gui/tests/logs-surface-filter.test.ts`, `gui/tests/logs-tab-keydown.test.ts`, and `gui/tests/logs-table-overflow.test.ts`. Full GUI test execution may supply these receipts; avoid redundant unchanged reruns. +3. Verify visible-copy checking in hosted CI: `cd gui && bun run lint:i18n`. This exact script is not a separate step in the inspected CI workflow; main must prove equivalent lint coverage or arrange a hosted run. Do not claim it ran merely because general CI is green. +4. Docs validation is `cd docs-site && bun install --frozen-lockfile && bun run build` on CI, per docs-site instructions. Confirm actual job/step coverage rather than assume a workflow filename. If absent, main arranges a narrowly scoped hosted verifier before readiness. +5. Screenshots come from the final candidate in an isolated Vite dev preview (bundling only; no typecheck/test command), a CI-built artifact, or a hosted preview. Do not use the existing live port 10100 to claim this patch works; that service is not this candidate. Use native in-app browser inspect → act → inspect; do not install Playwright or run any local suite. +6. Use synthetic request metadata in the isolated preview: distinct surface/provider/model/status/rate combinations and opaque conversation ids, no real accounts, secrets or request bodies. Capture 1440×1000 EN/light and KO/dark with combined filters and count/reset, 768×1024 DE, and 390×844 plus 320×800 FR/KO. Include one no-matches state, one empty-ring state and keyboard-focused radio/reset state. The 320px capture must show toolbar containment separately from intended table scrolling. +7. Main stores screenshots under its ignored evidence directory (suggested `.tmp/d-delivery/screenshots/3625/`), with a manifest naming head SHA, preview URL, viewport, locale/theme, scenario, observed result and file. Inspect each actual image. Publish a durable screenshot URL in the integration PR description; the old source PR screenshot is reference only, not final-head proof. +8. For L05/L06 use deterministic CI test output for elapsed-time proof, not a screenshot or timed sleep. For browser flows record console/network state and check filter changes add no new request parameters or extra fetches beyond existing polling. + +## Stack, attribution and closure handoff + +Main owns branch creation, cherry-pick/reimplementation, commit, `--no-verify` push, PR template, review, merge and closure. This delegate performs none. Place this logical slice after the preceding D stack layer chosen in 000; its GUI/source changes have no semantic dependency on the earlier Cursor/tool-call work, but inherit that parent for stack topology. Re-read the actual parent at P, and cascade lower-layer updates before pushing upper layers. + +Merge bottom-up. Before landing, require current-head CI, screenshot URL, contributor trailer and applicable maintainer review. Retarget children before deleting a parent branch. After merge, main fetches dev and proves the integration merge commit is its ancestor. Close original #3625 immediately after that proof if a carry PR superseded it, linking the landing. No issue is linked in the supplied source PR metadata; do not close another D issue or #3659 as a side effect. + +## Open gates and document verification + +- No blocker to writing this roadmap. Implementation remains pending. +- B3659's actual current changed-key/selector inventory must be checked by main before merging shared files; no coordination message was sent by this delegate. +- Source tests lack behavioral clock/rollover coverage; L05–L08 are required amendments, not verified results. +- Source PR has no docs-site delta; the explicit documentation additions above are required. +- Current integrated CI, hosted preview/screenshots, independent review and final dev ancestry are not yet available from this document's read-only snapshot. +- Author's green local reports and cached readiness state do not satisfy these gates. +- Verification for this docs-only deliverable: inspected the source range, current consumers/styles/test harness and supplied PR JSON; read back this document and checked only its own diff/paths. No production code, peer document, Git state, GitHub state, tests, typecheck, goal or orchestration was changed. + +## Roadmap lock clarification + +The implementation cycle certifies its current-head published candidate. Final dev-ancestry and original-closeout requirements remain in the separate landing work-phase; lower layers may land early after all gates pass. An isolated Vite preview is permitted for UI evidence; local test/typecheck commands remain forbidden. diff --git a/devlog/_fin/260906_d_integrations_delivery/041_logs_refresh.md b/devlog/_fin/260906_d_integrations_delivery/041_logs_refresh.md new file mode 100644 index 0000000000..6848309c2c --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/041_logs_refresh.md @@ -0,0 +1,29 @@ +# Logs cycle P refresh + +Current stack parent: Cursor #3707 at6005ea8017dc7d113bba0d8dcef061d4f677c60f, including the parent index repair and current dev. Original #3625 remains4f79746b4cedffeb61700113977cd72adf25c51f; its four SB Yoon-authored mailbox patches are retained in scratch. The first patch dry-run applies on this tree. Apply all four during B, preserving every authored commit, then add only the 040 amendments. + +B coordination confirms3659 implementation has not started; its original scope overlaps nine locales, not styles.css. D owns logs.* keys and scoped.logs-* rules; B will carry both sets when its work starts. No whole-file overwrite of locale dictionaries. + +## Design read and ownership + +Existing dense diagnostic dashboard, existing colors/fonts/native selects and table. Primary workflow is immediate local filtering of already-loaded rows; reset restores defaults. Distinguish an empty ring, no matches, cold network failure, and stale refresh error. No URL-persistence feature, wizard, new icon library or redesign is introduced. Those are outside the adopted original contract. + +Main applies original commits and owns Git/CI/stack, QA fixture and screenshot capture. After A, an inherited implementation worker owns only gui/src/pages/{Logs.tsx,logs-filter-bar.tsx,logs-filter.ts,logs-surface-keydown.ts}, gui/src/styles.css and the four existing/new Logs test files named in040. A separate document worker owns eight web-dashboard guides and structure05. Main preserves original locale commits and resolves any local-key conflicts. + +## Browser verification construction + +Use an ignored .tmp fixture that mounts the real Logs component with the real LanguageProvider and stylesheet. Logs accepts apiBase and consumes only settings/logs for the Logs tab, so a Vite middleware serves canonical synthetic LogEntry arrays and settings under an isolated same-origin /__qa/ path. No real proxy/account data or port10100 is used. Vite performs bundling only; no local test/typecheck/build script is executed. The fixture selects locale/theme and dataset from its own query parameters through normal React/DOM initialization. Browser interaction uses the native in-app browser tooling and actual controls; do not inspect private browser stores. + +Capture component behavior with the source branch at its final UI commit: composition/reset/exact model, no matches/empty, keyboard radio navigation, desktop/mobile containment. Use stable synthetic timestamps away from range boundaries; deterministic timer/rollover behavior remains remote-test evidence. Publish sanitized screenshots under the existing docs-site/public/screenshots convention with immutable commit URLs in the PR. Record head/URL/viewport/locale/theme/scenario for each actual image. + +Remote validation uses project Bun1.4 and explicit Node22.22 in a private macmini checkout: complete suite/typecheck, GUI lint/i18n/build, docs build. Hosted CI remains recorded separately and required at final integration. No local application suites/typecheck. + +## Audit amendment: production layout constraints + +The preview must use the actual stylesheet's `.app` → `.main` → `.main-inner` structure, not mount Logs at full viewport width. Include a `.sidebar` rail occupying the production232px desktop grid column, and the production `.mobile-topbar`/off-canvas sidebar arrangement at the existing breakpoint. The base main-inner max-width980px is overridden to1200px by `.main-inner:has(.logs-page)`; preserve that actual cascade and32px/36px/64px desktop padding, plus22px/18px/48px mobile padding, remain untouched. Render an inert representative navigation rail using existing classes; only Logs functionality is under test. No fixture CSS may widen the main container or shrink these paddings. + +Before each containment capture, inspect rendered `.app`, `.main`, `.main-inner`, toolbar and table-wrapper rectangles at the requested viewport. The toolbar must fit the actual content box; the table's deliberate horizontal scroller is checked separately. This folds audit blocker1 and prevents falsely passing a wide standalone preview. + +## Tooling preparation + +Local GUI dependencies may be installed exactly from the committed lockfile with lifecycle scripts disabled, solely to run the Vite preview. This runs no local application test/typecheck/build script. All suite, lint, typecheck and production-build gates remain remote. diff --git a/devlog/_fin/260906_d_integrations_delivery/042_logs_review_corrections.md b/devlog/_fin/260906_d_integrations_delivery/042_logs_review_corrections.md new file mode 100644 index 0000000000..7568eed82e --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/042_logs_review_corrections.md @@ -0,0 +1,33 @@ +# Logs review corrections: focus and proxy-relative windows + +## Loop specification + +Archetype: bounded correctness repair (C3). Trigger: #3712 review threads PRRT_kwDOS-0Gi86fmmdd and PRRT_kwDOS-0Gi86fmpGe. Goal: keyboard Reset returns focus to a stable selected surface control; relative windows use the proxy clock despite browser wall-clock skew. Non-goals: changing log history, server-side filtering, auth, URLs, or UI layout. Verifier: rendered GUI cases, real management envelope test, pinned remote typecheck/root/GUI checks plus browser activation. Stop: candidate verified; final logs-proof/c-2 still require dev integration and hosted CI. Memory: this042 and ignored receipts. Failed gates stay open. Main reclaims a delegated packet after two distinct worker failures; new scopes require a P amendment. + +Prior D accepted the Cursor guidance candidate92f848e8, with final c-4 shipping still open. These parent Logs findings stopped the admin merge before any mutation. Build the correction on Logs248177c9, preserve the child3715 commit, then merge the corrected parent into that child. Do not rewrite either original author history. Existing screenshots describe the unchanged layout; browser verification adds reset-focus and a deliberately skewed synthetic proxy clock. + +## Grounded owners and changes + +- `src/server/management/logs-usage-routes.ts` GET /api/logs already returns an envelope with timeZone,total,logs (older array support remains in the GUI). Add `generatedAt: Date.now()` in that envelope. It is the proxy epoch milliseconds sampled while preparing the response. No headers/auth/CORS or existing fields change. +- `tests/server/logs-timezone.test.ts`: real handleManagementAPI response must include a finite generatedAt between pre/post request wall-clock samples and still carry logs/total/timeZone. Do not mirror the implementation in a fake formatter. +- `gui/src/pages/Logs.tsx`: capture valid numeric generatedAt on successful logs reads and anchor it to browser performance.now at receipt. The filter clock advances from server epoch plus monotonic elapsed time, not browser Date.now. New samples resynchronize the anchor. An aborted/stale response must not change the clock; clear/replace the anchor when apiBase/resourceKey changes. Network failures keep the last accepted anchor. Legacy arrays/envelopes lacking a usable timestamp preserve the existing browser-clock fallback until a server sample exists; do not pretend old servers supply precision they lack. Poll backoff remains separate. +- Small pure clock logic may live in `gui/src/pages/logs-clock.ts` if that keeps the large page readable; no generic clock framework. Document creation (generatedAt), serialization (envelope), validation/deserialization (finite nonnegative number), and consumers (relative-window timer and immediate selection) in the helper/page. Timezone formatting still uses timeZone independently. +- `gui/src/pages/logs-filter-bar.tsx`: the Reset click handler first performs the existing state reset, then restores focus to the stable All surface radio with a component-owned ref; avoid document-global queries or per-render focus stealing. Pointer and keyboard activation share the handler. The surface control remains mounted and selected after reset; no permanently disabled toolbar or layout expansion. +- `gui/tests/logs-auto-refresh.test.tsx` and `logs-filter-bar.test.ts`: actual rendered Reset activation verifies document.activeElement points to All after reset and the filters/default rows are restored. Add generatedAt envelope fixtures for browser clocks ahead AND behind by hours; a fresh row stays in15m while an older row is excluded. Advance the monotonic clock without network/with auto-refresh OFF to expire the row. A browser wall-clock jump after sampling must not shift the window. Cover fresh sample resync, malformed/legacy fallback, API-base switch and aborted late response not poisoning the active clock. Existing rollover/hash/timer tests remain meaningful, adapting their clock seam where required. A small logs-clock.test.ts is allowed for pure fallback validation; the skew acceptance must exercise rendered Logs. +- `docs-site/src/content/docs/guides/web-dashboard.md`, Korean counterpart and `structure/05_gui-and-management-api.md`: explain proxy-clock windows and stable reset focus, with older-proxy fallback stated accurately. Other translations must not contradict the source; no new untranslated UI strings are needed. + +## Activation matrix + +1. Apply a filter, focus Reset, activate it with keyboard: button can disappear but focus lands on selected All; subsequent navigation continues within the stable surface group. Also click Reset and verify focus. +2. Browser Date.now is six hours ahead, then six hours behind proxy generatedAt. Fifteen-minute window still selects only the same fresh proxy row; all-time remains unaffected. +3. Pause auto refresh, advance monotonic30s tick past a relative cutoff: old row expires with no fetch. Change browser wall-clock separately: rows do not jump. +4. Later successful server sample updates the anchor. A failed or aborted request cannot replace it; a different apiBase cannot inherit the previous server epoch. Legacy/malformed metadata uses documented fallback, never NaN windows. +5. Real management API returns generatedAt in the request time interval and preserves the envelope shape. Existing metrics/authorization tests remain green. + +## Delegation and verification + +Main owns server route/test, docs, Git/CI/FSM and native browser QA. Inherited Huygens owns only the GUI page/filter bar/optional clock helper and the corresponding GUI tests. Nash audits before B; independent implementation review follows. All application test/typecheck/lint/build commands run remotely; no local suite. Remote GUI checks include all GUI tests, lint/i18n/build, plus root typecheck/full suite and docs build. Final receipts state any queued GitHub jobs explicitly rather than marking them passed. + +## Check-phase React Doctor correction + +A cold pinned0.9.11 scan with the actual6005 base available reported two concrete diagnostics: rollover selection was adjusted in a post-render effect, and the rendered filter-bar test assigned an external observer during render. Move reconciliation into acceptance of the latest valid log response, preserving permanent reset when an identity disappears and current spelling when it remains. Keep component rendering pure by observing test state from an effect/event. No rule suppression is planned. Re-run the cold changed-scope scan before the regression suites. The earlier same-head hosted success is retained as an observed run result; it is not evidence that these diagnostics were absent. diff --git a/devlog/_fin/260906_d_integrations_delivery/050_remote_aliases.md b/devlog/_fin/260906_d_integrations_delivery/050_remote_aliases.md new file mode 100644 index 0000000000..b4e0452a28 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/050_remote_aliases.md @@ -0,0 +1,10 @@ +# 050 — Delivered remote Desktop integration + +The remote-hub Desktop alias and connection-lifecycle work associated with +[issue #3646](https://github.com/lidge-jun/opencodex/issues/3646) landed on dev in +[PR #3720](https://github.com/lidge-jun/opencodex/pull/3720). + +[070 — D delivery result](070_result.md) records the verified public behavior, merge and CI +references, contributor attribution, and original issue disposition. The separate thinking/replay +and prompt-cache request remains open in [#3719](https://github.com/lidge-jun/opencodex/issues/3719). +Detailed working notes are not reproduced in this public outcome record. diff --git a/devlog/_fin/260906_d_integrations_delivery/060_landing.md b/devlog/_fin/260906_d_integrations_delivery/060_landing.md new file mode 100644 index 0000000000..78adc6dcf8 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/060_landing.md @@ -0,0 +1,32 @@ +# 060 — D integration and final verification + +This operations/verification cycle consumes all five implemented candidates and any explicitly recorded repair work. Main owns it; no new feature scope is implied. + +## Loop specification and current handoff + +Archetype: integration/evidence closeout. Trigger: all five candidate cycles and their repair cycles have completed. Goal: published D changes and contributor history are on dev, originals are closed with honest remainder tracking, and the completed record is archived. Non-goals: new runtime features, release promotion, dogfooding or unrelated cleanup. Verifier: `.tmp/d-delivery/verify-final.py --ci-head ` and its later `--docs-pr ` form, plus remote privacy verification of the docs-only closeout. Stop: merged archive record, successful actual integrated runtime CI and all durable criteria met. Memory: this unit,070 result and ignored JSON/log receipts. A failed or pending gate remains open. Main owns operations; an independent reviewer audits the plan and final evidence. Main reclaims any failed sidecar after two distinct failed packets; new write scopes require a plan amendment. + +The preceding D accepted remotealias candidate022887702 with remote full19,751/15/0,292 focused tests, typecheck/docs425, privacy/final-patch Gitleaks and independent reviews. PR3720 still needs integration when this handoff is written. Replay/cache remains separately tracked in3719. Earlier D originals3669/3673/3628/3625 and Cursor follow-up3715 are already on dev; final verification rereads their actual state. + +The verifier deliberately refuses an OPEN3720 or a CI head preceding its merge. Its existence and refusal can be audited now; a passing final receipt is only possible after those prerequisites occur. It fetches dev, checks each merge's ancestry in both dev and the tested CI head, checks original/follow-up disposition and preserved authored commits, rejects unresolved carry-review threads, and requires actual Linux four-shard/macOS two-shard/gates/aggregate success. Dispatch-only skips are recorded separately. + +## Exact action map + +- Read live head/base/reviews/checks for every D carry; compare source-original current heads before closing them. Preserve contributor commits and Co-authored-by trailers. +- Integrate bottom-up with owner-authorized admin merge. Verify fetched origin/dev contains each merge SHA. Retarget every open child immediately; preserve its unique commits and branch identity. A squash requires cascading descendants before further readiness claims. +- Close superseded originals3669/3673/3628/3625 only after actual dev ancestry proof. Resolve3646's alias slice and explicitly preserve its separate thinking/cache request in an exact-scope existing or templated follow-up before closing it; never claim the alias patch fixes cache behavior. +- Reconcile all valid late findings and real CI failures. Any required source repair is planned with exact files/activation before patching; a new independent feature becomes a separate appended cycle, not hidden work here. +- Capture a final integrated dev SHA containing all five fixes and verify actual GitHub CI producers and aggregate on that SHA. Queued/cancelled/skipped application tests are not passes. Record platform dispatch-only limitations honestly. c-2 cannot be met until integrated CI succeeds. +- Recheck shared B locale/model-alias changes and A/C touched seams at final dev. If their source inputs changed the rendered Logs surface, repeat only affected browser scenarios; retain immutable sanitized screenshot URLs. +- Move this completed unit to devlog/_fin only after the published outcome is verified, recording final heads/run URLs/author/source dispositions. Detailed pending security notes remain in ignored scratch; publish only resolved outcomes. + +## Evidence and boundaries + +Use .tmp/d-delivery JSON/log receipts, current GitHub APIs and git merge-base --is-ancestor checks, never old labels or remembered output. Preserve the managed worktree and unrelated edits. No local suites/typecheck, no release/main/preview or dogfood operations. Every push uses --no-verify. Final goal completion requires all original criteria plus late-review and final-CI criteria; this page does not weaken any earlier bar. + + +## Archive implementation and checks + +After source integration and actual integrated CI succeed, merge the current dev into the closeout branch with hooks disabled. Write `070_result.md` using only verified public outcomes (PR/issue links, merge/run hashes, test scope and attribution), update050's neutral pointer to the published outcome, and move this unit to `devlog/_fin/260906_d_integrations_delivery/`. Keep private050/051 threat/design notes in ignored scratch. Preserve all unrelated A/B/C records and runtime files. + +Publish a template-compliant docs-only PR targeting dev. Verify its changed paths are solely this unit's devlog records, and validate the current archive checkout through remote privacy scan and source-tree equality. Admin-merge that record after the docs checks, fetch its ancestry and run the final verifier with its PR number. The P-to-A plan-artifact gate only requires the current plan directory at entry; archiving the verified unit later in B/C is the intended terminal operation, not a new feature. Do not hand-edit FSM/task completion flags. diff --git a/devlog/_fin/260906_d_integrations_delivery/070_result.md b/devlog/_fin/260906_d_integrations_delivery/070_result.md new file mode 100644 index 0000000000..f9add40b91 --- /dev/null +++ b/devlog/_fin/260906_d_integrations_delivery/070_result.md @@ -0,0 +1,60 @@ +# 070 — D delivery result + +Verified 2026-09-06 against integrated `dev@014061a7ea908118225314538b607afdac2015b1`. +All five assigned units landed through six PRs. The four source PRs and issue #3646 are closed; +the separate Anthropic thinking/replay/cache request remains open as +[#3719](https://github.com/lidge-jun/opencodex/issues/3719). +This records the verified public implementation outcome. + +## Delivered changes + +| Unit | Landing | Merge commit on dev | Outcome | +| --- | --- | --- | --- | +| #3669 | [#3684](https://github.com/lidge-jun/opencodex/pull/3684) | `22da7a4bc80040f66b819239c5028e578f9a1ede` | Refuse lossy TOML temporal-value rewrites before client configuration mutation. | +| #3673 | [#3702](https://github.com/lidge-jun/opencodex/pull/3702) | `eeca697b6fecddb507fdab6808ccbe7eb9de2f74` | Retain late tool-call index aliases while preserving budget ownership and cleanup. | +| #3628 | [#3707](https://github.com/lidge-jun/opencodex/pull/3707) | `6dd23d6314c41f1113639e042353aae9e6614e62` | Preserve Cursor executable tool schemas and reserved-name handling. | +| Cursor guidance follow-up | [#3715](https://github.com/lidge-jun/opencodex/pull/3715) | `67fdf24eb6e661f4d9e84aaa86a4eb39c6f3ba58` | Retain parser-owned freeform input descriptions, including apply_patch guidance. | +| #3625 | [#3712](https://github.com/lidge-jun/opencodex/pull/3712) | `cf6f30727e71c59a4c50f0be87d6fe7614564fc3` | Composable loaded-row Logs filters, reset/focus behavior, proxy-relative time, responsive controls and translated documentation. | +| #3646 | [#3720](https://github.com/lidge-jun/opencodex/pull/3720) | `014061a7ea908118225314538b607afdac2015b1` | Hub-issued Desktop IDs/origin, restart routing, owned restoration, key migration/recovery and explicit legacy standard fallback. Unresolved date-shaped IDs return mapping-unavailable 503; unknown legacy hashes return 400, without fallback. | + +## Verification + +| Evidence | Verified result / limit | +| --- | --- | +| [Integrated CI 34001966922](https://github.com/lidge-jun/opencodex/actions/runs/34001966922) | Exact integrated head `014061a7ea908118225314538b607afdac2015b1`: all four Linux shards, both macOS shards, common gates and aggregate succeeded. | +| Dispatch-only jobs | Six Windows shards and the macOS control job were **skipped**, not passed. This run does not establish full-suite Windows coverage. | +| Remote candidate `500aa73a760993d95f3e96f9ff9cfd240de2b7b4` | Bun 1.4.0 / Node 22.22.0: full suite **20,098 pass / 15 skip / 0 fail**; TypeScript checking and **425-page** docs build passed. | +| Same-candidate focused validation | **562 tests across 26 files**, exit 0; privacy scan passed; final-patch Gitleaks scan found no leaks. | +| Logs at `2221aed73cf8ef5b24452f22eda369c6539b2273` (#3712) | Browser evidence covers composition/reset, exact models, empty/offline states, keyboard navigation and widths 320–1440. Remote GUI suite: **1,499 pass / 0 fail**, with lint/i18n/build and docs validation. | +| Final Logs surface comparison | The recorded comparison with candidate `500aa73a7` preserves the browser-verified Logs surface and relevant inputs. Existing screenshots remain applicable; no new final-head browser run is claimed. | + +All application test/typecheck execution was remote or hosted. No local application tests or +typechecks were run. The final verifier checked every landing's ancestry in both dev and the +integrated CI head, original/follow-up disposition, carry-review resolution and retained authorship. +Evidence receipts: `.tmp/d-delivery/final-verification.json`, +`.tmp/d-delivery/final-014061a7e-verifier.log`, `remotealias-remote-proof.json`, +`remotealias-focused-proof.json` and `logs-final-surface-identity.json` in the same evidence directory. + +Synthetic screenshots published with #3712: +[English desktop](https://raw.githubusercontent.com/lidge-jun/opencodex/2221aed73cf8ef5b24452f22eda369c6539b2273/docs-site/public/screenshots/logs-filters-desktop-en.png), +[proxy-clock window](https://raw.githubusercontent.com/lidge-jun/opencodex/2221aed73cf8ef5b24452f22eda369c6539b2273/docs-site/public/screenshots/logs-filters-proxy-clock.png), +[Korean mobile](https://raw.githubusercontent.com/lidge-jun/opencodex/2221aed73cf8ef5b24452f22eda369c6539b2273/docs-site/public/screenshots/logs-filters-mobile-ko.png). + +## Attribution and remaining work + +The verifier confirms eight contributor-authored commits retained in dev: + +| Author | Retained commits | +| --- | --- | +| Hako | `08c7d3784d0cfa96c61467b8c7a581ea661378e3`, `fef024a69cfbe735d3ce0a6d33e65e911461bd2d` | +| SB Yoon | `4f0c278420998778e1341f7c7ed88e818c7ae048`, `3a7e4996435e68fd8caf8374dc75b3c759133582`, `f13cf27a22d975db2927e71960cec6e5fea02288`, `0073dd331b075578d1616d39a915bf87f00befaa`, `846197c91efeb3e411c3650e884260fdf6e45a7b`, `e7c3495b73bf73ab199bf283d0d8a079eed929e1` | + +The earlier PR CI run for candidate `500aa73a7` timed out in macOS shard 1's +`shellStreamExec completion acknowledgement` test. Its native root cause remains unproven; +the successful integrated run is new evidence, not proof that the timeout cause was fixed. +macOS CI runner-policy alignment remains a separate maintenance task; this record does not +claim a native-shell root-cause repair. + +Closing #3646 records delivery of its remote-alias and connection-lifecycle slice only. +Thinking/redacted-thinking replay and prompt-cache behavior remain separate in #3719; +no cache-fidelity, cache-hit or quota-saving fix is claimed here. diff --git a/devlog/_fin/260906_manual_account_selection/000_plan.md b/devlog/_fin/260906_manual_account_selection/000_plan.md new file mode 100644 index 0000000000..3ee8ea01cd --- /dev/null +++ b/devlog/_fin/260906_manual_account_selection/000_plan.md @@ -0,0 +1,33 @@ +# Manual account selection must control dispatch + +Loop: single-cycle satisfy-spec, C4 (credential/account allocation). Trigger: the user selected a healthy OAuth account in the dashboard, but every request was silently assigned to another account. Goal: disabled pools do not proactively reassign healthy requests; explicit selection wins; permitted automatic assignment is reflected in active-account state and dashboard. One cohesive PR targeting dev, pushed with `--no-verify` and merged as explicitly authorized. No stacks. + +Boundaries: existing account/key owners, request credential pairing, dashboard account synchronization, regression coverage, matching public docs. No provider API changes, real-account configuration changes, paid inference probes, releases, service restarts, or unrelated refactors. Use existing dependencies and temp credentials. No token, cost, or wall-clock budget was set; no paid oracle is required. Completion requires fresh direct verification and actual remote merge; a pending PR is not DONE. Escalate only a genuine tool/access block or a necessary authority not already granted. Main reclaims a lane after two distinct worker failures; new worker scope requires a P amendment. + +Memory: this numbered unit, `.tmp/manual-account-selection/` evidence, session-bound goalplan. The implementation and test map is in 010. One PABCD work-phase covers this single contract across its existing owners; frontend/runtime lanes are subtasks rather than separate deliverables. + +## Evidence and rival hypotheses + +GUI `useProviderAccountPools.ts:247` sends selected account to PUT `/api/oauth/accounts/active`. `oauth-account-routes.ts:322` calls `setActiveAccount`, which saves the selected id. `generic-account-failover.ts:185-196` defaults healthy proactive selection on when two accounts exist. `preferredInitialAccount` ranks by quota and can replace the selected healthy account. `responses/core.ts` resolves that other credential and logs it without updating stored selection. Provider quota reads stored active selection separately. Exact user-provided request IDs matched another account on attempt1, sendCount1, no retry. This is not an upstream429 recovery or a failed GUI save. + +H1 failed persistence is disproved by stored selected id and successful route semantics. H2 only a2s roster cache is insufficient: quota ranking would choose the other account after the cache expires. H3 manual authority absent from the selector is supported by the complete route-to-store-to-request chain. We will prove H2/H3 with isolated real owner functions before implementation. + +## Contract + +- Presence of multiple stored accounts does not enable proactive healthy-request allocation. Absent generic OAuth proactive enablement is off; explicit false wins for proactive allocation. REACTIVE429 recovery remains mandatory whenever another usable account exists, regardless of the pool switch, per the latest explicit user correction. +- With a pool enabled, the healthy active/manual account stays eligible and preferred. A quota percentage alone below exhaustion must not replace it. On a real account-scoped refusal or known exhaustion, enabled rotation may choose another usable account. +- Committing an automatic account selection must be conditional on the selection generation that produced the request. A newer manual selection (including A→B→A) wins. +- The selected account and its access token/project/origin metadata travel together; unsupported/unknown quota is not exhaustion. +- Codex, Anthropic, and API keys keep their own established contracts, but any contradiction with these user requirements is repaired in the same PR. Their specific findings must be folded into010 before their edits. + +Enforcement: runtime selectors plus guarded persisted active-account transition; execution surface covers first dispatch and every reactive replay. Known bypass: external callers can intentionally route exact account-targeting selectors, which remain their own explicit contract. Residual: concurrent requests can already be in flight on different credentials; dashboard reports the latest committed allocation, never retroactively cancels an already sent request. Wording: no claim that a UI highlight can reassign a request already upstream. + +Verification: focused Bun store/management/selection/retry tests first; full `bun run typecheck` and `bun run test` before PR review-ready; relevant GUI tests/lint/build and a rendered state transition if frontend changes. Regression tests use synthetic identities only. Public SoT: `structure/05_gui-and-management-api.md` and existing account/pool guide pages. Final record includes rejected hypotheses, unmodified owners with proof, security/concurrency audit, and remote merge. + +Latest steering: the user reports Codex works correctly. Preserve Codex routing/controller semantics and use its existing manual-selection behavior as the reference; include regression-only checks for Codex. Concentrate changes on the other quota-aware account paths where a concrete mismatch is established. + +Latest explicit design instruction: GUI selection and pool selection must share one selection owner, as Codex does. Both must commit the same authoritative selection BEFORE dispatch; requests use the committed account. Do not bolt on a separate UI-only mirror or route around manual selection while pretending the old active account remains selected. A concurrent newer user selection wins. This strengthens the existing planned store-owned selection transaction and applies with pool on or off. + +Latest correction: 429 automatic account switching is ALWAYS allowed, including poolOFF. Withdraw the planned reactive disable gate. Manual selection wins ordinary dispatch; a real429 may replace it using the same guarded selection owner, and the GUI follows that committed replacement. + +Latest explicit verification restriction: do not run repository-wide tests. The earlier full-suite requirement is superseded. One attempted full run was interrupted by user at exit130; it is not completion proof. Resolve observed failures with their specific test files, run focused affected checks and typecheck, then push --no-verify and merge the single PR. diff --git a/devlog/_fin/260906_manual_account_selection/010_implementation.md b/devlog/_fin/260906_manual_account_selection/010_implementation.md new file mode 100644 index 0000000000..85bf466a3c --- /dev/null +++ b/devlog/_fin/260906_manual_account_selection/010_implementation.md @@ -0,0 +1,71 @@ +# Implementation — one account-selection contract + +Depends on000. One PABCD work-phase, one PR. Existing subsystem owners stay intact. + +## Shared OAuth store and generic allocation (main) + +MODIFY `src/oauth/types.ts`, `src/oauth/store.ts`: add optional non-secret `ProviderAccountSet.selectionRevision` and a typed selection snapshot `{ accountId, revision? }`. Legacy files without the field remain valid. Normalize/persist/copy it through the existing auth-store boundary; every active-selection change (including re-selecting the same account) advances it. Add a store-owned capture function and conditional active-selection commit that compares both original active id and revision inside `mutateStore` before writing. Credential-only refresh must preserve the selection revision. Consumers: generic and Anthropic request admission/promotion. Management DTO need not expose the revision: existing active id remains the public selection. No credential value is logged. + +MODIFY `src/oauth/generic-account-failover.ts`: proactive allocation requires effective pool `enabled === true` (provider override then global). Merely storing2 accounts does not enable healthy-request steering. Preserve presence-based REACTIVE429 switching even when disabled, as the user expressly requires. Keep a healthy manually active account first; use quota ranking only when the chosen account is ineligible/exhausted or when an enabled pool recovers a real refusal. Unknown quota never implies exhaustion. Preserve existing cooldowns and bounded attempts. Clear roster on manual selection so an old2s cache cannot dispatch the previously selected account. + +MODIFY `src/server/management/oauth-account-routes.ts`: manual selection retains successful persistence and cache invalidation, and invalidates relevant generic selection state. Existing Anthropic manual handler remains its owner. Update outdated pool-settings contract comments/DTO docs in `src/oauth/pool-settings-capability.ts`, `src/types/provider.ts`, `src/types/config.ts` to match effective enablement; do not introduce a new UI toggle simply to repair a default. + +MODIFY `src/server/responses/core.ts`: capture the OAuth selection snapshot before awaited token materialization; preserve token/project/origin pairing. For actual automatic initial/retry selection, await the guarded active-account commit before publishing that allocation. A rejected promotion caused by a newer user selection must not overwrite it; continue via current valid selected account or return the original request failure as appropriate. Apply consistently to direct upstream errors, runTurn on429 callback, passthrough/combo retries and downstream stream recovery call sites. The same shared core serves Responses/Chat/Messages surfaces. + +## Anthropic and API keys (backend lane) + +MODIFY `src/oauth/anthropic-routing.ts` and its existing tests: preserve reactive429 rotation even when the pool is off (latest user correction). Manual selection must seed a preference that wins the next eligible dispatch, including quota strategy, clearing stale affinities. Guard automatic active-account promotion with the same store selection snapshot; main owns core call-site integration. Maintain account-scoped refresh restrictions. + +MODIFY `src/providers/key-failover.ts` and relevant caller/rotation types only if the isolated repro confirms env/keychain reference identity mismatch: match failed attempts by stable pool-entry identity/reference, never by comparing a resolved secret with a stored reference. Preserve newer manual key selection and committed config-to-dashboard mapping. API-key pools have no separate enable boolean: an explicitly configured multi-key pool remains their existing enable contract. No speculative new mode is added. Codex is unchanged and regression-only, per latest user steering. + +## Dashboard (frontend lane) + +MODIFY existing `gui/src/hooks/useProviderAccountPools.ts` / `gui/src/pages/Providers.tsx` runtime-read integration and existing tests as needed: periodically reconcile cheap local OAuth/key roster active state through the shared scheduler, without forcing upstream quota probes on every tick. Reuse quota rows and reject stale read results around manual mutation. Do not alter Codex controller behavior. Keep layout and existing localized labels. Render a synthetic selected-account transition and retain a screenshot in the unit for PR evidence. + +## Proof / reachable cases + +- Pool absent/false with2 accounts: manual A30%, B11%; ordinary dispatch staysA. Simulated429 MUST auto-switch to another usable account even with pool off, and persist that selection. Pool enabled: manual A remains preferred; known exhaustedA or actual refusal chooses usableB and active DTO becomesB. +- A delayed token refresh/429 starts onA; manual choiceB or A→B→A occurs before completion; older selection revision cannot change active state. Removal/reauth during candidate resolution cannot promote an invalid target. +- Generic matrix runs xAI, Cursor, Kimi, Copilot, Antigravity, Nous and representative passive-quota provider using synthetic snapshots. Copilot regional origin and Antigravity project remain paired to the selected bearer. +- Anthropic off/on, quota/RR manual priority, stale affinity, failed token resolution, and promotion races; Codex direct/manual/pin existing regressions stay green. +- Literal/env/keychain-supported API-key identities: rejected attempted key rotates to another distinct key, newer manual key wins, chosen key is the persisted active key. +- Dashboard backendA→B read updates highlighted selection and current quota association; an older quota/roster poll cannot revert a newer manual choice; a roster-only poll never initiates a paid/upstream quota read. + +Reuse existing tests: `tests/oauth/generic-oauth-failover.test.ts`, `tests/oauth/oauth-store-multi.test.ts`, `tests/oauth/adapter-event-oauth-failover.test.ts`, `tests/server/account-pool-management-api.test.ts`, provider quota/Anthropic/key failover suites discovered by owner search, and existing `gui/tests/provider-account-quota-loading.test.tsx` / provider revalidation tests. Prefer these files to new layout entries. Verify focused red before repair, then green; run `bun run typecheck`, `bun run test`, `bun run privacy:scan`, and relevant GUI tests/lint/build. New failures outside scope are diagnosed and recorded, not ignored. Fresh independent security/concurrency review before delivery. + +SoT sync: `structure/05_gui-and-management-api.md`, existing English configuration/account pool guide plus translated statements that would otherwise contradict changed enablement. Record provider coverage and limitations in011 evidence. Push single branch with `git push --no-verify`; create one PR using repository template againstdev, attach rendered UI evidence if GUI changed, verify remote head/CI, then merge as authorized and verify integration SHA. + +Known design risk for A audit: making selection persistence part of dispatch must not serialize all independent successful requests; commit only actual account changes, and use the existing guarded store writer. Manual selection while an upstream request is already running applies to subsequent allocation; no retroactive cancellation claim. + +## P clarification from frontend owner + +Current scheduler is `useKeyedClientResource`/`client-resource.ts`, not `useRuntimeRead`. Exact frontend writes: `gui/src/hooks/useProviderAccountPools.ts`, `gui/src/pages/Providers.tsx`, and `gui/src/pages/use-providers-oauth.ts`; tests: existing `provider-account-quota-loading.test.tsx` and `provider-revalidation-policy.test.tsx`. Register one local-roster refresh through App's existing30s shared scheduler, key by server+sorted provider list, not active ids. Preserve initial quota enrichment; never add quota=1 to periodic reads. Invalidate per-provider read generation at manual PUT start; apply successful response active id; late login-status hydration may seed only a missing roster. Codex controller stays unchanged. Existing relevant GUI baseline52 tests passed in separate file processes; grouped globals can collide, so run each file separately. + +## Shared-selection requirement (latest steering) + +GUI PUT and pool choice both use the store-owned active-selection transaction. Make the common operation return/confirm the committed selection, and dispatch from its matching credential snapshot; a pool proposal is not authoritative until it commits. Do not mutate per-request bearer first and asynchronously update GUI afterward. The generation guard is a concurrency condition inside that same shared operation, not a competing selection state. Keep the public active-id DTO unchanged. Main and backend lane must align on this seam before writing callers. + +Authoritative429 exception: poolOFF suppresses only proactive steering. Every generic/Anthropic/key recovery test must preserve automatic429 failover. Any earlier statement blocking429 whileOFF is superseded. + +## Immediate synchronization amendment + +Latest user rejects waiting for a poll after automatic selection. The repository has no dashboard EventSource subscription to reuse. Add a narrow authenticated management SSE invalidation channel for committed account/key selection (`/api/accounts/events`) with bounded subscriber count, lightweight heartbeat, disconnect cleanup, and no credentials/account identifiers in events (provider plus kind/revision only). A dependency-leaf `src/lib/account-selection-events.ts` owns subscription/publication; it must not import server or Lab. Shared authoritative OAuth/key selection writers publish only after successful persistence. `src/server/management/oauth-account-routes.ts` serves the channel behind existing management auth; close it through existing optional shutdown hooks if necessary. Frontend lane adds a single lifecycle-owned EventSource for this screen, invalidates cheap roster via current generation guards, reconnects with a full local refresh, and keeps30s scheduler as recovery only. Existing test files cover event arrival→highlight change without advancing poll clock, blocked/failed writes emitting no selection event, and subscription cleanup. New endpoint is authenticated and carries no authority to select; data-plane keys cannot subscribe. This replaces the earlier30s-only plan. + +## A synthesis — accepted bounded corrections + +Independent reviewer verdict: GO-WITH-FIXES(blockers=5). All five are folded into the implementation, none rebutted: +1. Revision lifecycle covers manual reselect, new activation, removal promotion, replacement/recreation; rollback replacement receives a new revision, never resurrects an old one. Credential-only writes preserve it. +2. Common store operation `commitOAuthAccountSelection(provider, accountId, {expectedSelection?, expectedCredentialGeneration?, requireUsableAccount?})` returns committed `{accountId,revision?}` ornull. GUI's existing `setActiveAccount` boolean API wraps this same operation. `captureOAuthAccountSelection` supplies the expected snapshot. Validate unchanged-account admission too; retry current selection after a failed CAS, never send the rejected candidate. Cover generic core sites4868/5753/6100/6834/7244 and initial selection. Failed CAS emits nothing. GUI invalidates reads at both PUT start and settle and preserves settled quota state. +3. Anthropic affinity/rotation success bookkeeping occurs only after selection commits. All four promotion callers await it; background local-CLI token restrictions remain checked before commit. +4. API-key attempt carries stable pool identity/reference plus selection generation across all callers including nativeChat; common manual/automatic selection commit guards ABA and notifies only after persistence. +5. Quota eligibility explicitly distinguishes known exhaustion from unknown, including Kiro overage rules. Parameterized provider coverage includes Kiro and passive providers. + +B lane allocation (approved plan): main owns core.ts, OAuth management route/SSE route registration, generic selector/rank and integration proof/docs; store lane owns oauth/types.ts+store.ts, leaf account-selection event bus, and oauth-store-multi.test.ts; backend lane owns Anthropic routing+tests and API-key source/router/transport+tests including types/provider.ts; frontend lane owns the3GUI sourcefiles and2testfiles above. No worker changes maincore or another lane's files. Independent context review follows integration. + +Latest explicit verification restriction: do not run repository-wide tests. The earlier full-suite requirement is superseded. One attempted full run was interrupted by user at exit130; it is not completion proof. Resolve observed failures with their specific test files, run focused affected checks and typecheck, then push --no-verify and merge the single PR. + +## C corrective review amendment + +Accepted independent review findings: dispatch must revalidate after pacing/build waits;401 replay must use the common selection owner; CCA project must always come from the admitted account; Anthropic initial manual choice must survive restart; selection SSE must stop on session revocation/expiry; hub relay must not apply its15s total deadline to an established selection stream; late initial quota data must survive manual selection without restoring old active flags. Main owns physical dispatch,401,CCA; backend lane owns Anthropic/API-key corrections; frontend lane owns reconnect/quota fixes. For bounded parallel C repair, the completed store worker is reassigned to SSE/management session liveness and hub-relay fixes only; no concurrent write ownership overlaps. All verification remains focused; full suite is prohibited. Draft PR3768 is open and CI runs asynchronously. + +C second-review correction: a rebuilt adapter must replace the active adapter/cache, and physical admission must be bound to the particular wire request's originating credential, not merely shared request state. Image/search model loops need the same request-specific executor. The runtime reviewer is reassigned as an exclusive repair worker for core/fetch-helpers and those two loops plus focused regression tests; main pauses edits there and independently verifies the returned delta. API-key helper/native Chat remains the backend lane; runtime worker integrates its exported helpers. Codex forward path remains unchanged. No full local tests. diff --git a/devlog/_fin/260906_manual_account_selection/011_verification.md b/devlog/_fin/260906_manual_account_selection/011_verification.md new file mode 100644 index 0000000000..ac944beeb8 --- /dev/null +++ b/devlog/_fin/260906_manual_account_selection/011_verification.md @@ -0,0 +1,50 @@ +# Verification and delivery record + +The fix uses a common committed selection for manual and automatic OAuth/API-key allocation. +A healthy manual selection has priority; reactive429 recovery remains enabled with poolOFF. +The physical request carries the binding of the adapter that built it. A stale binding is rebuilt, +and the new adapter and request cache remain authoritative for later retries and continuations. +Image/search loops share the request-specific executor. Codex routing/controller semantics are unchanged. + +## Focused evidence + +| Surface | Evidence | +| --- | --- | +| Generic OAuth | Parameterized xAI, Cursor, Kimi, Copilot, Antigravity, Nous, Kiro, Meta-Muse manual priority;36 focused checks passed | +| Store |13 failing regression cases before repair;36 store checks passed, including ABA/removal/recreation and refresh-only preservation | +| Actual dispatch | Copilot build/pacing races,413 follow-up, image/search pacing, and runTurn first-send coverage;31 checks passed with3 final boundary cases demonstrated RED→GREEN | +| API keys | Literal/env/keychain identity and newer manual selection; native Chat pacing revalidation; focused12+59 checks passed | +| Anthropic | Manual selection, guarded promotion, restart bootstrap, and always-on429; focused96-test group passed | +| Antigravity |20 OAuth401/project tests passed; a project-less account is refused before dispatch; every admitted request uses its account's project | +| Image/search | Image loops31, search61, timeout contract7 passed in separate processes | +| Management/relay |40 focused checks passed; authenticated invalidation stream, client cancellation, byte/subscriber bounds, expiration/revocation, and established SSE lifetime | +| Dashboard | Probe/passive quota hydration, stale selection guards and immediate event/reconnect behavior;38 roster+8 page checks passed | +| CI fixes | Upsert fixtures now persist like the real login flow and verify disk; GUI source binding check updated; React Doctor0.9.11 changed-file scan has0 errors/0 warnings | +| Static/privacy | Typecheck, privacy scan and diff check passed at integration checkpoints | + +Counts identify each recorded check group; they overlap and must not be added into a unique-test total. +The user prohibited repository-wide local tests. An earlier full run was interrupted with exit130; +it is not completion evidence and was not repeated. CI runs asynchronously on PR3768. +At dd5aec571, all23 applicable CI checks passed, with2 intentional skips. + +## Current browser proof + +Aside opened a local synthetic fixture rendering the real Providers component and styles at1440×900. +The current GUI moved ChoiceA→ChoiceB from a selection event without advancing the poll clock; +upstream quota-read count stayed2→2. Both screenshots were inspected by main; no Korean clipping or +incorrect active indicator was observed. The fixture and owned browser tabs were stopped afterward. +The screenshots contain only synthetic account names and masked IDs. + +- [Before](evidence/011_selection-before.png) +- [After](evidence/012_selection-after.png) + +Independent C reviews identified and drove repairs for cached adapter reuse, sidecar dispatch, +credential refresh priority, account/project pairing, restart priority, stream lifetime and quota +hydration. All identified findings were implemented and the repaired cases were exercised. + +## Delivery scope + +One PR: https://github.com/lidge-jun/opencodex/pull/3768 . Every push uses`git push --no-verify` as +explicitly requested. The maintainer explicitly authorized an administrator merge. Remote merge +state and its final SHA are verified separately from local implementation proof; no runtime service +restart or real-account configuration mutation is part of this change. diff --git a/devlog/_fin/260906_manual_account_selection/evidence/011_selection-before.png b/devlog/_fin/260906_manual_account_selection/evidence/011_selection-before.png new file mode 100644 index 0000000000..e48fdf946f Binary files /dev/null and b/devlog/_fin/260906_manual_account_selection/evidence/011_selection-before.png differ diff --git a/devlog/_fin/260906_manual_account_selection/evidence/012_selection-after.png b/devlog/_fin/260906_manual_account_selection/evidence/012_selection-after.png new file mode 100644 index 0000000000..1806056ab7 Binary files /dev/null and b/devlog/_fin/260906_manual_account_selection/evidence/012_selection-after.png differ diff --git a/devlog/_plan/260906_aside_profiles/000_research.md b/devlog/_plan/260906_aside_profiles/000_research.md new file mode 100644 index 0000000000..b1e32b995f --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/000_research.md @@ -0,0 +1,21 @@ +# Aside profile synchronization roadmap + +User scope extension: synchronize all Aside profiles and expose independent profile switches in GUI and CLI. Continue the original Grok catalog/Responses stabilization stack; no local test suites or local typecheck, no release/service deployment. Existing push --no-verify and admin-merge authorization applies to these scoped layers. + +Observed installed contract: accounts.json has currentAccountId plus accounts[] with numeric id and name; profileAccountBindings maps browser profiles to accountId. This machine has three account-backed profiles (one cloud, two local), with a models.json only in the current account. Every model catalog lives under the configured Aside root/u//models.json. Browser profilePath is metadata, never a write destination. Multiple bindings sharing one account share one model catalog and therefore one control row. Keep only id/name/current metadata; never serialize sessions, tokens, user IDs, email or subscription metadata from the manifest. + +Current owners: config-export.ts asideCurrentAccountId resolves only currentAccountId. registry.ts aside.resolvePaths freezes that current path pair. writer.ts synchronous input supports resolvedPaths but its async freeze recomputes current paths; state.ts has no frozen-pair input. The ownership store is keyed by clientId, so a single root can retain only one Aside account. FileIntegrationPage already owns safe toggle/overwrite/history/restore, and all its resource keys currently use only client ID. CLI is a thin management caller with no profile flag. + +Decision: enumerate account-backed profiles; derive paths strictly from numeric IDs under Aside root, not profilePath. Partition each new profile's ownership and journal into /aside-profiles/; retain exactly one stable writable legacy root owner; all sibling writes use independent child stores. Older mixed legacy history remains readable by exact profile path and can be imported into the correct child store only for explicit restore. Freeze the chosen profile's paths for status/write/restore. Do not move user files, change currentAccountId, or copy credentials. + +Desired state: add asideProfileSync:{allProfiles?:boolean,profiles?:Record,legacyProfileId?:number|null} to OcxConfig. Absent defaults to whether a legacy Aside ownership record establishes prior connection. That legacy connection enables all discovered profiles by default, satisfying the user's all-profile request. A per-profile override persists independently. Before modifying a per-profile override, materialize the prior global default so disabling the legacy profile does not flip siblings. Explicit actions persist desired policy before any file writes; a save failure aborts with no file mutation. Bulk intent sets allProfiles and clears overrides, while actual per-profile applied states and refusals remain separate. A failed file mutation leaves visible pending intent, never an all-applied claim. Restore reconciles only its target profile policy with validated prior ownership so Undo cannot be silently reversed by the next sync. Per-profile-only enable when previously disconnected leaves other profiles off. Implicit sync refreshes owned enabled profiles and may safely apply an absent block in an explicitly/legacy-enabled unowned profile; it never overwrites foreign blocks or recreates a manually removed previously-owned block. + +Cycle map: docs-only roadmap; 010 backend/profile ownership/API/CLI (foundation and API can be separate dependent PRs within this single implementation unit); 020 GUI controls/QA and final full-stack landing. Every original exact-head CI and merge-ancestry criterion remains open until terminal delivery. + +Design Read: a repeated-use integration settings page using the existing monochrome dashboard: --bg white/#212121, --surface white/#262626, --accent #0d0d0d/#ececec, existing --font-ui and ClientMark. Compact profile rows show name/current marker, state, and switch; a global switch and enabled/total count summarize all profiles. Details reuse the existing FileIntegrationPage scoped to a selected profile so history/restore stays available. No new visual framework, assets or motion. DESIGN_VARIANCE2, MOTION_INTENSITY1, densityD5. Loading/error/empty/partial/busy states are explicit; the current browser account never changes when an integration switch changes. + +Resource bounds inherited: six-hour window from original goal, no requested token budget, original at-most24 live synthetic provider requests. Profile probes use temporary roots with three profiles; bulk production discovery is bounded to128 account entries. Existing local/GitHub credentials only for authorized repo work. Actual user profile files remain read-only during development. Runtime file writes are tested only in isolated fixtures. C4 ownership/path review is required before production merge; security working notes stay ignored scratch. + +## Baseline + +`bun .tmp/aside-profiles/baseline.ts` runs only synthetic temp files: manifest has0/1/2, legacy owned0, current model-selection route runs refresh, and configuredAsideProfiles remains1. This reproduces the user report without editing any real profile. Browser profile bindings resolve to three distinct account IDs in the current install. diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md new file mode 100644 index 0000000000..570ec2a3ea --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md @@ -0,0 +1,47 @@ +# 010 Profile data, ownership, API and CLI + +Class C4 for controlled multi-file writes; spec-satisfaction repair. Goal: all account-backed Aside profiles receive the selected catalog and can be independently enabled/disabled. Non-goals: login/account switching, browser profile data, credential changes, unowned overwrite without the existing explicit flag, other clients redesign. + +NEW src/clients/aside-profiles.ts: typed AsideProfile {id:number,name?:string,current:boolean,configPath:string,detectDir:string}; read configured asideHomeDir accounts.json, validate bounded account array, dedupe safe nonnegative integer IDs, fall back to current-only legacy manifest when accounts is absent, fail on malformed identities. Map only safe metadata; derive root/u/id paths. A numeric query selector must refer to this enumeration. No path from browser profile bindings reaches writes. +MODIFY src/types/config.ts + src/config.ts: asideProfileSync optional object with allProfiles boolean, numeric-key boolean overrides, and optional nullable safe-integer legacyProfileId provenance. Per-field validity must not erase unrelated configuration; preserve unknown future policy fields where existing conventions require. Full field chain: creation in Aside mutation service; persistence via saveConfigPreservingClaudeCode; deserialization in config schema; consumers profile status, explicit toggles and implicit sync; serialization GUI/CLI receives effective enabled per row, not raw credentials. +MODIFY src/integrations/state.ts: optional resolvedPaths in IntegrationStateInput, use it instead of resolving current profile. MODIFY writer.ts freezeIntegrationInput to clone a supplied internal resolved pair, preserving existing resolution otherwise. This is an internal seam only; routes never accept caller-provided paths. +NEW src/integrations/aside-profiles.ts: resolve profile-specific store/path input. Exactly one profile may use the writable legacy root: a matching current legacy ownership record wins; only if no record exists may the newest legacy Aside operation choose it. An unrecognized existing record makes the root unassigned. Persist the resolved legacyProfileId (number or null) before the first explicit mutation, so disabling/reloading cannot reassign it. All other profiles use isolated child stores. Read statuses with same classifier. Model load memoized across profiles. Compute effective default and per-profile override. Explicit enable/disable/overwrite uses existing coordinated writer and mutation-flight exclusivity, serializes profiles, returns per-profile results; persist desired preferences through the caller save seam before file mutation, under the same exclusive operation. On save failure restore the in-memory prior policy and abort before filesystem changes. Report desired enabled separately from actual state and per-profile refusals; do not fabricate all-applied success. Missing/foreign/unsafe/drifted profile remains untouched with explicit refusal. A manual deletion with a surviving ownership record stays absent on implicit refresh. First safe creation in enabled unowned profile uses apply without overwrite. Never switch the active account. +MODIFY src/integrations/owned-refresh.ts optional internal resolvedPaths; MODIFY catalog-refresh.ts Aside fan-out to profile service and preserve per-profile outcome IDs; update CLI explicit sync logs/type projection to identify profiles. + +NEW src/server/management/aside-profile-routes.ts: GET /api/client-integrations/aside/profiles returns {profiles:[{profileId,name?,current,enabled,...IntegrationStatus}],allEnabled,enabledCount,total}; GET /aside without profile returns aggregate IntegrationStatus+profiles, PUT /aside without profile acts on all discovered profiles. Existing /aside?profile= handles one explicit profile with same mutation/refusal semantics. Numeric profile parsing is strict, membership checked, non-Aside use rejected. Reuse existing jsonResponse/body parsing/CSRF outer boundary. Return partial failures visibly; do not turn mixed outcomes into a successful all-applied status. +MODIFY integration-routes.ts: route Aside list/status/toggle to profile service; collection projects Aside aggregate while other clients remain unchanged. Bind optional profile scope for journal/delete/restore query paths to the same selected store and frozen paths; no snapshot can restore into another profile. Existing no-profile legacy history remains accessible. Existing test hooks (root store/env/home/io/lock seams) must propagate. New prefs writes use deps.saveConfigPreservingClaudeCode, never bypass fixture isolation. +MODIFY src/cli/integrations.ts: --profile for Aside status/show/list, enable/disable, history/journal and restore/delete equivalents that exist; reject on other clients and malformed IDs. No --profile on Aside enable/disable means all. Route flag through query profile; status prints all per-profile rows, JSON preserves metadata; mixed failure exits nonzero with structured result retained. Update usage/capability source if help registry owns it, and operating docs. + +Tests: new profile enumeration/store/writer domain tests registered in both layout manifests; management and CLI tests cover current0+local1+local2, all-enable, individual-off persists through sync, legacy-default all, explicit one-only enable, active-account changes do not retarget a pinned write, unowned/drifted/removed/symlink/missing profile refusals, malformed selectors, unknown ID, partial outcome, per-profile journal/restore isolation and old legacy history. Actual temporary fixtures and original writer/management calls; no live user config mutation. + +Verification: standalone temp-root production probe establishes three distinct file outputs and one-off persistence across refresh; remote Bun focused regressions/typecheck/privacy gates; independent ownership/API review. Final exact-head hosted CI and all PR ancestry are terminal obligations, not satisfied by queueing. Candidate new paths source-checked before B. Escalation only for a concrete unresolvable external constraint, not routine design choices. + +## Audit-locked operational contracts + +- One outer Aside mutation flight owns the complete action, including policy persistence and every coordinated writer call. Its key includes root fingerprint, sorted selected profile IDs, operation/overwrite/restore semantics and a unique operation nonce. Overlap returns busy; no profile ever joins another result. Do not nest refreshOwnedIntegration inside that flight; call coordinated refresh/apply directly after the service's ownership checks. Different profile roots cannot coalesce either. +- Profile status and every writer use the concrete filesystem validation/guard contract recorded in ignored .tmp/aside-profiles/security-scope.md. Frozen path pairs alone are not the boundary. The guard is rechecked immediately before file mutation and is shared with status. +- Restore resolves the operation's exact profile independently of currentAccountId. Before policy persistence validate operation/snapshot availability, target identity and ordinary drift preflight. Desired state after Undo is true only when priorRecord describes the exact snapshot bytes as owned; absent/foreign/conflicted snapshots set a target false override. Global defaults and sibling overrides remain unchanged. Persist that target intent first; writer refuses or restores under the same flight. Cover enable->undo->sync and disable->undo->sync after reload. A later filesystem refusal remains visible as desired/actual mismatch, not success. +- NEW src/integrations/aside-profile-journal.ts (if separation needed): path-filtered profile history combines its writable store and matching legacy operations, deduping operation IDs. Snapshot reads use each operation's source store. A restore of an older sibling legacy operation imports only that immutable operation and its available snapshot into the target child store (same opId, exact priorRecord/configPath, no original deletion), then uses the existing coordinated restore there; it never changes the legacy owner's record. Expired snapshots stay expired. Profile history deletion checks the newest operation within that profile and retires duplicate imported/source copies together so a deleted row cannot reappear. Generic history/restore paths resolve Aside operation scope by exact configPath when no profile query is supplied, and reject an operation whose profile is no longer registered instead of retargeting it. +- Add profileId to journal/API rows, and treat (clientId,profileId/configPath) as history ownership for latest/undo/delete checks. Existing non-Aside behavior stays unchanged. + +C4 audit findings and concrete filesystem guard details are kept in ignored scratch; the public roadmap records feature contracts only. + +## P implementation interfaces at37b3a7f9b + +Delegation is within this one010 cycle with disjoint write sets. Path worker owns clients/aside-profiles.ts and tests/clients/aside-profile-paths.test.ts. Engine worker owns integrations/aside-profile-context.ts, aside-profiles.ts, aside-profile-journal.ts and tests/clients/aside-profiles.test.ts. Main owns type/config schemas, resolved-path seams in state/writer, management routes, CLI, implicit fan-out wiring and route/CLI tests. No worker commits, orchestration, local suites or real profile mutation. + +Path module exports AsideProfile {id,name?,current,root,configPath,detectDir}; listAsideProfiles(env?,home?) and guardAsideProfileIO(profile,io,profiles?) plus assertAsideProfileBoundary(profile,profiles?,mutation?). Invalid manifest/selector/path raises ClientPathError with safe text. Engine module exports AsideProfilesInput (config, models array/lazy, port, env/home/store/io, persistConfig?, lockSeams?), AsideProfileState (IntegrationStatus plus profileId/name/current/enabled and optional safe error), AsideProfileList (clientId,profiles,allEnabled,enabledCount,appliedCount,total plus aggregate state fields), listAsideProfileStates, getAsideProfileState(input,id), mutateAsideProfiles(input,{enabled,profileId?,overwriteConflict?}), refreshAsideProfiles. Mutations return {ok,clientId,changed,state,message,results:[WriteOutcome+profileId]}; singleton result stays accessible for the existing refusal serializer. + +Journal module exports listAsideOperations(input,profileId?) -> [{profileId,entry,store}], findAsideOperation(input,opId,profileId?) -> row|null, restoreAsideProfile(input,{opId,profileId?,confirmDrift?}) -> WriteOutcome+profileId and deleteAsideOperation(input,{opId,profileId?,principal?}). Main serializes journal metadata using each source store; profile-scoped newest protection and duplicate retirement live in the journal service. Journal discovery can return null for unrecognized non-Aside operations so the existing route handles them. + +The context owner centralizes exact scope/store resolution, desired policy, guarded IO and outer flight; engine/journal import it without circular imports. Scope includes a safe ownership-store root as well as the client file target. No writable legacy root may be shared across profiles. Domain errors carry safe code/status for route mapping; no manifest/session payload reaches diagnostics. + +## Implementation evidence and review scope + +`bun .tmp/aside-profiles/api-cli-probe.ts` passed against an isolated live HTTP management handler and actual CLI: three-profile bulk enable, individual-off after persisted reload/model selection, Undo followed by sync, unrelated settings and metadata privacy. Default unconfigured/disabled Aside now skips implicit fan-out before manifest/catalog discovery. + +This C4 backend layer is larger than the default review-size guideline because the new filesystem scope, one-owner store model, reversible desired state, and API/CLI consumers must be assessed as one complete contract; these are new cohesive modules with focused fixtures, not unrelated cleanup. UI implementation remains a separate dependent PR/cycle, and the original Grok work is already four separate reviewed PRs. + +## Coordinated client interface amendment + +CLI Aside refresh runs through POST /api/client-integrations/aside/sync on the live server, never through the local file writer; MCode/Pi keep their existing paths. Add a deterministic two-process CLI/server coordination regression. Dedicated primary profile paths are /aside/profiles (GET list, PUT bulk), /aside/profiles/ (GET/PUT one), /aside/profiles//journal (GET/DELETE) and /aside/profiles//restore (POST). CLI and new UI use these paths so unsupported old servers refuse rather than ignore a profile query. The new server may retain validated query compatibility, but Aside can never fall through to a legacy generic writer. Journal source availability and request selector consistency are part of the final regression matrix; detailed review synthesis stays ignored scratch. diff --git a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md new file mode 100644 index 0000000000..974d91f335 --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md @@ -0,0 +1,33 @@ +# 020 Aside GUI profile controls and terminal delivery + +Depends on 010 verified API and CLI. Class C3 UI with C4 backend unchanged. Goal: all discovered profiles are visible, bulk and individual switches operate on exact profiles, and existing history/restore is still usable. + +NEW gui/src/pages/integrations/AsideProfilesPage.tsx: useDataSurface GET profiles endpoint, existing Notice/Switch/ClientMark/IntegrationStateBadge. Global switch sets desired sync for all; rows show profile name or translated numeric fallback, current marker, actual state, independent switch, and details action. Single pending target serializes interactions consistently with backend. Switches read desired enabled; badges and applied/total count read actual file state. Show pending mismatch and per-profile refusal after partial failure, never optimistic applied success for siblings. A retry repeats the same desired action. Empty profile list prompts opening Aside; errors offer existing refresh action; inactive tabs do not fetch. A selected profile opens the existing FileIntegrationPage with profileId plus name and a back action; do not duplicate its rollback machinery. +MODIFY gui/src/pages/Integrations.tsx: Aside renders new page; remaining file clients stay on existing page. +NEW or MODIFY integration-api.ts profile contract/types and load function; optional profileId selects dedicated nested profile paths for state/toggle/history/restore/delete; old servers must refuse unsupported scoped mutations. Preserve old call signatures for other clients. Runtime response validation must accept only safe profile IDs and recognized IntegrationStatus states, and retain partial outcomes for UI display. +MODIFY FileIntegrationPage.tsx: optional profileId/profileLabel, read optional desired enabled on scoped status, include profile in every resource/cache/dependency key and every state/history/mutation call. MODIFY RestoreDialog.tsx if needed to pass profile scope through; rollback/delete remain on selected profile. +MODIFY styles-integrations.css: compact row layout using existing tokens; responsive wrapping for long labels/paths. No new color system or decorative assets. +MODIFY every gui/src/i18n locale module: profile list/title, sync-all, enabled count, current profile, details/back, empty, per-profile switch labels and partial failure copy. All visible text uses t/useT; names and numeric IDs are API metadata. +UPDATE guides/integrations.md and operating CLI docs with all-profile default, --profile examples, active-profile independence, per-profile exclusions and restart behavior. Translations must not contradict new all-profile behavior. + +Verification: remote focused GUI/API tests, GUI lint/i18n/build, root typecheck and required CI. Browser QA on local dev UI against three synthetic profiles, never real user profile mutation: initial mixed state, global enable, one profile disable, return to list after details, correct request selector, reload retains off state, failed profile does not imply sibling success, keyboard switches and narrow viewport. Capture actual screenshot for PR body using existing browser plugin, view it, and fix layout if needed. Screenshot contains synthetic labels only. A PR mentioning GUI includes screenshot. No local test suite or local typecheck; local dev server/browser probes are permitted. + +Terminal: verify every PR current head and all applicable hosted checks; native stack registration, owner-authorized admin merge, async merge completion, fetch dev and prove every merge SHA ancestry. Resolve CI or reviews rather than bypass evidence. No release or live service deployment. All original Grok/Pi/Codex and added Aside-profile criteria must be met before host goal completion. + +## P revalidation at1d4da9f9b + +Backend primary routes are now dedicated nested profile paths; server-only POST /aside/sync owns synchronization and preserves exclusions. The UI Sync-now button uses it, while the bulk switch sets all desired flags and per-row switches set one. Shared Switch already supports mixed state and aria-pressed. Preserve existing monochrome tokens/ClientMark (variance2/motion1/densityD5). StatusDTO carries desired enabled plus actual state, counts and per-profile errors; show partial/error states explicitly. FileIntegrationPage and RestoreDialog/Overview actions carry optional profile IDs into paths and cache keys. + +Main owns AsideProfilesPage, integration-api profile DTO/functions, parentpage/FileIntegrationPage/RestoreDialog/Overview wiring and CSS. A disjoint locale/test worker may own gui/src/i18n/{en,de,fr,ko,zh,zh-TW,ru,ja,tr}.ts and new gui/tests/aside-profiles-page.test.tsx after exactkeys/APIcontract are fixed. All GUI checks run remotely; local Vite and browser probes only. Capture wide+narrow realcomponent screenshots against synthetic three-profile management fixtures, not real accounts. + +A audit passed: DTO error precedes empty rendering; stale errors remain visible; always refetch after refused mutations because intent may already be saved; use mixed Switch and void refresh semantics. Exact locale keys are locked in ignored .tmp/aside-profiles/ui-keys.json. Main exports loadAsideProfiles and syncAsideProfiles from integration-api, and adds profileId as last optional argument to existing state/toggle/history/restore/delete functions. Toggle rejects207 partial only through returnedokfalse; UI reports it and refetches. + +Implementation modularization: profile DTO validation and load/sync readers live in new aside-profile-api.ts, reusing the existing integration transport/error owner without a circular re-export. Existing integration-api functions keep their non-Aside signatures and unscoped cache identities; profile scope is an optional final argument. Scoped successful state/toggle/restore/delete responses and journal rows must match the requested profile. + +Review fold-back: per-profile refusal/recovery outcomes remain typed and visible in bulk and Sync-now failures, including snapshotPath/residual; failed Aside restore reconciles owner resources while retaining the dialog/error; list and detail share one pure profile-status validator. Add a localized no-snapshot recovery warning and the corresponding regression cases. No source permission or confirmation boundary is weakened. + +B verification: the final interface provides bulk and individual desired-state switches, actual applied counts, per-profile retry/refusal and recovery details, profile-scoped history/restore/delete, and nine-locale copy. Remote checks passed: 74 interface/API cases, then 54 affected interface/cache cases after type narrowing, GUI build and both lint commands; 27 engine cases with real failed compensation, 62 CLI cases, and root typecheck. Initial failing compensation fixture was corrected to fail ownership after a successful file write, then fail rollback. Browser probes on three synthetic profiles covered bulk enable, exclusion persistence, scoped Undo, keyboard control, narrow layout without horizontal overflow, and per-profile external-edit refusal. Screenshot: docs-site/public/screenshots/aside-profiles.jpg. No real Aside profile files were written. Hosted exact-head CI and stack landing remain open. + +Hosted-review follow-up stays within terminal stabilization: Models selection/preset consumers must distinguish saved selection from refused client-file refresh. Add a persistent warning listing each failed client/profile and recovery details while retaining truthful selection-success feedback; clear it after a later successful refresh result. Failed HTTP saves never claim saved selection. Reuse shared refusal formatting and add nine-locale copy, focused interface cases and browser evidence. The ordinary owned-refresh producer also preserves backup/residual metadata. Native schema review follow-up uses structural object-key equality while retaining array order and true-conflict refusal; test both explicit and loaded duplicate declarations. These are review repairs to the existing stack slices, not new product scope. + +Final review verification: model-warning fixes passed 35 remote interface cases, build and lint; 80 schema/owned-refresh cases passed. Full remote interface suite passed 1,484 cases with one ownership-path copy failure; the missing per-profile path was restored in all nine locale sentences, and the unchanged five-case locale-parity file, build and i18n lint then passed. No test was weakened. Browser evidence confirms a saved selection with an independent affected-profile warning and clears the warning after a subsequent successful refresh. Independent reviews passed for the interface recovery, registry reconciliation, model-warning concurrency/fallback behavior and structural schema comparator. A complete source hash manifest matched 576e6b557 for the final root typecheck and privacy scan. Hosted exact-head checks and merge ancestry remain terminal evidence to capture in the session ledger. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/000_research.md b/devlog/_plan/260906_grok_catalog_and_patch/000_research.md new file mode 100644 index 0000000000..7b97dc7bee --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/000_research.md @@ -0,0 +1,17 @@ +# Grok catalog selection and Codex patch parity + +Class C3, spec-satisfaction repair. The user clarified that filtering means enabled model visibility in Pi/Aside, not assistant output filtering. No output-filter changes are authorized by this unit. Prior Chat patch fixes must remain effective at every Codex Responses tool completion boundary. + +Scope: export catalog selection, refresh of already-owned Pi/Aside integrations, native Responses custom-tool repair, focused regression tests, matching docs. No live user config changes, deployment, release, unrelated cleanup, local test suites or local typecheck. Push --no-verify and admin merge are explicitly authorized. Probes and hosted CI are authorized. + +Evidence: src/clients/config-export/constants.ts selects openai-completions for Pi; Aside uses the same builder. src/server/management/model-rows.ts:218 filters disabled only. src/cli/opencode.ts:373 likewise ignores selectedModels. src/codex/catalog/provider-fetch.ts:1992 is canonical for allowlist plus disabled and pending selection. src/server/management/model-routes.ts visibility writes converge Codex only. Both explicit sync paths refresh only MCode among owned file integrations. Live read-only snapshot: /v1/models and existing Aside managed file currently contain xai/grok-4.6 only; do not claim that the live snapshot reproduced a full catalog leak. Synthetic allowlist and stale-owned-file scenarios will establish the gaps. + +Independent patch analysis: native custom exec is skipped by repairable||aliased in responses-custom-tool-repair.ts:206. Its item.done/input.done preserve raw patch while response.completed repairs it. Function apply_patch helper aliases can stream raw patch before compiling final JS. Existing arbitrary JS and foreign namespace boundaries stay byte-exact. + +Dependencies: 010 export selection -> 020 owned file convergence -> 030 Codex patch completion parity. Each has its own PABCD and reviewable PR. The patch layer is a separate user-requested stabilization concern published after catalog layers in the requested stack. + +Resource scope: existing GitHub repo credentials, read-only local configuration with no secret output; at most 24 synthetic live-provider calls, each <=120 seconds; six-hour execution window. No explicit token budget. Probe scripts stay ignored .tmp; public notes contain no credentials or private requests. DONE = regression probes, actual hosted exact-head test/typecheck CI, independent review, registered stack merged and fetched-dev ancestry. BLOCKED only for a persistent external dependency; no stopping on CI queueing. Each later P rechecks current source and carries earlier evidence. Escalation: reclaim failed delegated scope; new write delegation requires P amendment. + +## Baseline probes + +`bun .tmp/grok-stabilization/catalog-probe.ts` exit 0: management/CLI × pi/aside each emitted grok-4.3, grok-4.5, grok-4.6 despite selectedModels=[grok-4.6]; full management roster was three. `bun .tmp/grok-stabilization/patch-probe.ts` exit 0: native custom exec emitted one raw delta, input.done uncompiled and item.done uncompiled; function apply_patch alias emitted one raw preview despite compiled final. Both are observation probes before repair, not passing acceptance assertions. `python3 .tmp/grok-stabilization/verify-roadmap.py` exit 0 checks numbered roadmap artifacts and actual existing source target paths. No local suite or typecheck was run. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md new file mode 100644 index 0000000000..3b687e9b1c --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md @@ -0,0 +1,10 @@ +# 010 Export catalog visibility + +Loop: spec-satisfaction repair; trigger: selectedModels ignored by export projection. Goal: Pi/Aside export obeys the same provider allowlist, blocklist, pending selection as routed catalog. No change to full management catalog or routing authorization. + +MODIFY src/server/management/model-rows.ts: import filterCatalogVisibleModels. In loadExportModels compute visible routed row identities from filterCatalogVisibleModels(rows.filter(row => !row.native), config); return only !row.disabled and (row.native || visible set contains row), then map toExportModel. Preserve native visibility semantics. +MODIFY src/cli/opencode.ts: use same canonical filter once for rows with non-native provider/id identity, then exclude those not retained before seen.add. Do not infer provider identities for legacy rows missing them; keep existing disabled and Direct-native checks. Preserve order, custom/combo aliases and per-row metadata; do not duplicate allowlist matching. +MODIFY tests/server/management-client-config-route.test.ts and tests/cli/cli-export-command.test.ts: fixtures with xai selectedModels=[grok-4.6], full three-model roster, blocklist override, empty allowlist, slash-bearing ids, disabled duplicate. Render both pi and aside through production loader and CLI projection. A nonempty allowlist retains only selected IDs; a ready provider with an empty allowlist retains its full otherwise-visible roster; pending initial selection keeps routed rows hidden. Management still offers all IDs. CLI consumers must reload their configured state after discovery because the request can persist the initial selection. +MODIFY docs-site/src/content/docs/guides/integrations.md: explain selected list applies to generated catalogs. + +Verifier: standalone synthetic imports of loadExportModels/exportModelsFromProxyRows plus Pi/Aside serializers; no bun:test. CI runs existing focused regressions, typecheck and full platform suite. Before C record exact source SHA and probe output. Stop after export boundaries agree; next cycle refreshes old owned files. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md new file mode 100644 index 0000000000..8ae79ae938 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -0,0 +1,27 @@ +# 020 Converge already-owned Pi/Aside catalogs + +Depends on 010 filtered loader. Loop spec-satisfaction repair. Goal: a model visibility/selection change and explicit sync refresh existing connected Pi/Aside files. No adoption of unowned/manual files, no recreation of removed blocks, no override of drift. + +NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input, clientIds) defaults clientIds to [pi, aside]; callers may supply an explicit list including mcode. It passes a lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. The later Aside-profile layer delegates Aside to its server-owned profile engine; direct CLI sync passes [mcode, pi] here and invokes the Aside server helper once separately, including an explicit unavailable-server diagnostic. +MODIFY src/server/management/model-routes.ts: local async convergence helper calls existing convergeCodexCatalog then new owned refresh for pi/aside with port from URL/config and lazy loadExportModels(config); attach clientIntegrations outcome to disabled-models, model-visibility, selected-models and model-preset writes. Keep successful config persistence even when one file refuses refresh; return warning outcome. +MODIFY src/server/management/config-routes.ts and src/cli/dispatch.ts: expand current MCode-only owned refresh to mcode/pi/aside via helper; preserve native Grok/Desktop gates and refused-sync behavior. +MODIFY existing tests/clients/sync-client-integrations.test.ts and tests/server/management-integration-routes.test.ts: fake IO/store or isolated home seeds owned pi/aside with two models, refresh with selected one, assert hidden row removed and other provider fields preserved. Prove unowned, removed and drifted configs untouched; one failure does not block other client. Add route-driven visibility refresh coverage using injected convergence. +UPDATE structure/09_client-integrations.md and owning docs page with ownership/refusal semantics. + +Verification: standalone isolated writer probe using synthetic models and temp homes, then exact-head hosted CI. C4 care for automatic owned-file writes: independent review must confirm ownership/no-clobber and per-client failure boundaries. Final enforcement is existing coordinated writer; refresh helper is an early caller, not a permission boundary. Known bypass: manually calling writer with explicit adoption; no such call in this unit. Stop when file projection converges or produces truthful refusal. + +## Audit amendment: overlapping refreshes + +The existing constant refresh mutation-flight key incorrectly joins different model selections. MODIFY src/integrations/owned-refresh.ts to use a unique per-refresh operation key (crypto.randomUUID), making overlapping refreshes explicitly busy rather than reporting another desired catalog as success. Implicit refresh never joins an explicit HTTP mutation. Add controlled overlap with distinct old/new rosters: second call reports integration_mutation_busy; first result describes only its own write. Subsequent retry applies the new roster. Return per-client failures; never retry stale snapshots automatically. + +Add a ManagementApiDeps refreshOwnedCatalogIntegrations seam for route verification, defaulting to the real helper. Creation: exported helper/deps type; consumption: model routes and explicit sync. No serialization/deserialization: runtime-only dependency injection. Tests use fake IO/store or temporary home, never actual user-owned files. + +## P revalidation and implementation interface + +010 b8010aebd passes the four standalone visibility probes and source review; all original hosted-CI/merge criteria are retained under the terminal stack cycle, not marked complete. Helper signature: refreshOwnedCatalogIntegrations(input: Omit, clientIds: readonly IntegrationClientId[] = ["pi", "aside"]): Promise. Memoize the lazy model load per fan-out; no owned record means no catalog load. Catch and redact each failure. Explicit sync passes [mcode,pi,aside]. Visibility routes attach both catalogRefresh and clientIntegrations; native Codex failure does not undo an already persisted selection. + +Delegate tests only to one worker: tests/clients/sync-client-integrations.test.ts owns helper refresh+overlap coverage; main owns implementation and route regression tests. The worker has no production writes, suite execution, FSM or git mutations. + +## Implementation audit synthesis + +Averroes found an indirect source-oracle dependency: codex-convergence-contract.test.ts counts direct convergence calls and two preset calls. The shared visibility helper changes direct count but preserves fourteen logical paths. Update the inventory to subtract the helper definition and add its five callers, assert exactly one Codex convergence inside the helper, and preserve the marker-only custom preset negative. Run that affected file remotely in addition to the writer/route tests. No runtime blockers in the ownership audit. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md new file mode 100644 index 0000000000..a7778f6dc0 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md @@ -0,0 +1,21 @@ +# 030 Codex native Responses patch completion parity + +Depends on recorded export layers for stack delivery; runtime independent. Class C3, spec-satisfaction repair. Goal: same repaired executable input at deltas/input.done/item.done/response.completed for complete patches misrouted as exec. No arbitrary JavaScript rewriting. + +MODIFY src/server/responses-custom-tool-repair.ts: register same-name routed custom calls in addition to aliases. Track original wire name and target; hold custom input deltas when code-mode exec may be a patch envelope or when helper alias requires compilation. Accumulate under TranslatorBudget, release on done/dispose. Run restoreRoutedCustomCalls for same-name custom items, and use existing resolveCodeModeHelperName/compileCodeModeHelperInput at input.done. Do not place exec in repairNames. Preserve ordinary JavaScript streaming where monotonic; once raw prefix would diverge, withhold to authoritative completion. Suppress function helper-alias progressive previews rather than emitting raw patch before compiled JS. +MODIFY tests/responses/responses-custom-tool-repair.test.ts: native custom exec raw/wrapped complete patch, fragmented marker, input.done and output_item.done plus terminal snapshots; function apply_patch wrapper alias; invalid/incomplete envelopes and valid JS remain exact; flat catalogs and foreign namespaces do not retarget; cancellation frees retained buffers. +UPDATE existing patch compatibility docs and structure/11_compatibility-contracts.md to describe completion-boundary parity. + +Verifier: pure standalone synthetic SSE-block imports, compare outputs at each lifecycle edge and execute generated JS against a recording tools.apply_patch stub (no filesystem writes). Probe must assert monotonic preview or held preview, one call, exact canonical patch data. CI runs added regressions and existing bridge/native compatibility tests plus full suite/typecheck. Complete only after independent review and exact-head CI; register requested stack and admin merge after verified heads. Fetch dev and prove every merge SHA ancestor. D records parity inventory and public PR links. + +## Audit amendment + +Executable repair is limited to authorized code-mode exec and recognized helper aliases; unrelated same-name native custom tools keep raw input byte-for-byte. Explicit negative: render_diagram input JSON string {"input":"literal"} is not unwrapped. Separate scenarios cover missing input.done, terminal-only completion, failed/incomplete after held deltas, and disposal. Authoritative completion wins over previews. Failure never synthesizes successful completion. All retained buffers release. One simulated execution means choose the client-consumed completed item once, not execute every redundant lifecycle representation. + +## P revalidation + +Consume 020 ff388977a with isolated route/writer proof and remote75+19 tests. 030 remains scoped to the two patch lifecycle gaps. Append040 for independently confirmed ordinary function and dotted-namespace parity gaps; all terminal CI/merge obligations move there unchanged. Main owns production030; a disjoint worker may add tests only in tests/responses/responses-custom-tool-repair.test.ts. Native code-mode exec previews may be held until final when they could be complete raw/wrapped envelopes; unrelated native custom JSON bodies remain raw. + +## Implementation audit synthesis + +A fragmented pretty JSON wrapper beginning with brace-newline escaped the compact-prefix guard, so preview bytes could contradict compiled completion. The completion parser accepts arbitrary whitespace, escaped property names and property order; native exec now conservatively holds all object-leading inputs to completion. Ordinary JavaScript stays byte-exact, though a block-leading program waits for completion. Added per-character pretty-wrapper and escaped-key regressions. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md new file mode 100644 index 0000000000..8d85a71fa0 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -0,0 +1,30 @@ +# 040 Native function and namespace parity + +Depends on 030 custom-call restoration. Class C3 spec-satisfaction repair. User requested all Chat-era tool repairs be checked. Independent source/probe inventory finds native ordinary calls retain integer-as-float and numeric-as-string mismatches, completed empty arguments, and dotted namespace names that bridge already repairs. These are in scope; assistant output filtering is not. + +NEW src/responses/function-call-compat.ts: collect original current-turn ordinary function declarations using collectResponsesToolGroups, preserving namespace/kind and original parameter schema. Lookup exact declared identity, including reserved functions children as bare and authorized canonical namespace aliases. Do not consume historical-only declarations or provider-normalized schemas. Pure completed-item transform calls coerceIntegerToolArguments(raw||"{}", original.parameters, original.namespace ? undefined : original.name). Only explicit completed empty payload becomes {}; unknown/missing, malformed nonempty, fractions, numeric unions, unsafe integers, custom/hosted/helper calls remain unchanged. +NEW src/server/responses-function-tool-repair.ts: SseBlockRewrite tracks item identity by item_id/output_index and uses same pure completion transform at arguments.done, item.done and terminal snapshots. Preserve in-progress placeholders. Budget any buffered data and release on done/terminal/dispose. Original declaration is authority; attempt wire alias is transport spelling only. Final representations must agree and failed/incomplete never synthesize successful executable input. Revalidate whether delta holding is necessary against existing bridge closeCurrentToolCall (which already repairs authoritative final arguments after streamed numeric previews); use one compatible completion contract rather than introducing arbitrary JSON rewriting. +MODIFY src/server/responses/core.ts: derive ordinary schemas from currentTurnWireToolCatalogBody before lowering; compose native function repair after namespace/custom restores and before undeclared guard. Apply pure repair in JSON, SSE final snapshots, bounded JSON-to-SSE, and rememberPassthroughResponseChecked so client and replay state agree. Canonical forward auth remains byte-pass-through. Rebuild only attempt-specific aliases on retries. +MODIFY src/responses/namespace-tool-compat.ts and, if needed, responses-undeclared-tool-guard.ts: reuse existing collectAmbiguousDottedAliases ownership algorithm rather than duplicate. Add unambiguous dotted aliases after canonical authorization, collisions computed from whole original current-turn catalog including bare spellings before selection. Never reinterpret explicit conflicting namespaces or different kinds; canonical identities retain precedence. +MODIFY existing native Responses repair and namespace tests, or register new domain tests in both layout manifests: integer/string and no-arg scenarios at JSON/each SSE completion/replay, namespace wait exception boundaries, same-inner-name schemas, forbidden selectors/replay-only names, early/interleaved events, terminal/dispose cleanup, dotted collision order independence, unchanged030 code/patch semantics. +UPDATE structure/11_compatibility-contracts.md and guides/codex-integration.md with completion parity boundary and inventory. + +C: standalone synthetic imports with stub tools (no real execution) and remote focused tests/typecheck; all stack PR exact-head hosted CI must pass before merge. Keep original pi-filter/owned-refresh/responses-patch terminal criteria unchanged and satisfy them at final D with PR heads/CI/merge ancestry. Register native GitHub stack, merge approved prefix using async REST and SHA guard, wait for actual merged status, fetch dev and prove all merged SHAs ancestors. No release/deploy/local suites. Stop only verified DONE or actual external blocking evidence. Resource bounds inherited from000. + +## P revalidation at b477b731e + +030 lifecycle+raw payload boundaries are independently reviewed and55 remote tests pass. The existing bridge streams ordinary function argument previews then uses coerceIntegerToolArguments at authoritative arguments.done/item.done;040 mirrors that contract. Unlike executable exec source compilation, numeric representation repair does not require withholding previews. Do not synthesize corrected deltas; correct the authoritative completion events and JSON snapshots, and verify downstream Chat collector consumes those finals. Buffer only early identity-less completion events if correlation needs them; all retention remains budgeted. + +Implementation interfaces locked for disjoint delegation: function-call-compat.ts exports collectFunctionCallRepairSchemas(body), repairFunctionCalls(value, schemas): {value,changed}, repairFunctionCallsInJson(text,schemas). responses-function-tool-repair.ts exports createResponsesFunctionToolRepairBlockRewrite(schemas,budget?). The collector reads only original current-turn ordinary declarations and honors original function-kind/namespace selector restrictions; it can reuse pure namespace lowering to resolve selectors while preserving original schema values. Native forward routing receives an empty repair map. Empty ordinary completed arguments become{}; custom/native wrappers never enter. + +Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to new responses/tool-name-aliases.ts, guard import updates, core integration, namespace tests, layout registration, docs. Worker owns only the two new function repair modules and one new tests/responses/responses-function-tool-repair.test.ts. No shared write paths, no worker commits/FSM/local suites. Core captures schemas after successful adapter buildRequest from the previously captured clientToolAuthorizationBody, then uses same pure repair in remembered continuation and clientJSON, and block repair after custom/tool-search restores before final declaration guard. Each attempt receives fresh block state. + +## Implementation observations + +Namespace aliases are built after custom lowering (openai-responses.ts2410-2432), so an original custom tool can carry lowered kind=function. Preserve that existing namespace restoration kind behavior; only original-schema function repair enforces ordinary function kind. Dotted restoration adds spelling parity, not a new kind conversion. Explicit conflicting namespaces stay untouched. Reserved functions children participate in the shared collision inventory as bare names. The existing namespace tests are updated for additional alias entries rather than weakening their authorization assertions. + +Review-size exception: keep original-schema collection, native SSE/JSON/replay wiring and their end-to-end regressions in one layer because they jointly define the completion contract. Roughly half the added lines are focused regressions; the alias inventory is moved, not reimplemented. Prior catalog and patch concerns are already separate PRs. Additional Aside profile work remains separate future cycles. + +## Review synthesis, round1 + +Accept three medium findings: (1) sparse JSON receives inferred completion status after the new repair, so normalize snapshot/required fields before function repair and reuse that normalization for stored replay; (2) an index-only early completion can be correlated but still lacks item_id, so attach the known id even if arguments stay unchanged; (3) current-turn tool_search_output declarations are promoted by the adapter but absent from the original-schema collector, so include their original definitions in collector/selector resolution after the replay-prefix cut. Do not broaden collectResponsesToolGroups globally or include historical loaded declarations. Main owns normalization order/replay regression; existing worker owns early-frame id and loaded-declaration collector fixes/tests. Original authorization and preservation constraints remain. diff --git a/devlog/_plan/260906_key_login_ci_timeout/000_plan.md b/devlog/_plan/260906_key_login_ci_timeout/000_plan.md new file mode 100644 index 0000000000..6e43cf1a09 --- /dev/null +++ b/devlog/_plan/260906_key_login_ci_timeout/000_plan.md @@ -0,0 +1,31 @@ +# 000 — Diagnose the key-login live-update CI timeout + +One focused PABCD repair cycle. Baseline dev `922bfa653a013647881316f3d95f0631a87acb10` differs from failed CI head `73190c20443876fe1dbf4e9dde5d25644e48e71a` only by the previous lane's outcome record. + +## Evidence and outcome + +Public CI run 33999342751, job 101395411095, failed `tests/oauth/key-login-live-update.test.ts:61` after 15,046ms against a 15,000ms test budget. The shard finished 9,470 pass, 7 skip, 1 fail. The log records server startup but no failing assertion or awaited-operation trace. Other execution jobs passed; Windows six-shard tests were intentionally skipped by the push workflow. + +Keep the same disk/live modelCosts and rotated-key assertions. Locate the wait before changing code. A passing retry alone does not explain the failure. + +## Investigation and conditional change map + +- Read `tests/oauth/key-login-live-update.test.ts` and instrument its asynchronous boundaries only in remote scratch: key-login commit, management read and server stop. +- Trace `src/oauth/login-cli.ts` notify, `src/server/local-provider-reload-client.ts` request, `src/server/management/provider-routes.ts` reload validation/convergence, and the server's shutdown hooks. Preserve every admission predicate. +- The fixture currently installs the real Umans hostname both before start and through the replacement preset. Check whether DNS/network dependence causes the observed wait. If established, modify only the test fixture: use a synthetic controlled destination via the existing baseUrl override, preserve real local attestation/reload/convergence, assert the reload outcome, and clean owned resources. No timeout extension, skip or mock of the operation under test. +- If the wait is a production lifecycle defect instead, amend this plan with the observed boundary and smallest production correction before B. Do not introduce speculative cancellation or security changes. +- Put the final evidence and failure disposition in `010_outcome.md`; unpublished security findings, if any, remain in ignored scratch. + +## Execution and acceptance + +Class C2 for a hermetic fixture correction; promote to C4 with independent security review if executed auth/admission logic must change. Main owns refs/PR/FSM; one remote worker owns serialized macOS reproduction under the shared test-user lock, and an independent reviewer owns static analysis. Inherit the parent model. No local suite, typecheck, build or hooks. Remote pinned Bun 1.4.0 and synthetic fixtures only; do not access personal accounts/services. No requested token/cost budget; six-hour checkpoint, not an automatic success condition. + +Required evidence: original failure and causal trace, focused remote original/fixed comparison, original assertions intact, relevant adjacent tests and typecheck, independent review, current-head hosted CI, admin merge and actual dev ancestry. Follow final dev CI for this repair. Existing user push/admin authorization applies; every push uses --no-verify. No release, deployment, global relink, integration-branch direct push or changes to another lane's jobs. + +DONE requires those actual outcomes. NOOP needs proof current dev already resolves the failure. Unknown cause, an expired wait, and a red CI are not completion. Append a separate cycle only if a distinct necessary repair appears. + +## A evidence amendment: controlled failure path + +Remote pinned-Bun macOS reproduction: original fixture passes in 95.42ms with controlled outbound HTTP. Delaying only the real Umans DNS answer gives reload transport-unavailable at ~10.28s, successful local config GET at ~10.29s, then `server.stop(true)` begins and remains unsettled until the original 15s test timeout (15,008.86ms). The trace identifies `providerDestinationResolvedError` as the DNS caller. This reproduces the CI timeout shape; the uninstrumented historical CI log still does not prove which external delay occurred there. + +Selected change is test-only: an owned literal-loopback upstream in beforeEach with a deterministic catalog response, `umansKeyConfig(baseUrl, port)` using explicit private-network opt-in, the existing key-provider constructor's URL override plus private-network opt-in on the replacement row, and awaited upstream teardown after the proxy. Preserve all four original assertions and the 15s ceiling; additionally assert reload outcome is `reloaded` and config GET is HTTP200. No production auth, destination validation, transport or shutdown change. Focused remote gate covers this test, key-login overlay merge, OAuth live update, local reload client and direct transport, plus root typecheck. A controlled delayed-DNS run must remain green with zero DNS calls from the fixed fixture. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md b/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md new file mode 100644 index 0000000000..d2be407c30 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md @@ -0,0 +1,65 @@ +# Lane B catalog carry roadmap + +## Loop specification + +- Archetype: spec-satisfaction repair and attributable integration. +- Trigger: owner assigned catalog lane B and authorized stacked PRs, no-verify pushes, merges and immediate closure of completed source work. +- Goal: manual OpenAI visibility, persistent context limits, Go effort/ordering, provider model management and Fable 1M selectors work together on dev. +- Non-goals: other lanes, release/main/preview promotion, deployment, global proxy/config changes, new dependencies, broad cleanup. +- Tool/credential scope: git and authenticated GitHub CLI for this repository; inherited-model subagents; read-only local inspection; isolated QA or remote checks only when needed. +- Write scope: this unit, the exact source-PR files named by each decade plan, necessary focused regression/SoT follow-ups, and ignored scratch/evidence. Preserve peer changes. +- Resource policy: user authorized inherited parallel agents without a numeric cap. No imposed token/cost limit. Six-hour work-phase checkpoint; a reached bound is reported honestly, never as success. Context compaction only checkpoints work. +- Verifier: current-head Cross-platform CI, GUI tests/lint/build and privacy checks from repository CI; independent diff review; GUI observation where rendering changed; git ancestry and attribution checks. Local tests, suites, typechecks and builds are prohibited for this run. +- Stop: all five outcomes verified on dev, replacements merged, original PRs closed and fully resolved issues closed. +- Memory artifact: this unit plus the session-bound goalplan; volatile source/review/CI snapshots in `.tmp/lane-b/`. +- Outcomes: DONE after proof; NOOP only with current-code proof; external BLOCKED/UNSAFE/NEEDS_HUMAN requires evidence and no other authorized progress. Pending CI is continuing work. +- Escalation up: main reclaims a packet after two distinct agents fail it. Down: delegate only explicit bounded tasks recorded at P; no speculative implementation of a later phase. + +## Current tree and source anchors + +Initial dev is `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. The managed checkout stays in place and is adopted as `codex/lane-b-01-visibility`. + +| Phase | Source PR/head | Contract | Branch plan | +|---|---|---|---| +| roadmap | current dev | lock these documents only | base visibility branch | +| visibility / 010 | #3653 / `956eedac439922cf7645f130ef8432833e813a9a` | distinguish native and configured manual rows | `codex/lane-b-01-visibility`, base dev | +| context / 020 | #3654 / `8facdb0d8c10109701015c0f6109fc67b1d9dd3c` | preserve selection independently of enabled state | `codex/lane-b-02-context`, base 01 | +| ordering / 030 | #3571 / `0a935c5694229760c8c1cd5a62072107d8ae6696` | separate picker/spawn rank and exact efforts | `codex/lane-b-03-ordering`, base 02 | +| management / 040 | #3659 / `ff4e5cd5352b9c1bd05e3de0091f3483ca130be5` | consume visibility contract for hide/delete and static sync | `codex/lane-b-04-management`, base 03 | +| fable / 050 | #3649 / `95becce94255982667cef10308806770d49cc05b` | preserve 1M selector and canonical upstream route | `codex/lane-b-05-fable`, base 04 | +| landing / 060 | all verified replacement heads | bottom-up dev integration and closure | retain parent refs until child retarget | + +The owner explicitly requests stacked PR delivery. Context and ordering share persisted catalog configuration; model management consumes the visibility and catalog contracts. Fable is functionally independent and placed last only to satisfy the requested stack delivery; no runtime dependency is claimed. One work-phase is one full PABCD cycle; each implementation phase is verified before the next. + +## Existing owners and SoT + +Runtime management lives in `src/server/management/`, catalog publication in `src/codex/catalog/`, persistence in `src/config.ts` and `src/providers/context-cap.ts`, dashboard rows in `gui/src/models-groups.ts`, provider workspace in `gui/src/components/provider-workspace/`. Focused tests remain in domain directories; new files update both test-layout manifests. Read nested AGENTS before changes. + +SoT synchronization targets are `structure/02_config-and-codex-home.md` (context persistence), `structure/03_catalog-and-subagents.md` (efforts and ordering), and `structure/05_gui-and-management-api.md` (visibility and model operations), plus the source PR's public documentation. Add narrow contract notes only when existing text would otherwise be incomplete or contradictory. + +## Verification execution policy + +`.github/workflows/ci.yml:7` accepts all PR bases, including open stack heads. Its `changes` filter controls actual test execution; a green aggregate with skipped test jobs is insufficient. `workflow_dispatch` supports all lanes. Inspect each actual run's head SHA, event, test jobs and conclusions. Author-reported historical test counts do not certify a carry head. + +`git diff --check` and a Python document-completeness checker are documentation/static artifact checks, not repository test execution. These are the only local checks in the docs-only cycle. Implementation C receipts invoke a read-only GitHub evidence verifier that asserts the actual checked-out SHA and successful test jobs; the verifier never starts local tests. Screenshot paths already in original PRs preserve author evidence; rendering changes require an actual observation of the carried state or an explicitly identified outstanding gate. + +## Attribution and publication + +Carry source non-merge commits with `git cherry-pick -x` when compatible; otherwise apply the exact merge-base diff preserving binaries and create a scoped commit with actual source-author `Co-authored-by` trailers. Keep source PR/head references in every replacement description. Do not cherry-pick upstream merge commits as new feature content. Every push uses `git push --no-verify`; no direct dev pushes or contributor-branch rewrites. + +GitHub operations stay sequential. Bottom-up merge commits preserve ancestry; if squash is required, restack the children immediately and revalidate. Before merging, inspect current head, exact-head CI, outstanding reviews and any peer dev drift. Preserve all author trailers. Close source PRs as superseded and issues #3650/#3651 as completed only after their replacement is reachable from dev and solves the full report. + +## Shared surfaces + +- A #3679 and B #3654 share `src/config.ts`; B reconciles both independent field additions. +- D #3625 and B #3659 share locale modules; retain all keys. +- D #3646 and B #3649 share Claude alias routing; D owns hub alias resolution, B owns Fable native selector round-trip. +- A #3568 and B #3571 share layout manifests and provider docs; preserve both additions. + +Independent review findings involving security stay in ignored scratch space. Public plans describe the already-public source changes, never unpublished vulnerability analysis. + +## Owner steering and verification checkpoints + +The owner explicitly authorized admin merges during execution. Once a child PR is open, land a verified parent with `--admin --merge`, prove dev ancestry and immediately close its completed source work; retain/retarget the child before any parent-ref cleanup. The final landing cycle reconciles all outcomes rather than delaying every already-ready parent until the end. + +An implementation preparation cycle may close after the exact-head functional CI jobs (Linux/macOS full tests, typecheck, GUI tests and privacy), independent review and applicable remote GUI/docs checks pass. Queued aggregate packaging/keyring jobs remain explicit PR merge gates; do not claim them passed or merge before resolving required checks. This allows the next stack layer to be prepared while ancillary jobs queue, without weakening final verification or source-closure requirements. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/009_roadmap_lock.md b/devlog/_plan/260906_lane_b_catalog_stack/009_roadmap_lock.md new file mode 100644 index 0000000000..f5c1ce2a2b --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/009_roadmap_lock.md @@ -0,0 +1,5 @@ +# Roadmap lock + +The seven numbered roadmap documents passed independent read-only audit. The roadmap cycle produced documentation only. Original final binary diffs are pinned in scratch for attributable carry. GUI and docs observation will use an isolated remote checkout; macmini-cf has Bun 1.3.14. Ordinary PR CI proves Linux/macOS and quality gates, and the final stack receives an explicit Windows all-lane run. No local repository tests, typecheck or builds were run. + +Next cycle: 010 visibility. Carry the final #3653 diff rather than only its early commits, preserving the final Fast-row assertions and original PNG. Add the planned mixed-group and client-export behavior coverage, obtain independent management-boundary review and verify the carry head in CI. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/010_visibility.md b/devlog/_plan/260906_lane_b_catalog_stack/010_visibility.md new file mode 100644 index 0000000000..3577713269 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/010_visibility.md @@ -0,0 +1,298 @@ +# 010 — Manual OpenAI visibility and replacement rows + +Status: planned; source-inspection only. Research captured 2026-09-05T16:34:39.759005+00:00. Local anchor: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Source: [PR #3653](https://github.com/lidge-jun/opencodex/pull/3653), issue [#3650](https://github.com/lidge-jun/opencodex/issues/3650). Source head `956eedac439922cf7645f130ef8432833e813a9a`, source base `0b7f60ee259bdd0e5c68b62936fe153af151e9dd`. Live GraphQL confirmed this head remains OPEN with zero unresolved threads on 2026-09-05 UTC (2026-09-06 KST). + +## Execution contract + +This is an implementation design for one later PABCD cycle, not an implementation receipt. The main agent owns the FSM, host goal, 000 roadmap, branch stack, publication, and merge. This delegated research made no production edits, ran no local tests/typecheck/build, and changed no refs. + +Loop archetype: spec-satisfaction repair. Trigger: the linked public issue and source PR. Verifier: exact carried-head GitHub CI plus targeted behavior evidence below. Stop: all activation scenarios accounted for, CI producers successful, author attribution retained, and merge commit proven reachable from dev. Expected outcomes: DONE after that evidence, NOOP only if current dev already implements the same behavior; otherwise retain explicit BLOCKED/NEEDS_HUMAN evidence without claiming completion. Upward escalation: main reclaims a slice after two distinct agents fail its packet; downward delegation requires a P-phase amendment. Resource and credential limits inherit the main lane-B 000 plan; this document authorizes no independent goal, workflow dispatch, deployment, or account change. + +The future executor must re-read the nearest src/GUI/docs AGENTS before code changes. No local tests, suites, typecheck, lint, builds, or dependency installation: CI is the execution verifier. Read-only `git apply --check` below checks textual portability only, not correctness. + +## CI evidence contract + +Source inspection at the recorded local HEAD establishes coverage, not passing execution: + +- `.github/workflows/ci.yml:182-201` selects runtime/tests/GUI changes; both source diffs select `ci` and `gui`. +- `ci.yml:255-316`: four Linux shards invoke `bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"`. `scripts/ci/run-bun-test-batches.sh:46-64,196-204` enumerates test files and excludes only the dedicated storage/API-usage families; the targeted files below are included. +- `ci.yml:422-428`: root TypeScript checks and `cd gui && bun test --isolate tests`; GUI lint/build at 416-420 and 442-446, privacy scan at 430-431. These are CI commands, never instructions to run locally. +- macOS test execution is at `ci.yml:532`; Windows is **dispatch-only** at `ci.yml:661-662` and runs six shards at 754. A normal PR check does not prove Windows test execution. Main must obtain appropriate exact-head dispatch evidence before claiming three-platform coverage. +- `ci.yml:917-933` permits skipped producers. Inspect actual test/gates job results and tested commit (including the PR merge ref and its head parent), not just aggregate `ci=success`. +- `.github/workflows/react-doctor.yml:15-18,46-57` scans PR changes in `gui` and blocks warnings. It is an additional review gate, not a replacement for GUI tests. +- `.github/workflows/deploy-docs.yml:3-10,25-32` builds docs only on main push or dispatch; normal PR CI has no Astro-docs build. Do not dispatch a deploying workflow merely to get validation. Main must arrange non-deploy hosted docs-build evidence or explicitly retain that verification gap. Local builds remain prohibited. + +Before merging a carried layer, refresh source/head/base/review status, inspect exact-head CI jobs and remaining findings, preserve coauthor credit through squash, and verify the resulting merge SHA is an ancestor of fetched dev. Only then close the superseded source PR and its resolved issue; a source PR carried through another PR is not automatically merged/closed. Retarget/rebase the next child onto dev after its parent lands; do not delete a parent branch while an open child still targets it. These are main-owned future actions. + +## Scope and caller proof + +C3 product slice, with the existing management validation boundary retained for independent review. Outcome: manually configured `openai/gpt-5.5` can be toggled without HTTP 400; it replaces the matching bare dashboard row, while account-qualified native rows and native provider controls survive. No route renaming, entitlement change, catalog order redesign, model deletion API, or runtime transport change. + +Current `src/server/management/model-routes.ts:567-570` rejects every non-native OpenAI target. `gui/src/pages/Models.tsx:1463` sends the actual row's `native` flag; group visibility at 1218 sends mixed native/manual targets. `gui/src/model-visibility.ts:58-70` serializes these unchanged. Reuse this caller and existing atomic visibility handler, rather than adding an endpoint. + +Current `src/server/management/model-rows.ts:125-172` deduplicates routed custom rows but concatenates all native rows. Add bare-native filtering after `visibleCustomModels`/`customNamespaced` are known, before Fast-row metadata at 173-184. `loadExportModels` at 214-216 consumes the same rows and removes disabled entries, so client-config export needs explicit regression coverage. `model-routes.ts:357` returns these rows; `model-routes.ts:490-510` serializes client config via the existing export path. `gui/src/models-groups.ts:84` loses native controls when all visible rows are manual; derive `nativeProviderGroup` also from configured canonical OpenAI `authMode: forward`, while leaving `native` false for manual-only groups. + +## Exact source carry map + +| Operation | Path | +|---|---| +| NEW | `docs-site/public/screenshots/manual-openai-model-toggle.png` | +| MODIFY | `docs-site/src/content/docs/reference/management-api.md` | +| MODIFY | `gui/src/models-groups.ts` | +| MODIFY | `gui/tests/models-native-group-controls.test.ts` | +| MODIFY | `src/server/management/model-routes.ts` | +| MODIFY | `src/server/management/model-rows.ts` | +| MODIFY | `tests/codex-integration/model-visibility-management-api.test.ts` | + +All six textual files were reviewed, including all test hunks. The PNG is accounted for as a binary evidence asset: blob identity verified, pixels not inspected in this docs-only pass. Existing modules and test files are reused; no new runtime abstraction or test manifest entry is needed. + +### Carry method and attribution + +Prefer a **base-to-final-head diff port**. The source contains merge commits, and the last one resolved `model-rows.ts` against Fast metadata. Applying only the original feature commit loses the final regression assertions. Actual `git show -s` commit metadata identifies **Robin Bially <7304732+RobinBially@users.noreply.github.com>** on: + +- `7c5b4d918401d086dc633ab941be1ff9f844b13e`: original feature. +- `6c1b8b2d2f0abc4baa6618cea2e485374dda2aeb`: account-qualified and pending-selection regression additions. +- `956eedac439922cf7645f130ef8432833e813a9a`: final merge resolution, parents `e520ee5e437e6fc1d8482f51950722db9b58049a` and the source base above; adds Fast availability assertions in the existing test. + +Use `Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com>` in the carry commit and squash description. Do not blindly cherry-pick merge commits with `-m`. A selective cherry-pick is possible only if the executor separately ports the final merge resolution and compares resulting source delta to the final PR diff. + +Read-only `git apply --check --exclude='*.png' .tmp/lane-b/3653.patch` returned 0 against the recorded HEAD. Cached patch and `git diff ` match after normalizing Git's abbreviated index-hash lines. The patch has no binary payload; the future executor must retrieve `docs-site/public/screenshots/manual-openai-model-toggle.png` from the pinned source commit, blob `d8a0dab0de58bdfee4764341465eeff6a41b4dec`, or capture a replacement from the carried-head UI. + +## Implementation sequence within this phase + +1. Port the validation hunk without moving existing malformed-request or initial-selection-pending checks (`model-routes.ts:531-542`). Preserve mixed native/routed key handling at 593-635 and catalog convergence. +2. Port bare-native row filtering before current Fast annotations; retain combo precedence, account-qualified IDs and export metadata. +3. Port canonical forward-OpenAI grouping and all source regression tests. +4. Update public API documentation and attach honest UI evidence. Update the translated API error rows listed below so they do not imply that 400 is the only rejection contract. +5. Obtain hosted CI and independent review; then main performs authorized stack merge/issue closure. + +## Activation and regression matrix + +| Scenario / trigger | Observable proof | Owning test/evidence | +|---|---|---| +| Configured manual OpenAI row, `native:false`, enable then disable | 200; namespaced disabled key changes, native key preserved | source-added `tests/codex-integration/model-visibility-management-api.test.ts` | +| Unconfigured routed OpenAI or unsupported native target | 400, no config mutation | same file, retain existing negatives | +| Pending initial selection with configured or unconfigured manual target | 409 `initial_model_selection_pending`, config equal to before | source-added same file; `tests/providers/initial-selection-write-fence.test.ts` | +| Malformed scope while pending | 400 before pending check; no mutation | source-added same file | +| Bare `gpt-5.5` custom/native collision | one manual row with routed selector and 128k metadata | source-added row-list test | +| Exact account-qualified collision `desktop/` | account-qualified native survives custom collision and deletion | source-added row-list test | +| Remove manual entries | bare native row returns, qualified row remains | source-added row-list test | +| Replacement enabled / disabled | `fastRowAvailable` true / false, pending also false | final-head source assertions plus existing pending tests | +| Manual-only canonical forward OpenAI group | `nativeProviderGroup:true`, `native:false`; controls remain | `gui/tests/models-native-group-controls.test.ts` | +| Client-config export after replacement, disable, restoration | uses manual selector once; excludes disabled replacement; restores native selector when manual row removed | extend `tests/server/management-client-config-route.test.ts` using existing export fixture; preserve `tests/config/client-config-export.test.ts` coverage | +| Combined group visibility with bare + custom rows | both target kinds accepted atomically; unrelated provider keys unchanged | add explicit mixed group-scope case to existing visibility test if existing fixtures do not cover OpenAI | + +The source GUI grouping test exercises data grouping, not a rendered switch. Future browser evidence must show one manual row, toggle success, account-qualified row retention and native controls in the carried version. Use synthetic accounts; record build/head, DOM/API result and screenshot. The source PNG is prior evidence, not proof that the carried build works. + +## Docs additions beyond the source diff + +The source English API reference at `docs-site/src/content/docs/reference/management-api.md:194-204` is the public SoT. MODIFY each existing locale's `PUT /api/model-visibility` error cell to append `409 initial_model_selection_pending` and refresh/retry guidance; retain existing translated 400 text. Carry the manual-row paragraph's same semantics without changing API identifiers. Exact existing paths: + +- MODIFY `docs-site/src/content/docs/fr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ja/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ko/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ru/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/tr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/reference/management-api.md`. + +No GUI copy key is introduced by this patch. Do not alter unrelated locale content or the structure ownership table (`structure/05_gui-and-management-api.md:138`) which already names the correct owner. + +## Interphase dependencies and readiness + +010 establishes manual/native group identity consumed by 020's context controls and the later #3659 hide/delete layer. 020 is not mechanically dependent on this change, but must preserve 010's appended GUI test and API paragraph. Later #3659 shares `src/server/management/model-routes.ts`; port it after this visibility contract. Current dev changes since the source base do not touch any of these seven source paths. + +Live review thread `discussion_r3940553047` is resolved; source final tests include its account-qualified fixture. The out-of-diff 409 documentation request is also carried. No unresolved source review finding was returned. Contributor-reported passes are not our validation. Source Cross-platform CI run **33974042485** and React Doctor run **33974042542** were `action_required`; neither establishes passing product CI. Merge remains blocked on carried-head executed checks, independent review and valid GUI/docs evidence. + +## Pinned public source diff + +The following is the full textual base-to-head source patch (PNG retrieval is described above). Apply against current owners, not by copying entire stale source files. Plan amendments above add focused coverage/docs; keep them in this same phase. + +```diff +diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md +index 784c6e17f5..ae24513a97 100644 +--- a/docs-site/src/content/docs/reference/management-api.md ++++ b/docs-site/src/content/docs/reference/management-api.md +@@ -191,12 +191,20 @@ first and submit the returned digest. Prefer quarantine when recovery may be nee + | `GET /api/models` | Return the dashboard/CLI model rows | `catalog_busy` when gathering is saturated | + | `GET /api/client-config?client=...` | Build a read-only client config for any supported file integration | 400 unsupported client; 503 catalog unavailable | + | `PUT /api/disabled-models` | Replace the shared disabled-model list | 400 invalid JSON | +-| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body | ++| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body; 409 `initial_model_selection_pending` (refresh the model list and retry) | + | `GET, POST /api/custom-models` | List custom models or add one | 400 invalid fields; 404 provider missing; 409 duplicate model | + | `PUT, DELETE /api/custom-models/{id}` | Edit or delete one custom model | 400 invalid id/fields; 404 not found; 409 duplicate model | + | `GET, PUT /api/selected-models` | Read provider allowlists and availability, or replace one allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | + | `GET, PUT /api/model-presets` | Read preset summaries or choose preset/all/custom mode | 400 invalid mode or unsupported preset; 404 unknown provider; PUT 409 `initial_model_selection_pending` | + ++A manual model replaces the Models dashboard row with the same provider and model ID. ++For OpenAI, the manual row keeps `openai/` and supports the same visibility controls ++as other routed models; removing it restores the bare native dashboard row. Explicit ++account-qualified native rows stay separate. This does not rename bare native routes or ++change account entitlements. Non-native OpenAI visibility targets must match a configured ++manual model. ++ ++ + Valid PUT requests to `/api/selected-models` and `/api/model-presets` return HTTP 409 with code `initial_model_selection_pending` until a reliable initial model list is available. Refresh model discovery (for example, `GET /api/models`) and retry after it succeeds. + + ### OAuth accounts, provider keys, and data-plane keys +diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts +index a8d6ddc69c..3e24aaf459 100644 +--- a/gui/src/models-groups.ts ++++ b/gui/src/models-groups.ts +@@ -81,7 +81,8 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), +- nativeProviderGroup: providerRows.some(row => row.native === true), ++ nativeProviderGroup: providerRows.some(row => row.native === true) ++ || (provider === "openai" && configured?.authMode === "forward"), + liveModels: configured?.liveModels !== false, + configuredModels: configured?.models ?? [], + contextWindow: configured?.contextWindow, +diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts +index ffd27ad17f..14c2f2c6d8 100644 +--- a/gui/tests/models-native-group-controls.test.ts ++++ b/gui/tests/models-native-group-controls.test.ts +@@ -98,3 +98,9 @@ test("the native group exposes the context modal alongside the custom-model and + // The custom-add and cap controls no longer sit behind an isNative guard. + expect(src).not.toMatch(/\{!isNative && { ++ const groups = buildProviderModelGroups([customRow("gpt-5.5")], [{name:"openai",authMode:"forward"}]); ++ expect(groups[0]!.nativeProviderGroup).toBe(true); ++ expect(groups[0]!.native).toBe(false); ++}); +diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts +index e9ea26a90e..c3e9d58cf9 100644 +--- a/src/server/management/model-routes.ts ++++ b/src/server/management/model-routes.ts +@@ -566,7 +566,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise model.provider === provider && model.modelId === id); ++ if (!id || (native && (provider !== "openai" || !supportedNative.has(id))) ++ || (provider === "openai" && !native && !configuredOpenAiCustom)) { + return jsonResponse({ error: "invalid model visibility target" }, 400); + } + const key = `${native ? "native" : "routed"}:${id}`; +diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts +index 4a3fbeaa64..4635a9fbfd 100644 +--- a/src/server/management/model-rows.ts ++++ b/src/server/management/model-rows.ts +@@ -169,7 +169,11 @@ export async function listManagementModelRows( + ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), + }; + }).filter((row): row is ManagementModelRow => row !== null); +- const rows = [...native, ...dedupedRouted, ...visibleCustomModels]; ++ // Manual OpenAI rows retain their routed selector but replace the bare dashboard row. ++ // Account-qualified rows remain distinct, explicitly selected routes. ++ const visibleNative = native.filter(model => model.id.includes("/") ++ || !customNamespaced.has(routedSlug(model.provider, model.id))); ++ const rows = [...visibleNative, ...dedupedRouted, ...visibleCustomModels]; + // Include disabled rows and configured aliases before the export visibility filter: + // a hidden real `x--fast` must never become a synthetic selector for another model. + const knownIds = config.fastRows === false ? new Set() : knownEffortRowIds(config); +diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts +index 6259818667..15bc17f808 100644 +--- a/tests/codex-integration/model-visibility-management-api.test.ts ++++ b/tests/codex-integration/model-visibility-management-api.test.ts +@@ -1,5 +1,5 @@ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +-import { existsSync, mkdirSync} from "node:fs"; ++import { existsSync, mkdirSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { nativeModelRows } from "../../src/codex/catalog"; + import { loadConfig, saveConfig } from "../../src/config"; +@@ -7,6 +7,8 @@ import { handleManagementAPI } from "../../src/server/management-api"; + import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; + import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; + import { removeTreeWithRetry } from "../helpers/remove-tree"; ++import { ManagementRequest as Request } from "../helpers/management-auth"; ++import { listManagementModelRows } from "../../src/server/management/model-rows"; + + const TEST_DIR = join(import.meta.dir, `.tmp-model-visibility-management-${process.pid}`); + const previousOpencodexHome = process.env.OPENCODEX_HOME; +@@ -365,4 +367,77 @@ describe("atomic model visibility management", () => { + expect(loadConfig()).toEqual(before); + }); + }); +-import { ManagementRequest as Request } from "../helpers/management-auth"; ++ ++test("configured manual OpenAI rows can be toggled alongside native rows", async () => { ++ const config = loadConfig(); ++ config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; ++ config.customModels = [{id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}]; ++ config.disabledModels = ["openai/gpt-5.5", "gpt-5.4"]; ++ expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false}],enabled:true},config)).status).toBe(200); ++ expect(config.disabledModels).toEqual(["gpt-5.4"]); ++ expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false},{id:"gpt-5.4",native:true}],enabled:false},config)).status).toBe(200); ++ expect(config.disabledModels).toContain("openai/gpt-5.5"); ++ expect(config.disabledModels).toContain("gpt-5.4"); ++ expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"not-configured",native:false}],enabled:true},config)).status).toBe(400); ++}); ++ ++test("manual models replace management rows with the same provider/id and deletion restores natives", async () => { ++ const config = loadConfig(); ++ config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; ++ config.customModels = [ ++ {id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}, ++ {id:"manual-google",provider:"google-antigravity",modelId:"gemini-3.1-pro",contextWindow:128_000}, ++ ]; ++ config.codexAccountNamespaces = { desktop: "@main" }; ++ config.codexAccountPickerEnabled = true; ++ const accountModel = "gpt-5.5-account-fixture"; ++ const qualifiedId = `desktop/${accountModel}`; ++ writeFileSync(join(isolatedCodexHome!.path, "models_cache.json"), JSON.stringify({ ++ models: [{ ++ slug: accountModel, supported_in_api: true, visibility: "list", ++ base_instructions: "You are Codex.", comp_hash: null, shell_type: "unified_exec", ++ supported_reasoning_levels: [{ effort: "medium" }], model_messages: {}, ++ }], ++ })); ++ // Even an exact qualified-ID collision must preserve the account-bound native route. ++ config.customModels.push({ id: "manual-qualified", provider: "openai", modelId: qualifiedId }); ++ const rows = await listManagementModelRows(config,{entitlementWaitMs:0}); ++ expect(rows.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ ++ expect.objectContaining({namespaced:"openai/gpt-5.5",custom:true,customId:"manual-gpt",contextWindow:128_000,fastRowAvailable:true}), ++ ]); ++ expect(rows.filter(row=>row.provider==="google-antigravity" && row.id==="gemini-3.1-pro")).toHaveLength(1); ++ expect(rows.filter(row => row.id === qualifiedId && row.native)).toEqual([ ++ expect.objectContaining({ namespaced: qualifiedId, provider: "openai", native: true }), ++ ]); ++ config.disabledModels = ["openai/gpt-5.5"]; ++ const disabledRows = await listManagementModelRows(config, { entitlementWaitMs: 0 }); ++ expect(disabledRows.find(row => row.namespaced === "openai/gpt-5.5")).toMatchObject({ ++ custom: true, disabled: true, fastRowAvailable: false, ++ }); ++ config.disabledModels = []; ++ config.customModels = []; ++ const restored = await listManagementModelRows(config,{entitlementWaitMs:0}); ++ expect(restored.some(row => row.id === qualifiedId && row.native)).toBe(true); ++ expect(restored.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ ++ expect.objectContaining({namespaced:"gpt-5.5",native:true}), ++ ]); ++}); ++ ++test("manual OpenAI visibility preserves the pending-selection error contract", async () => { ++ const config = loadConfig(); ++ config.providers.openai = { ++ adapter: "openai-responses", authMode: "forward", liveModels: false, ++ baseUrl: "https://chatgpt.com/backend-api/codex", ++ initialModelSelection: { version: 1, registrationId: "11111111-1111-4111-8111-111111111111", status: "pending" }, ++ }; ++ config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; ++ const before = structuredClone(config); ++ for (const target of [{ id: "gpt-5.5", native: false }, { id: "not-configured", native: false }]) { ++ const response = await putWithConfig({ scope: "models", provider: "openai", targets: [target], enabled: true }, config); ++ expect(response.status).toBe(409); ++ expect(await response.json()).toMatchObject({ code: "initial_model_selection_pending" }); ++ expect(config).toEqual(before); ++ } ++ expect((await putWithConfig({ scope: "invalid", provider: "openai", targets: [], enabled: true }, config)).status).toBe(400); ++ expect(config).toEqual(before); ++}); +``` diff --git a/devlog/_plan/260906_lane_b_catalog_stack/011_visibility_build.md b/devlog/_plan/260906_lane_b_catalog_stack/011_visibility_build.md new file mode 100644 index 0000000000..987134d7ed --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/011_visibility_build.md @@ -0,0 +1,9 @@ +# Visibility carry build + +Replacement PR: #3685, branch `codex/lane-b-01-visibility`, source #3653 at `956eedac439922cf7645f130ef8432833e813a9a`. + +The complete final diff and binary screenshot were carried in `daee875fe` with Robin Bially as commit author and an explicit coauthor trailer. Translated API references now document manual/native identity and pending discovery rejection. Three additional regression cases cover mixed provider-group toggles, atomic invalid trailing targets and client-export replacement/disable/restoration. + +Independent production and management-boundary review of `53649bab..daee875f` returned PASS with no actionable findings. The reviewer traced authentication, ownership, pending-state ordering, atomic updates, row identity and native entitlement behavior. Added tests receive final independent review; hosted CI and isolated remote GUI/document checks remain pending at this build checkpoint. No local repository suite, typecheck or build was run. + +C/D evidence is recorded in session scratch and the goalplan ledger without editing the tested head while CI runs. The later landing record will publish the final verified SHA and closure outcome. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/020_context.md b/devlog/_plan/260906_lane_b_catalog_stack/020_context.md new file mode 100644 index 0000000000..71f9519a10 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/020_context.md @@ -0,0 +1,640 @@ +# 020 — Preserve selected provider context limits + +Status: planned; source-inspection only. Research captured 2026-09-05T16:34:39.759005+00:00. Local anchor: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Source: [PR #3654](https://github.com/lidge-jun/opencodex/pull/3654), issue [#3651](https://github.com/lidge-jun/opencodex/issues/3651). Source head `8facdb0d8c10109701015c0f6109fc67b1d9dd3c`, source base `0b7f60ee259bdd0e5c68b62936fe153af151e9dd`. Live GraphQL confirmed this head remains OPEN with zero unresolved threads on 2026-09-05 UTC (2026-09-06 KST). + +## Execution contract + +This is an implementation design for one later PABCD cycle, not an implementation receipt. The main agent owns the FSM, host goal, 000 roadmap, branch stack, publication, and merge. This delegated research made no production edits, ran no local tests/typecheck/build, and changed no refs. + +Loop archetype: spec-satisfaction repair. Trigger: the linked public issue and source PR. Verifier: exact carried-head GitHub CI plus targeted behavior evidence below. Stop: all activation scenarios accounted for, CI producers successful, author attribution retained, and merge commit proven reachable from dev. Expected outcomes: DONE after that evidence, NOOP only if current dev already implements the same behavior; otherwise retain explicit BLOCKED/NEEDS_HUMAN evidence without claiming completion. Upward escalation: main reclaims a slice after two distinct agents fail its packet; downward delegation requires a P-phase amendment. Resource and credential limits inherit the main lane-B 000 plan; this document authorizes no independent goal, workflow dispatch, deployment, or account change. + +The future executor must re-read the nearest src/GUI/docs AGENTS before code changes. No local tests, suites, typecheck, lint, builds, or dependency installation: CI is the execution verifier. Read-only `git apply --check` below checks textual portability only, not correctness. + +## CI evidence contract + +Source inspection at the recorded local HEAD establishes coverage, not passing execution: + +- `.github/workflows/ci.yml:182-201` selects runtime/tests/GUI changes; both source diffs select `ci` and `gui`. +- `ci.yml:255-316`: four Linux shards invoke `bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"`. `scripts/ci/run-bun-test-batches.sh:46-64,196-204` enumerates test files and excludes only the dedicated storage/API-usage families; the targeted files below are included. +- `ci.yml:422-428`: root TypeScript checks and `cd gui && bun test --isolate tests`; GUI lint/build at 416-420 and 442-446, privacy scan at 430-431. These are CI commands, never instructions to run locally. +- macOS test execution is at `ci.yml:532`; Windows is **dispatch-only** at `ci.yml:661-662` and runs six shards at 754. A normal PR check does not prove Windows test execution. Main must obtain appropriate exact-head dispatch evidence before claiming three-platform coverage. +- `ci.yml:917-933` permits skipped producers. Inspect actual test/gates job results and tested commit (including the PR merge ref and its head parent), not just aggregate `ci=success`. +- `.github/workflows/react-doctor.yml:15-18,46-57` scans PR changes in `gui` and blocks warnings. It is an additional review gate, not a replacement for GUI tests. +- `.github/workflows/deploy-docs.yml:3-10,25-32` builds docs only on main push or dispatch; normal PR CI has no Astro-docs build. Do not dispatch a deploying workflow merely to get validation. Main must arrange non-deploy hosted docs-build evidence or explicitly retain that verification gap. Local builds remain prohibited. + +Before merging a carried layer, refresh source/head/base/review status, inspect exact-head CI jobs and remaining findings, preserve coauthor credit through squash, and verify the resulting merge SHA is an ancestor of fetched dev. Only then close the superseded source PR and its resolved issue; a source PR carried through another PR is not automatically merged/closed. Retarget/rebase the next child onto dev after its parent lands; do not delete a parent branch while an open child still targets it. These are main-owned future actions. + +## Scope and caller proof + +C3 context-state contract; provider-removal integration receives the affected persistence review. Outcome: off → reload → on preserves an explicit cap such as 128,000 and does not force 922,000. Persist selection independently of activation. No new context-cap endpoint, automatic enable on read, changed account entitlement, arbitrary native-window expansion or redesign of the context modal. + +`gui/src/pages/Models.tsx:733-741` currently sends `NATIVE_GPT56_OPT_IN_WINDOW` when a native group is enabled. `src/providers/context-cap.ts:46-54` deletes active state on off and uses global value on every implicit enable. `src/server/management/provider-routes.ts:1399-1503` owns all three public request branches and refreshes live state/catalog after writes. `src/config.ts:1137` and `src/types/config.ts:607` carry only active limits. `src/codex/catalog/metadata.ts:301-304` exempts ordinary native windows from the 922k ceiling. + +Reuse `context-cap.ts` for two maps: `providerContextCaps` is the only active input; new `providerContextCapValues` remembers the last selection. `selectedProviderContextCaps` merges sanitized remembered values first, active values last. Existing `providerContextCap` (line 10) remains unchanged, so disabled selections never activate catalog capping. `nativeContextLimits` at `metadata.ts:242-263` reads only active caps; long-window opt-in at 278-299 retains per-model ceilings. `src/codex/catalog/provider-fetch.ts:626` keys discovery by active caps; remembered-only changes do not need a new runtime cache key. + +Provider removal/rename consumers are not optional: `providerEditorCandidate` at `provider-routes.ts:267-271`, editor adoption at 288-291, persisted editor callback at 849-852, direct deletion at 1381-1385, and `provider-id-rewrite.ts:113-124`. All must move/clear remembered state along with active state, without enabling it. + +## Exact source carry map + +| Operation | Path | +|---|---| +| NEW | `docs-site/public/screenshots/openai-context-cap-off.png` | +| NEW | `docs-site/public/screenshots/openai-context-cap-on.png` | +| MODIFY | `docs-site/src/content/docs/reference/management-api.md` | +| MODIFY | `gui/src/pages/Models.tsx` | +| MODIFY | `gui/src/pages/models-shared.ts` | +| MODIFY | `gui/tests/models-native-group-controls.test.ts` | +| MODIFY | `gui/tests/models-status-toast.test.tsx` | +| MODIFY | `src/codex/catalog/metadata.ts` | +| MODIFY | `src/config.ts` | +| MODIFY | `src/providers/context-cap.ts` | +| MODIFY | `src/providers/provider-id-rewrite.ts` | +| MODIFY | `src/server/management/provider-routes.ts` | +| MODIFY | `src/types/config.ts` | +| MODIFY | `tests/codex-integration/native-model-toggle.test.ts` | +| MODIFY | `tests/providers/provider-id-rewrite.test.ts` | +| MODIFY | `tests/server/management-provider-validation.test.ts` | + +All 14 textual source files were reviewed. Both PNGs are accounted for as evidence assets; blob identity verified but pixels not inspected. Existing tests are extended, so source carry requires no new test-layout registration. + +### Carry method and attribution + +Prefer a **final diff port** atop the 010 child branch. Actual commit metadata names **Robin Bially <7304732+RobinBially@users.noreply.github.com>** on original `216c11a4941e1b00dc8a069de4ab75128c5f8abf` and clarification `202028670b8f3ec8b8b51761a89cccae081b32a7`. Preserve both contributions with `Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com>` in the carry commit and eventual squash body. Merge commits brought in evolving dev; do not cherry-pick them blindly. The two non-merge commits can be considered for selective cherry-pick, but no cherry-pick was tested and the final delta must be compared to the pinned final PR diff. + +Read-only `git apply --check --exclude='*.png' .tmp/lane-b/3654.patch` returned 0 against the recorded HEAD, before 010 is applied. Cached text equals the local source base-to-head diff after normalizing abbreviated index-hash lines. This is not proof against the future stacked parent. Preserve dev's unrelated xAI changes: `src/config.ts:583-587` and provider creation/patch handling changed since the source base; port hunks rather than replacing those files. + +Binary source assets to carry from the pinned head (the cached patch omits payload): + +- `docs-site/public/screenshots/openai-context-cap-off.png`, blob `092f7377f9781e9ea6b52b04cee5b1069c59f9c6`. +- `docs-site/public/screenshots/openai-context-cap-on.png`, blob `204cbd24a72b0d37efe900cedc02f72d9515fb51`. + +## Implementation sequence within this phase + +1. Add the optional positive-integer map to OcxConfig and Zod config schema; no default map is required. Existing configs lacking it must retain active values via the merged selector. +2. Add selector/forget helpers and modify per-provider/global/all-provider mutations exactly as the source diff. Use existing top-level deletion provenance helper when a map empties. Keep active-only readers unchanged. +3. Extend rename handling to both fields and preserve collision reporting. Switch editor candidate, persisted editor and direct provider removal from disable to forget. Adopt remembered values back into live config after successful editor persistence. +4. Expose `values` on GET and every successful PUT response. Update the route comment at current `provider-routes.ts:1433-1436`: implicit enable restores remembered value, then global default; it no longer always chooses global. Retain existing validation and catalog-refresh branches. +5. Add GUI response/cache/state for `values`; old server/cache fallback is `values ?? caps ?? {}`. Remove the native-only forced enable value and display `active ?? remembered ?? global`. Keep the select present but disabled while cap is off; custom drafts use that same displayed selection. +6. Remove only the special 922k bypass for ordinary native windows in `metadata.ts`; keep `longWindowOptInCeiling`, provider/per-model overlay precedence, and supported ceiling clamps intact. +7. Carry source tests, fill the concrete branch-coverage gaps below, synchronize directly affected documentation, and obtain hosted CI/rendered evidence. Do not implement any later layer here. + +## Activation matrix and exact test changes + +| Scenario / trigger | Required observed result | Test owner | +|---|---|---| +| First enable, no saved selection, global 350k | active and returned selected value 350k | source-added `tests/server/management-provider-validation.test.ts` | +| Explicit 128k, off, reload, implicit on | no active map while off, persisted remembered 128k; returns to active 128k | same source test | +| Remembered 128k while off | `providerContextCap` undefined and native/routed catalog not narrowed by remembered map | extend same test to inspect off-state catalog, not only config | +| Active legacy config with no remembered map | selection response derives active value; first off records it; reload/on restores it | extend same test file with legacy active 128k fixture | +| Global value change without setAll | existing enabled and disabled choices unchanged; first-time provider gets new global | existing test at 4168-4189 plus remembered assertion | +| Enabled A=128k, disabled B remembers 256k, `{value:600000,setAll:true}` | A active/remembered 600k, B still disabled/remembered 256k; B later restores 256k | add explicit two-provider case near existing 4196-4202; source only exercises all-active case | +| Same initial state, `{setAll:true}` without value | every configured provider active and remembered at global; replaces B's 256k | extend existing 4212-4218 and source-added setAll case | +| `{setAll:false}` | all active caps removed, all selections retained | source test plus multi-provider extension | +| Invalid/mixed body, unknown provider, fractional value floors to zero | existing 400/404, no active or remembered mutation | extend existing negatives at 4243-4321 to snapshot both maps | +| Rename while disabled | remembered key moved, no active cap; destination collision preserved/reported | source `tests/providers/provider-id-rewrite.test.ts` + collision case for remembered map | +| Direct removal or editor removal | both maps lose removed ID after persisted reload and live adoption; other provider selections stay | add cases in `tests/server/management-provider-validation.test.ts` near existing delete/editor fixtures | +| GUI native OpenAI 128k off/on | display stays 128k, aria-pressed changes, request body contains only provider/enabled | source `gui/tests/models-status-toast.test.tsx` | +| GUI reload while disabled, old cached/server response without values | remembered 128k restored after reload; old shape falls back to caps/global without crashing | extend the same rendered test with remount and legacy-response fixtures | +| Cap=922k on gpt-5.4 | window becomes 922k, not 1M | source expectation update in `tests/codex-integration/native-model-toggle.test.ts:299` | +| Supported long window ceilings / narrower overlays | gpt-5.6-sol ≤922k, Astra ≤872k; gpt-5.5 remains 272k; smaller cap and model overlay win | retain/extend existing native toggle cases around 289-315, inspect `metadata.ts:289-304` | + +`gui/tests/models-native-group-controls.test.ts` is a source-oracle guard and cannot replace the rendered behavior test. The source GUI test does not remount while disabled, and the source backend test does not exercise a disabled provider through global setAll; those are explicit amendments, not already-proven coverage. Existing `tests/providers/context-cap-unknown-window.test.ts` remains relevant to active-only routed fallback. + +## Public documentation synchronization + +Source updates `docs-site/src/content/docs/reference/management-api.md:237` with both request shapes. Additional MODIFY paths are required because `docs-site/src/content/docs/reference/configuration/providers.md:31-32` currently contradicts that new API text and lacks the stored-selection field. Apply this exact English row contract: + +```diff +-| `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers a known context window. | ++| `providerContextCaps?` | `Record` | `{}` | Active provider context limits. Ordinary windows are lowered; native models with a supported long window can expand only up to their own supported ceiling. | ++| `providerContextCapValues?` | `Record` | `{}` | Last selected provider limits, retained while disabled. These values do not activate a cap. An enabled value takes precedence over a remembered value. | +-| `contextCapValue?` | `number` | `350000` | Default value used by the dashboard context-cap controls. Changing it applies the value to every routed provider — including providers without an existing `providerContextCaps` entry — only when "apply to every routed provider" is toggled on; otherwise each provider keeps its own cap. | ++| `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. | +``` + +MODIFY `docs-site/src/content/docs/guides/model-routing.md:94-97` by adding after the existing active-cap paragraph: “Switching a cap off retains its selection in `providerContextCapValues`; switching it on restores that selection. A remembered selection never applies a limit while disabled.” Keep the current enabled-only global-update wording. Keep the valid explicit 922k opt-in example in `reference/configuration/providers.md:87-91`; eliminating the switch's forced value does not remove explicit native opt-in support. + +Mirror these same key/default/activation semantics in the existing translated references and routing guides below; keep the source English identifiers verbatim and use the established locale prose. For API references append the source `caps`/`values` explanation and two concrete JSON payloads; for providers references replace the stale all-providers global-update claim, insert `providerContextCapValues`, and distinguish supported native ceilings. This is contract synchronization, not unrelated translation cleanup. + +- MODIFY `docs-site/src/content/docs/fr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/fr/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/fr/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/ja/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ja/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/ja/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/ko/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ko/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/ko/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/ru/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ru/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/ru/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/tr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/tr/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/tr/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/guides/model-routing.md`. + +No new GUI visible string is required. The API reference remains the public behavior SoT; `structure/05_gui-and-management-api.md:137` already names the owner and endpoint and needs no ownership rewrite. + +## Interphase dependencies and readiness + +Stack this as the next layer after 010. Preserve 010's manual-only OpenAI group identity, appended GUI test and manual-visibility documentation. Shared paths with 010: `gui/tests/models-native-group-controls.test.ts`, API references after translation sync. The #3571 layer later shares `src/types/config.ts`; #3659 later shares provider configuration docs. Lane A's proxy work can touch `src/config.ts`; main must reconcile that file rather than overwrite whole snapshots. + +Source review thread `discussion_r3940577476` is resolved; carry clarification commit `202028670b8f3ec8b8b51761a89cccae081b32a7` and both setAll meanings. Zero unresolved source review threads was returned; this does not waive independent carried-head review. Source Cross-platform CI run **33974043191** and React Doctor run **33974043207** were `action_required`. No product CI execution pass is established. The concrete remaining gates are hosted execution, branch-coverage additions, documentation parity and rendered carried-head off/reload/on evidence. + +Browser evidence must show chosen 128k, disabled 128k after reload, enabled 128k, corresponding request/response payloads, and ordinary-native 922k ceiling behavior. Label the off-state selector as the next-enable choice through the existing context-cap label; do not report the displayed value as an active window. Use the existing browser tooling on a CI/remote-built isolated surface; no local build or service mutation is authorized by this research packet. + +## Pinned public source diff + +Full textual source diff follows. Binary blobs are pinned above. Amend only the paths and behaviors explicitly named in this phase; do not replace entire files with stale source versions. + +```diff +diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md +index 784c6e17f5..133084ba93 100644 +--- a/docs-site/src/content/docs/reference/management-api.md ++++ b/docs-site/src/content/docs/reference/management-api.md +@@ -237,6 +237,18 @@ keys are not returned to dashboard clients. + | `GET, PUT /api/provider-context-caps` | Read or update global, all-provider, or one-provider context caps | 400 invalid request; 404 unknown provider | + | `GET /api/provider-presets` | Return GUI provider presets derived from the runtime registry | — | + ++The provider context-cap response includes `caps` (active limits) and `values` (last selected ++values, retained while disabled). Enabling a provider without `value` restores its selection, ++or uses the global `contextCapValue` on first enable. This also applies to OpenAI: the switch ++does not select a special 922k mode. An active cap bounds every native window; models with a ++supported long-context window may expand only up to their own supported ceiling. ++Updating the global value with `{ "value": 600000, "setAll": true }` changes only enabled ++provider caps; disabled providers keep their remembered selections when later enabled. ++In contrast, `{ "setAll": true }` without `value` enables every configured provider at the ++current global value, replacing their remembered selections. Turning a cap off does not ++activate its remembered value or erase the selection. ++ ++ + `provider_has_dependent_combos` is a safety barrier: remove or edit the dependent combos before + deleting their provider. + +diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx +index 91971cf54d..5b90f29a73 100644 +--- a/gui/src/pages/Models.tsx ++++ b/gui/src/pages/Models.tsx +@@ -51,8 +51,6 @@ import { + fmtK, + NATIVE_CAP_OPTIONS, + NATIVE_CAP_OPTION_SET, +- NATIVE_GPT56_DEFAULT_WINDOW, +- NATIVE_GPT56_OPT_IN_WINDOW, + PAGE, + readCollapsedProviders, + THREAD_OPTION_SET, +@@ -75,6 +73,7 @@ type CachedModelsPage = { + selectedModels: ProviderModelMap; + disabled: string[]; + contextCaps: Record; ++ contextCapValues?: Record; + contextCapValue: number; + }; + +@@ -213,6 +212,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const [search, setSearch] = useState>({}); + const [limit, setLimit] = useState>({}); + const [contextCaps, setContextCaps] = useState>(() => cached?.contextCaps ?? {}); ++ const [contextCapValues, setContextCapValues] = useState>(() => cached?.contextCapValues ?? {}); + const [contextCapValue, setContextCapValue] = useState(() => cached?.contextCapValue ?? 350_000); + const [customCap, setCustomCap] = useState(""); + const [showCustom, setShowCustom] = useState(false); +@@ -428,6 +428,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + selectedModels: selectionData, + disabled: [...nextDisabled], + contextCaps: capsData.caps ?? {}, ++ contextCapValues: capsData.values ?? capsData.caps ?? {}, + contextCapValue: nextCapValue, + } satisfies CachedModelsPage; + writeSessionListCache(cacheKey, next); +@@ -447,6 +448,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + setSelectedModels(next.selectedModels); + setContextCapValue(next.contextCapValue); + setContextCaps(next.contextCaps); ++ setContextCapValues(next.contextCapValues ?? next.contextCaps); + }, []); + + const catalogResource = useDataSurface( +@@ -722,7 +724,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + } + }; + +- const toggleProviderCap = async (provider: string, nativeGroup = false) => { ++ const toggleProviderCap = async (provider: string) => { + setBusy(true); + busyRef.current = true; + setStatus(""); +@@ -733,13 +735,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const r = await fetch(`${apiBase}/api/provider-context-caps`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, +- body: JSON.stringify(enabled && nativeGroup +- ? { provider, enabled, value: NATIVE_GPT56_OPT_IN_WINDOW } +- : { provider, enabled }), ++ body: JSON.stringify({ provider, enabled }), + }); + try { + const data = await readJsonOrThrow(r, t("models.capSaveFailed")); + setContextCaps(data?.caps ?? {}); ++ setContextCapValues(data?.values ?? data?.caps ?? {}); + setOk(true); + setStatus(t("models.capApplied")); + await load(true); +@@ -784,6 +785,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const data = await readJsonOrThrow(r, t("models.capSaveFailed")); + if (typeof data?.value === "number" && Number.isFinite(data.value) && data.value > 0) setContextCapValue(data.value); + setContextCaps(data?.caps ?? {}); ++ setContextCapValues(data?.values ?? data?.caps ?? {}); + setOk(true); + setStatus(t("models.capApplied")); + await load(true); +@@ -828,7 +830,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const onSelectProviderCap = (provider: string, raw: string) => { + if (raw === CUSTOM_OPTION) { + setProviderCapCustomOpen(prev => ({ ...prev, [provider]: true })); +- setProviderCapCustomDraft(prev => ({ ...prev, [provider]: String(contextCaps[provider] ?? contextCapValue) })); ++ setProviderCapCustomDraft(prev => ({ ...prev, [provider]: String(contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue) })); + return; + } + setProviderCapCustomOpen(prev => ({ ...prev, [provider]: false })); +@@ -1176,18 +1178,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const recentForProvider = modelDiscovery?.recentArrivals[provider] ?? []; + const recentIds = new Set(recentForProvider.map(row => row.id)); + const capOn = contextCaps[provider] !== undefined; +- const providerCap = contextCaps[provider] ?? contextCapValue; +- // With the cap off, `providerCap` is only the value a future toggle would apply — for the +- // native group that is the 350k default, which says nothing true about what Codex sees. +- // The honest number there is the largest window the rows actually advertise. +- const widestRowWindow = rows.reduce((widest, row) => { +- const window = typeof row.contextWindow === "number" && row.contextWindow > 0 ? row.contextWindow : undefined; +- if (window === undefined) return widest; +- return widest === undefined || window > widest ? window : widest; +- }, undefined); +- const capDisplayValue = capOn +- ? providerCap +- : (nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)); ++ // Show the value the next enable will actually use, including a remembered selection. ++ const capDisplayValue = contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue; + // The native group offers only the three windows GPT-5.6 actually has contracts for + // (272k live, 372k legacy, 1.05M measured); routed providers keep the generic ladder. + // The set has to follow the list, or a saved value outside it loses its option. +@@ -1348,7 +1340,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + screen-reader user was not told this governs the context window. + The number belongs to the adjacent Select, which is where a value + goes (020_control_affordances.md). */} +- toggleProviderCap(provider, nativeProviderGroup)} disabled={busy} label={t("models.contextCapLabel")} showLabel /> ++ toggleProviderCap(provider)} disabled={busy} label={t("models.contextCapLabel")} showLabel /> + {/* Always rendered, disabled when the cap is off. A cap-off provider used to + drop this control entirely, which is the defect the user reported: openai + showed 1.05M and anthropic showed nothing, so the two rows started at +diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts +index 1575a52ac9..fdc487301c 100644 +--- a/gui/src/pages/models-shared.ts ++++ b/gui/src/pages/models-shared.ts +@@ -56,6 +56,7 @@ export interface ProviderContextCapsResponse { + cap?: number; + value?: number; + caps?: Record; ++ values?: Record; + } + + export interface V2Status { +diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts +index ffd27ad17f..dbe0d68a3c 100644 +--- a/gui/tests/models-native-group-controls.test.ts ++++ b/gui/tests/models-native-group-controls.test.ts +@@ -72,14 +72,9 @@ test("every provider keeps its window readable with the cap switched off", async + // slot is occupied on every card (040_cap_cluster_and_occupied_slot.md), which makes the + // property this test protects strictly wider than it was. + expect(src).not.toContain("{(capOn || nativeProviderGroup) && ("); +- // With the cap off the stored value is only what a future toggle would apply — the 350k +- // default — so the display falls back to the widest window the rows actually advertise. +- // Matched as separate fragments because the expression is wrapped across lines now, and +- // it grew a native branch: with the cap off the native group shows its default window +- // rather than the widest advertised row. A single-line literal pinned the formatting +- // instead of the behaviour and broke on the reflow that introduced that branch. +- expect(src).toContain("const capDisplayValue = capOn"); +- expect(src).toContain("nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)"); ++ // The disabled select previews the persisted choice or global default used by enable. ++ expect(src).toContain("contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue"); ++ expect(src).not.toContain("value: NATIVE_GPT56_OPT_IN_WINDOW"); + // The select is inert until the cap is actually on: showing a number is not the same as + // offering to change one. + expect(src).toContain("disabled={busy || !capOn}"); +diff --git a/gui/tests/models-status-toast.test.tsx b/gui/tests/models-status-toast.test.tsx +index 29c902c2cb..5ecadb078e 100644 +--- a/gui/tests/models-status-toast.test.tsx ++++ b/gui/tests/models-status-toast.test.tsx +@@ -175,3 +175,40 @@ test("success toast expires after 6s and a repeated action re-arms it", async () + await fireTimers(6000); + expect(container.querySelector(".action-toast")).toBeNull(); + }); ++ ++test("OpenAI context switch restores the selected cap instead of forcing 922k", async () => { ++ testWindow.sessionStorage.clear(); ++ let caps: Record = {openai:128_000}; ++ const values = {openai:128_000}; ++ const bodies: unknown[] = []; ++ const fallback = globalThis.fetch; ++ globalThis.fetch = (async (input, init) => { ++ const url = String(input); ++ if (url.endsWith("/api/models")) return Response.json([ ++ {provider:"openai",id:"gpt-5.5",namespaced:"gpt-5.5",native:true,disabled:false,contextWindow:caps.openai??272_000}, ++ ]); ++ if (url.endsWith("/api/providers")) return Response.json([{name:"openai",authMode:"forward",liveModels:false}]); ++ if (url.endsWith("/api/provider-context-caps")) { ++ if (init?.method === "PUT") { ++ const body=JSON.parse(String(init.body)); bodies.push(body); ++ caps=body.enabled ? {openai:values.openai} : {}; ++ } ++ return Response.json({caps,values,value:350_000}); ++ } ++ return fallback(input,init); ++ }) as typeof fetch; ++ const { createRoot } = await import("react-dom/client"); ++ await act(async () => { root=createRoot(container); root.render(); }); ++ const settle=async()=>{await new Promise(resolve=>testWindow.setTimeout(resolve,0));}; ++ await act(settle); ++ const cluster=()=>container.querySelector(".models-cap-cluster")!; ++ const toggle=()=>cluster().querySelector("button.switch")!; ++ expect(cluster().textContent).toContain("128k"); ++ await act(async()=>{toggle().click();await settle();}); ++ expect(toggle().getAttribute("aria-pressed")).toBe("false"); ++ expect(cluster().textContent).toContain("128k"); ++ await act(async()=>{toggle().click();await settle();}); ++ expect(toggle().getAttribute("aria-pressed")).toBe("true"); ++ expect(cluster().textContent).toContain("128k"); ++ expect(bodies).toEqual([{provider:"openai",enabled:false},{provider:"openai",enabled:true}]); ++}); +diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts +index a50dd9469f..f239ce48b1 100644 +--- a/src/codex/catalog/metadata.ts ++++ b/src/codex/catalog/metadata.ts +@@ -299,8 +299,6 @@ function narrowToLimits(raw: number | undefined, slug: string, input: NativeCont + return overlay !== undefined && cap !== undefined ? Math.min(window, cap) : window; + } + const narrowed = overlay === undefined ? raw : Math.min(raw, overlay); +- // 922k is the GPT-5.6 1M opt-in, not a request to shrink gpt-5.4's 1M window. +- if (cap === NATIVE_GPT56_MAX_INPUT_TOKENS) return narrowed; + return applyProviderContextCap(narrowed, cap) ?? narrowed; + } + +diff --git a/src/config.ts b/src/config.ts +index d2b0bb707a..cd14e26b9c 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -1134,6 +1134,7 @@ const configSchema = z.object({ + subagentModels: z.array(z.string().min(1)).optional().catch(undefined), + clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), + providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), ++ providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(), + contextCapValue: z.number().int().positive().optional(), + multiAgentGuidanceEnabled: z.boolean().optional(), + // Invalid optional recovery config must not discard unrelated provider/account state. +diff --git a/src/providers/context-cap.ts b/src/providers/context-cap.ts +index d10807ced2..9dd10126ad 100644 +--- a/src/providers/context-cap.ts ++++ b/src/providers/context-cap.ts +@@ -43,13 +43,21 @@ export function globalContextCapValue(config: Pick + return isValidContextCap(value) ? Math.floor(value) : DEFAULT_PROVIDER_CONTEXT_CAP; + } + ++/** Active caps win over remembered values from an earlier switch-off. */ ++export function selectedProviderContextCaps(config: Pick): Record { ++ return { ...providerContextCaps({ providerContextCaps: config.providerContextCapValues }), ...providerContextCaps(config) }; ++} ++ + export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean, value?: number): void { + const next = providerContextCaps(config); ++ const selected = selectedProviderContextCaps(config); + if (enabled) { +- next[provider] = isValidContextCap(value) ? Math.floor(value) : globalContextCapValue(config); ++ next[provider] = isValidContextCap(value) ? Math.floor(value) : (selected[provider] ?? globalContextCapValue(config)); ++ selected[provider] = next[provider]; + } else { + delete next[provider]; + } ++ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; + if (Object.keys(next).length > 0) config.providerContextCaps = next; + else deleteConfigTopLevelKey(config, "providerContextCaps"); + } +@@ -66,18 +74,33 @@ export function setGlobalContextCapValue(config: OcxConfig, value: number, apply + if (!applyToAll) return; + const caps = providerContextCaps(config); + for (const provider of Object.keys(caps)) caps[provider] = next; +- if (Object.keys(caps).length > 0) config.providerContextCaps = caps; ++ if (Object.keys(caps).length > 0) { ++ config.providerContextCaps = caps; ++ config.providerContextCapValues = { ...selectedProviderContextCaps(config), ...caps }; ++ } + } + + /** Enable the cap for every named provider at the current value, or clear all caps. */ + export function setAllProviderContextCaps(config: OcxConfig, providerNames: string[], enabled: boolean): void { ++ const selected = selectedProviderContextCaps(config); + if (!enabled) { ++ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; + deleteConfigTopLevelKey(config, "providerContextCaps"); + return; + } + const value = globalContextCapValue(config); + const next: Record = {}; +- for (const name of providerNames) next[name] = value; ++ for (const name of providerNames) { next[name] = value; selected[name] = value; } ++ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; + if (Object.keys(next).length > 0) config.providerContextCaps = next; + else deleteConfigTopLevelKey(config, "providerContextCaps"); + } ++ ++/** Provider removal clears both the active limit and its remembered selection. */ ++export function forgetProviderContextCap(config: OcxConfig, provider: string): void { ++ setProviderContextCap(config, provider, false); ++ const values = { ...config.providerContextCapValues }; ++ delete values[provider]; ++ if (Object.keys(values).length > 0) config.providerContextCapValues = values; ++ else deleteConfigTopLevelKey(config, "providerContextCapValues"); ++} +diff --git a/src/providers/provider-id-rewrite.ts b/src/providers/provider-id-rewrite.ts +index 10a6f3f211..0111a8674a 100644 +--- a/src/providers/provider-id-rewrite.ts ++++ b/src/providers/provider-id-rewrite.ts +@@ -112,14 +112,16 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s + + // Keys. `providerContextCaps` is KEYED by provider id — a prefix rewrite would + // silently orphan the cap — and a destination key may already be occupied. +- const caps = config.providerContextCaps; +- if (caps && Object.hasOwn(caps, from)) { +- if (Object.hasOwn(caps, to)) { +- collisions.push(`providerContextCaps.${to}`); +- } else { +- caps[to] = caps[from]!; +- delete caps[from]; +- changed += 1; ++ for (const field of ["providerContextCaps", "providerContextCapValues"] as const) { ++ const caps = config[field]; ++ if (caps && Object.hasOwn(caps, from)) { ++ if (Object.hasOwn(caps, to)) { ++ collisions.push(`${field}.${to}`); ++ } else { ++ caps[to] = caps[from]!; ++ delete caps[from]; ++ changed += 1; ++ } + } + } + +diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts +index e26420e003..7b4e10dec6 100644 +--- a/src/server/management/provider-routes.ts ++++ b/src/server/management/provider-routes.ts +@@ -60,7 +60,7 @@ import { clearThreadAccountMap } from "../../codex/routing"; + import { primeCodexPoolQuotas } from "../../codex/auth-api"; + import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; + import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements"; +-import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; ++import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, selectedProviderContextCaps, forgetProviderContextCap, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; + import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; + import { resolveCodexHomeDir } from "../../codex/home"; + import { readUsageEntries } from "../../usage/log"; +@@ -267,7 +267,7 @@ function providerEditorCandidate( + candidate.providers = providers; + for (const name of removedProviders) { + dropProviderCustomModels(candidate, name); +- setProviderContextCap(candidate, name, false); ++ forgetProviderContextCap(candidate, name); + } + const validated = validateConfigCandidate(candidate); + if (!validated.ok) { +@@ -288,6 +288,8 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo + else live.customModels = structuredClone(persisted.customModels); + if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; + else live.providerContextCaps = structuredClone(persisted.providerContextCaps); ++ if (persisted.providerContextCapValues === undefined) delete live.providerContextCapValues; ++ else live.providerContextCapValues = structuredClone(persisted.providerContextCapValues); + if (persisted.disabledModels === undefined) delete live.disabledModels; + else live.disabledModels = [...persisted.disabledModels]; + if (persisted.modelDiscovery === undefined) delete live.modelDiscovery; +@@ -847,7 +849,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; ++ /** Last selected provider caps; retained while a cap is switched off. Not an active limit. */ ++ providerContextCapValues?: Record; + /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */ + contextCapValue?: number; + /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ +diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts +index 52044cde5a..eac9ec0960 100644 +--- a/tests/codex-integration/native-model-toggle.test.ts ++++ b/tests/codex-integration/native-model-toggle.test.ts +@@ -296,7 +296,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { + const over = nativeModelRows({ providerContextCaps: { openai: 2_000_000 } }); + expect(over.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); + expect(raised.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); +- expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(1_000_000); ++ expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(922_000); + }); + + test("nativeModelRows applies providerContextCaps.openai as a ceiling (#1430)", () => { +diff --git a/tests/providers/provider-id-rewrite.test.ts b/tests/providers/provider-id-rewrite.test.ts +index 1df8753b82..cc87ae55fc 100644 +--- a/tests/providers/provider-id-rewrite.test.ts ++++ b/tests/providers/provider-id-rewrite.test.ts +@@ -209,3 +209,10 @@ test("removal leaves the custom-model ownership marker untouched", () => { + legacyOwnedSlugs: ["agnes-ai/agnes-2.5-flash", "huggingface/DeepSeek-V4-Flash-0731"], + }); + }); ++ ++ test("moves remembered provider caps without activating them", () => { ++ const config = { providerContextCapValues: { [FROM]: 128_000 } } as unknown as OcxConfig; ++ expect(rewriteProviderReferences(config, FROM, TO)).toEqual({ changed: 1, collisions: [] }); ++ expect(config.providerContextCapValues).toEqual({ [TO]: 128_000 }); ++ expect(providerContextCap(config, TO)).toBeUndefined(); ++}); +diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts +index 35a7924ebe..36bf70be57 100644 +--- a/tests/server/management-provider-validation.test.ts ++++ b/tests/server/management-provider-validation.test.ts +@@ -4664,3 +4664,31 @@ describe("provider transport option management contract (#1668, #2816)", () => { + }); + }); + }); ++ ++test("OpenAI provider cap remembers an explicit window across off, reload, and on", async () => { ++ mkdirSync(TEST_DIR, { recursive: true }); ++ process.env.OPENCODEX_HOME = TEST_DIR; ++ let live: OcxConfig = { ++ port: 0, defaultProvider: "openai", contextCapValue: 350_000, ++ providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", liveModels: false } }, ++ }; ++ saveConfig(live); ++ const put = async (body: unknown) => { ++ const url = new URL("http://localhost/api/provider-context-caps"); ++ const response = await handleManagementAPI(new Request(url, {method:"PUT", headers:{"content-type":"application/json"}, body:JSON.stringify(body)}), url, live, {createManagementConvergeCodex:catalogConvergenceFactory()}); ++ expect(response?.status).toBe(200); ++ return response!.json(); ++ }; ++ expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:350_000}}); ++ await put({provider:"openai",enabled:true,value:128_000}); ++ expect(await put({provider:"openai",enabled:false})).toMatchObject({caps:{},values:{openai:128_000}}); ++ live = loadConfig(); ++ expect(live.providerContextCaps).toBeUndefined(); ++ expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:128_000}}); ++ const {nativeModelRows} = await import("../../src/codex/catalog"); ++ expect(nativeModelRows(live).filter(row=>row.contextWindow !== undefined).every(row=>row.contextWindow! <= 128_000)).toBe(true); ++ await put({setAll:false}); ++ expect(loadConfig().providerContextCapValues?.openai).toBe(128_000); ++ await put({setAll:true}); ++ expect(loadConfig().providerContextCaps?.openai).toBe(350_000); ++}); +``` + +## Consuming P refresh + +Source #3654 remains OPEN at 8facdb0d8c10109701015c0f6109fc67b1d9dd3c. Full binary-preserving patch applicability passes on verified visibility head e556cc9f7. The actual persistence field is providerContextCapValues. Preserve the prior visibility tests and all translated API paragraphs. A confirmed its config overlap is documentation-only; the executable proxy resolver changes live elsewhere. Owner admin steering and preparation-vs-merge gates are recorded in 000. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/021_context_build.md b/devlog/_plan/260906_lane_b_catalog_stack/021_context_build.md new file mode 100644 index 0000000000..3327f7e5b2 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/021_context_build.md @@ -0,0 +1,7 @@ +# Context selection carry build + +Replacement #3695 carries #3654 final head `8facdb0d8c10109701015c0f6109fc67b1d9dd3c` with Robin Bially's author identity, coauthor trailer, source screenshots and setAll clarification. The new `providerContextCapValues` map preserves inactive selections without applying them to catalog metadata. + +Additional regression cases cover both setAll payload shapes, legacy active-only reloads, invalid-request memory/disk atomicity, removal/editor cleanup, rename collision, disabled native budgets and GUI remount/old-response fallback. The source/security review identified a numeric-selection lookup defect for valid inherited property names; the carry now requires an own numeric remembered value and covers first-enable/off/reload/on for toString and valueOf. Final review and remote/hosted execution remain pending at this checkpoint. Public API, provider config and routing documentation are synchronized across existing locales. + +Parent #3685 completed full exact-head CI 33978686258 and independent code/security plus remote GUI/docs verification. It was admin-merged into dev as `9115b179a29f1366561139b8502cebb17bf816e9`; source #3653 and issue #3650 were immediately closed after ancestry proof. #3695 was retargeted to dev before parent merge to preserve the stack safely. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md new file mode 100644 index 0000000000..10d33d01fb --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md @@ -0,0 +1,679 @@ +# 030 — Preserve configured Go efforts and separate complete picker order + +Class: C3 cross-module catalog contract. One future PABCD cycle consumes this document after the model-toggle/context layers (010/020), before management (040). This cycle carries only #3571; message recovery #3568 is explicitly outside it. + +## Outcome and necessity + +Configured canonical `opencode-go` efforts survive generation and retained sync without injected max/ultra. A nonblank bare catalog id in `modelPickerOrder` opts into complete-picker display ordering; exact ids outrank raw/encoded equivalents. Routed-only and empty configurations retain legacy behavior. Display sorting must leave OpenCodex's natural-priority guidance candidates unchanged. Native Codex advertisements are a separate consumer and may follow the changed display order. Existing `applyReasoningLevels`, `slugEquivalenceKey`, `SPAWN_PRIORITY_FIELD`, and observed-state merge own these behaviors; reuse them, with no new catalog engine or provider roster. + +## Current owners and amendment anchors + +- `src/codex/catalog/sync.ts:315,358,412`: `deriveEntry` currently preserves exact combo/forward ladders, but Go uses ordinary synthetic tiers. Pass a separate `preserveExactReasoning` predicate to both derive branches; do not alter exact-combo metadata policy. +- `src/codex/catalog/effort.ts:223-247`: `preserveExact` already skips synthetic max/ultra insertion; retain this owner unchanged. +- `src/codex/catalog/sync.ts:518,654-668`: builder currently applies routed display ordering and records natural spawn priority. Reject whitespace-only entries consistently, without trimming significant ids or changing routed-only ordering. +- `src/codex/catalog/sync.ts:781,814`: add `modelPickerRank`, `applyFullModelPickerOrder`, optional order/selectors on `ObservedCatalogMergeInput`; retain the backwards-compatible wrapper at `sync.ts:1215` with empty defaults. +- `src/codex/convergence.ts:344` and `src/codex/catalog/sync.ts:1764`: both production merge callers must pass order and account selectors. A helper-only test is not proof of caller wiring. +- Retained native rows restore natural priority before recomputing featured priority; retained OCX routed rows rebuild featured rank with selector stride and reset obsolete display overrides. Apply full order only after native/routed admission and multi-agent version assignment. +- `src/codex/catalog/sync.ts:177-208`: `effectiveSubagentRoster` actually reads `opencodex_spawn_priority`, then visibility/v2 filters and the five-row cap. Inspect this consumer on every carry amendment, not only emitted priorities. +- `src/types/config.ts` documents `modelPickerOrder`; it has diverged since the source base and the previous context phase also touches this file. Apply only the comment delta, preserving the new context contract. + +## Focused implementation deltas + +The appendix contains the full pinned textual source delta, including complete NEW test files. Key reviewable boundaries are: + +```diff + const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); ++const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; +-applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); ++applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning); +``` + +Use the same predicate in the native-template branch, preserving `codexForwardNativeCapabilityAlias !== null`. Retained merge must independently exclude `opencode-go/` from mock-max insertion while preserving the existing Reserve and exact-combo exclusions. + +```diff + const mergedModels = mergeCatalogEntriesFromObservedState({ ++ modelPickerOrder, ++ accountSelectors, + catalogModels, +``` + +Repeat at retained sync. Complete ordering preserves `entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9`, records that natural value, and sets display priority to exact/equivalent rank or `pickerOrder.length + natural`; empty/routed-only input returns before mutation. The retained-row block must execute before final routed filtering/merge, using fresh `featured` and selector stride. Do not transplant the whole 1,800-line sync module. + +## Activation and regression matrix + +| Test owner | Activate | Required observation | +|---|---|---| +| NEW `tests/codex-integration/catalog-go-exact-efforts.test.ts` | Derive Go with null and native template, `[high,max]` and `[high,xhigh]`; merge both disk-only and fresh-only Muse | Exact effort/default ladders, no synthetic max for Muse; other provider still has max/ultra | +| NEW `tests/codex-integration/catalog-full-picker-order.test.ts` | Bare native id + Go routed ids, then apply twice | Specified complete display order; unchanged stored natural ranks and byte-equivalent repeated result | +| Same | Empty, whitespace-only, routed-only, raw slash upstream id plus encoded id | Legacy behavior; no whitespace activation; exact rank wins equivalence and no suffix aliasing | +| Same | Start full order, switch to empty/routed-only during provider outage; change featured order, promote/demote; zero/two selectors and nonzero picker index | Healthy and degraded rows agree on both display and spawn rank; second merge is stable; input snapshot unmutated | +| Same plus existing `codex-v2-gate.test.ts` | Change picker only while retaining configured subagent roster; use v2 eligibility | Same five OpenCodex guidance candidates and valid exact Go effort membership | +| Existing `tests/codex-integration/codex-catalog.test.ts` | Existing normalization/recovery fixtures | Existing native Reserve/exact ladders and account rows retain their contracts; align assertions only for intentional Go tier change | +| Existing `tests/test-layout.test.ts`, `tests/test-layout-tooling.test.ts` | NEW file registration | Both explicit layout map and expected fixture contain both file names in codex-integration | + +Source tests cover most matrix rows. Add a production-entry convergence/retained-sync assertion to the existing catalog tests if source tests only call the helper: configure order, run each entry under fixture IO, then compare displayed ids and `effectiveSubagentRoster` before/after. Use known fixture helpers, no live service. CI commands to select this family for a focused rerun are `bun test tests/codex-integration/catalog-full-picker-order.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts tests/codex-integration/codex-catalog.test.ts tests/codex-integration/codex-v2-gate.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` on a CI runner only; full required CI still applies. + +## Docs, dependencies, and unresolved acceptance + +- English and French `guides/model-ordering.md` must explicitly describe opt-in and migration: old lists containing previously ignored bare ids now change complete ordering. Do not introduce a pinned-native allowlist restriction: the public source contract deliberately allows new bare catalog ids. +- English provider reference adds Go efforts/config-key examples and a roster link. The source's dated endpoint claims are not independently provider-validated by this research; carry as configured examples or require fresh primary evidence before describing them as current supported roster. No provider requests are authorized here. +- Proposed SoT amendment, MODIFY `structure/03_catalog-and-subagents.md:35`: add: “Complete picker order is enabled by a nonblank bare id in modelPickerOrder. Display priority is independent of opencodex_spawn_priority; retained rows recompute natural ranks from the current featured roster and account-selector stride. Canonical opencode-go rows preserve configured reasoning ladders in generation and retained merges.” Main owns applying this documented delta in C. +- 020 → 030 shares `src/types/config.ts`; 030 → 040 shares `tests/codex-integration/codex-catalog.test.ts` and English provider reference. Coordinate one sequential integration owner; no recovery dependency on lane A's #3568. +- No dashboard JSX change in #3571. For functional UI evidence obtain isolated CI-produced catalog/model-list output and a Codex picker capture showing native-first order plus unchanged subagent list; an old author's local-release screenshot is not carried-head proof. No local build or live default service mutation. +- No newly established algorithm blocker in this read-only source review. Pending: exact-head CI, caller-level coverage, docs build gap, source-era bare-id warning disposition, and refreshed independent review. Source metadata has no guaranteed complete review-thread list; stale CodeRabbit prose is not an unresolved-thread verdict. + +## Source and baseline + +Read on 2026-09-06 KST in `/Users/jun/.codex/worktrees/f80e/opencodex` at `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Pinned source: [PR #3571](https://github.com/lidge-jun/opencodex/pull/3571), head `0a935c5694229760c8c1cd5a62072107d8ae6696`, source base `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. Inputs are captured `.tmp/lane-b/3571.json` and `.patch`; no claim of a fresh remote status check is made. `git show -s` independently confirmed the head commit author below. + +| Source commit | Actual commit author | Subject | +|---|---|---| +| `e57a57d5f0299fefd37d0f3d661e7b8d81afda1d` | voiys <matej2714@gmail.com> | fix(catalog): preserve Go efforts and support native-first picker order | +| `d745d8a417dc8b56372625b656bde778270ddf41` | voiys <matej2714@gmail.com> | fix(codex): reset retained picker order after provider outages | +| `90eaaddd815f5f70263fbfe90f4968c41856fa2a` | voiys <matej2714@gmail.com> | fix(codex): normalize picker orders and retain slug compatibility | +| `0a935c5694229760c8c1cd5a62072107d8ae6696` | voiys <matej2714@gmail.com> | fix(codex): refresh retained spawn ranks during discovery outages | + +Preserve original commit authors on a clean replay; for reimplementation or squash put `Co-authored-by: voiys ` in each carried logical commit and the final squash body. PR login alone is not an author trailer. + +Safe carry strategy: main revalidates pinned source/head and incoming parent; replay the complete reviewed source series in order or reproduce its exact delta with attribution. Preserve source follow-up commits, not only the initial feature commit. Publish a child against its still-open parent; after parent squash, rebuild the child on the new dev ancestry and re-run exact-head CI. Retarget surviving children before parent branch deletion. Push uses the user's authorized `--no-verify` to avoid local hooks; this does not substitute for CI. Once dev contains the complete carried behavior, close the superseded source PR with a carry reference; do not close it for a partial/default-only slice. No linked issue is invented. + +## Exact change ledger + +Every source changed file is accounted for below. “Same base” means a read-only `git hash-object` of current file bytes matches the patch's old blob prefix; it does not prove future cherry-pick cleanliness. “Drift” requires contextual reconciliation. Source binary is explicitly unreviewed. All textual hunks were inspected as source behavior; appendix preserves exact before/after, including complete NEW test content. No source production file was edited. + +| Operation | Exact path | Baseline / disposition | +|---|---|---| +| MODIFY | `docs-site/src/content/docs/fr/guides/model-ordering.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/guides/model-ordering.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/reference/configuration/providers.md` | Drift; preserve current unrelated edits | +| MODIFY | `scripts/test-layout/layout.json` | Same base; reviewed textual delta | +| MODIFY | `src/codex/catalog/sync.ts` | Same base; reviewed textual delta | +| MODIFY | `src/codex/convergence.ts` | Same base; reviewed textual delta | +| MODIFY | `src/types/config.ts` | Drift; preserve current unrelated edits | +| NEW | `tests/codex-integration/catalog-full-picker-order.test.ts` | New source file, absent locally | +| NEW | `tests/codex-integration/catalog-go-exact-efforts.test.ts` | New source file, absent locally | +| MODIFY | `tests/codex-integration/codex-catalog.test.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/codex-integration/codex-v2-gate.test.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/fixtures/test-layout-expected.json` | Same base; reviewed textual delta | +| MODIFY (planned SoT addition) | `structure/03_catalog-and-subagents.md` | Public contract prose delta specified above; not in source PR | + +## Execution boundary and verifier + +This is a docs-only roadmap deliverable, not an implementation or merge receipt. The main agent owns 000, the goal/FSM, branch operations, integration, and final acceptance. No local tests, suites, typecheck, builds, hooks, provider requests, commits, pushes, or GitHub writes were run by this researcher. The next implementation cycle must re-read this plan against its actual parent tip. + +Loop archetype: spec-satisfaction repair. Trigger: carry the pinned public source PR into lane B. Stop: required behaviors, exact-head CI, reviewer disposition and parent integration all have durable evidence. Expected result: DONE only after verified dev ancestry; NOOP only if equivalent behavior is already landed; unresolved correctness/CI evidence is pending, not DONE. Scope and unattended resource bounds are inherited from main's 000; this document does not arm or amend the goal. Upward escalation: return concrete caller/test evidence to main if the carry contract cannot be satisfied; additional worker dispatch requires main's planned scope. + +CI-only verifier, inspected at the baseline below: + +- `.github/workflows/ci.yml:5` uses unfiltered `pull_request`, so child PR bases are supported. `changes` at lines 181-218 admits `src/**`, `tests/**`, `scripts/**`, `gui/**`; source changes also admit packaging. +- Linux `test 1/4..4/4`, lines 255-316, invokes `scripts/ci/run-bun-test-batches.sh`. That runner enumerates `tests` (line 197), admits `.test.ts` files (lines 46-68), and excludes only storage/API-usage families into dedicated jobs; the catalog/management files in this plan are included. Each actual batch runs `bun test --isolate --timeout 60000` under a process timeout (lines 121-126). Read logs to prove the named files ran; aggregate green alone is insufficient. +- `gates`, lines 390-449, runs root TypeScript (`bun x tsc --noEmit`), GUI tests (`cd gui && bun test --isolate tests`), privacy scan and skill-surface check. GUI changes additionally run `bun run lint` and `bun run build`; `gui/package.json:8` expands build to `tsc -b && vite build`. GUI lint is `oxlint .`, including changed locale/UI inputs; the separately named `lint:i18n` script is not a dedicated CI step. +- macOS runs two full-suite shards (lines 451-547). Windows full-suite six shards (lines 658-769) run only on `workflow_dispatch` with lane `all`; do not infer Windows full-suite coverage from packaging smoke or PR aggregate success. Main must obtain an exact-ref dispatch if Windows full-suite evidence is required, then verify the run head. +- `.github/workflows/deploy-docs.yml:3-10,24-33` builds Astro only on main push or manual dispatch and then deploys. Normal PR CI has no Astro docs build. Do not trigger this deploy workflow merely to obtain a pre-merge check. Main must arrange an approved non-deploy CI verifier on the exact candidate commit or explicitly retain this as a readiness gap; this research does not add workflow code or authorize deployment. +- Save head SHA, parent/base SHA, run URL, executed job conclusions, named test logs, approvals and unresolved-thread disposition. Source-author reported passes are historical claims, not carried-head validation. Never attest local checks that were intentionally prohibited. + + +## Pinned public source delta + +Reconcile only the touched hunks at the implementation parent. The conceptual deltas above and acceptance amendments take precedence over copying this source verbatim. This appendix records public source-PR behavior only. + +````diff +diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md +index cac2b0667c..64408efa53 100644 +--- a/docs-site/src/content/docs/fr/guides/model-ordering.md ++++ b/docs-site/src/content/docs/fr/guides/model-ordering.md +@@ -23,7 +23,7 @@ priorités `i * N + j`, où `j` est la position du sélecteur en base zéro ; un + sont déplacées hors de ces groupes de sélecteurs. Codex continue de n’annoncer que les cinq premières + lignes visibles dans le sélecteur. + +-Les priorités sans sélecteur pertinentes sont : ++Sans ordre global du sélecteur, les priorités sans sélecteur pertinentes sont : + + | Entrée du catalogue | Priorité | Source | + | --- | --- : | --- | +@@ -134,8 +134,31 @@ au-delà de ce bloc mis en avant : + Les lignes routées indiquées apparaissent dans l’ordre configuré. Une ligne absente du tableau conserve sa + priorité normale et reste donc devant la bande d’affichage de `modelPickerOrder` ; indiquez toutes les + lignes routées dont vous souhaitez contrôler l’ordre relatif. Une ligne également présente dans +-`subagentModels` conserve sa priorité de mise en avant. `modelPickerOrder` ne réorganise ni les lignes +-natives non qualifiées ni celles qualifiées par un compte ; utilisez `subagentModels` pour celles-ci. ++`subagentModels` conserve sa priorité de mise en avant. Une liste contenant uniquement des identifiants ++routés conserve la position normale des lignes natives. ++ ++Pour ordonner tout le sélecteur, incluez un identifiant natif non qualifié : ++ ++```json ++{ ++ "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] ++} ++``` ++ ++Les lignes indiquées apparaissent d’abord dans l’ordre du tableau, puis les lignes absentes ++selon leur priorité naturelle. La correspondance est exacte : `gpt-5.6-sol` et ++`openai/gpt-5.6-sol` désignent deux lignes distinctes. Pour une ligne qualifiée par un compte, ++indiquez son identifiant complet, sélecteur inclus. Les formes brute et encodée du même ++identifiant routé sont acceptées, avec priorité aux correspondances exactes. Les entrées ++vides sont ignorées. ++ ++### Migration : identifiants natifs dans les listes existantes ++ ++Auparavant, les identifiants natifs dans `modelPickerOrder` étaient ignorés. Une liste ++existante contenant un identifiant natif non qualifié ordonne désormais tout le sélecteur, ++y compris les lignes mises en avant. Supprimez ces identifiants pour conserver l’ancien ++comportement limité aux lignes routées. Les listes absentes, vides ou uniquement routées ++conservent leur comportement ; les priorités des candidats sous-agents ne changent pas. + + `modelPickerOrder` ne modifie jamais l’ensemble des candidats de `spawn_agent`. Il change uniquement la + priorité visible par Codex dans le sélecteur, tandis qu’OpenCodex conserve la priorité naturelle de chaque +diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md +index 696f631a58..352c8ddb12 100644 +--- a/docs-site/src/content/docs/guides/model-ordering.md ++++ b/docs-site/src/content/docs/guides/model-ordering.md +@@ -23,7 +23,7 @@ priorities `i * N + j`, where `j` is the selector's zero-based position; a route + rows are moved outside those selector groups. Codex still advertises only the first five + picker-visible rows. + +-The relevant no-selector priorities are: ++Without complete-picker ordering, the relevant no-selector priorities are: + + | Catalog entry | Priority | Source | + | --- | ---: | --- | +@@ -133,8 +133,28 @@ featured block: + Listed routed rows appear in the configured order. A routed row omitted from the array keeps its + normal priority, so it remains ahead of the `modelPickerOrder` display band; list every routed row + whose relative position you want to control. A row also present in `subagentModels` keeps its +-featured priority. Bare native and account-qualified native rows are not reordered by +-`modelPickerOrder`; use `subagentModels` for those rows. ++featured priority. With a routed-only list, native rows keep their normal positions. ++ ++To order the complete picker, include a bare native id: ++ ++```json ++{ ++ "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] ++} ++``` ++ ++Listed rows appear first in array order, followed by unlisted rows in natural priority ++order. Matching uses exact catalog ids: `gpt-5.6-sol` and `openai/gpt-5.6-sol` are separate ++rows. Raw and encoded spellings of the same routed id are also accepted, with exact ++matches taking precedence. Empty entries are ignored. Account-qualified rows need ++their selector-qualified id in the list. ++ ++### Migration note: native ids in existing orders ++ ++Previously, native ids in `modelPickerOrder` were ignored. An existing list containing ++a bare native id now activates complete-picker ordering, including featured rows. ++Remove bare native ids to keep the previous routed-only behavior. Unset, empty and ++routed-only lists retain their behavior; subagent candidate priorities are unchanged. + + `modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the + Codex-visible picker priority while opencodex retains each moved row's natural priority for +diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md +index ab8a154ecb..24f175a09a 100644 +--- a/docs-site/src/content/docs/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/reference/configuration/providers.md +@@ -810,3 +810,21 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 + "visionSidecar": { "enabled": true } + } + ``` ++ ++ ++## OpenCode Go reasoning efforts ++ ++Go catalog rows preserve their configured reasoning efforts exactly, including during ++catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. ++Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted ++upstream values. Key these per-provider maps by upstream model ID, not the routed ++`opencode-go/` catalog slug. For example, Omen Alpha (`omen-alpha`) accepts `low`, `high`, ++and `max`; Muse Spark 1.3 Contributor (`muse-spark-1.3-contributor`) accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). ++See the [OpenCode Go model list](https://opencode.ai/docs/go/#models) for the current roster. ++A configured subset can exclude the lower tiers. Other providers retain their existing behavior. ++ ++For a native-first picker, include native ids in `modelPickerOrder` followed by the ++routed ids. This orders the complete picker while preserving the separate subagent ++candidate priorities. Routed-only orders keep their previous behavior. See the ++[ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). ++`modelDisplayNames` on a provider controls readable labels without changing wire ids. +diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json +index 29dd2c5f1c..7f3fa69999 100644 +--- a/scripts/test-layout/layout.json ++++ b/scripts/test-layout/layout.json +@@ -255,6 +255,8 @@ + "bun-stream-caps.test.ts": "lib", + "cancel-body-on-abort.test.ts": "server", + "catalog-cursor-search.test.ts": "codex-integration", ++ "catalog-full-picker-order.test.ts": "codex-integration", ++ "catalog-go-exact-efforts.test.ts": "codex-integration", + "catalog-input-modality-enum.test.ts": "codex-integration", + "catalog-llamacpp-capabilities.test.ts": "codex-integration", + "catalog-oauth-observation.test.ts": "codex-integration", +diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts +index 3f5f472baf..7b0be6e19b 100644 +--- a/src/codex/catalog/sync.ts ++++ b/src/codex/catalog/sync.ts +@@ -315,6 +315,8 @@ export function deriveEntry( + contextCap?: NativeContextLimitsInput, + ): RawEntry { + const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); ++ // Go exposes model-specific upstream enums; synthetic tiers mislead subagent overrides. ++ const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; + const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true + ? upstreamNativeEntry(model.id) + : null; +@@ -359,7 +361,7 @@ export function deriveEntry( + e, + model?.reasoningEfforts, + model?.defaultReasoningEffort, +- preserveExact || codexForwardNativeCapabilityAlias !== null, ++ preserveExactReasoning || codexForwardNativeCapabilityAlias !== null, + ); + // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned + // native tool/search/responses-lite contract while preserving the routed slug and wire id. +@@ -409,7 +411,7 @@ export function deriveEntry( + }; + if (isRouted) { + applyRoutedCodexToolMode(entry, model?.codexToolMode); +- applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); ++ applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning); + } + else { + applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); +@@ -518,7 +520,7 @@ export function buildCatalogEntriesFromObservedState({ + // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so + // this display reorder cannot change which rows are spawn candidates. + const pickerOrder = Array.isArray(modelPickerOrder) +- ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.length > 0) ++ ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; + const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); + const pickerOrderActive = pickerOrder.length > 0; +@@ -779,12 +781,33 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< + unsupportedNativeEntries: "drop", + }); + ++/** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ ++function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { ++ const exact = new Map(order.map((slug, index) => [slug, index])); ++ const equivalent = new Map(order.map((slug, index) => [slugEquivalenceKey(slug), index])); ++ return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); ++} ++ ++/** A picker order containing native ids orders the whole list, without changing spawn ranks. */ ++export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { ++ const pickerOrder = order.filter(slug => slug.trim().length > 0); ++ if (!pickerOrder.some(slug => !slug.includes("/"))) return; ++ const rankOf = modelPickerRank(pickerOrder); ++ for (const entry of entries) { ++ const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; ++ entry[SPAWN_PRIORITY_FIELD] = natural; ++ entry.priority = rankOf(String(entry.slug)) ?? pickerOrder.length + Number(natural); ++ } ++} ++ + export interface ObservedCatalogMergeInput { + readonly catalogModels: readonly RawEntry[]; + readonly baselineCatalogModels: readonly RawEntry[]; + readonly routedEntries: readonly RawEntry[]; + readonly baseline: ReadonlyMap; + readonly featured: readonly string[]; ++ readonly modelPickerOrder?: readonly string[]; ++ readonly accountSelectors?: readonly string[]; + readonly wsEnabled: boolean; + readonly template: RawEntry | null; + readonly disabledModels: ReadonlySet; +@@ -817,6 +840,8 @@ export function mergeCatalogEntriesFromObservedState({ + routedEntries, + baseline, + featured, ++ modelPickerOrder = [], ++ accountSelectors = [], + wsEnabled, + template, + disabledModels, +@@ -975,7 +1000,9 @@ export function mergeCatalogEntriesFromObservedState({ + finished.priority = nativePriority(slug, upstream.priority); + return finished; + } +- const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); ++ const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); ++ // Recompute spawn rank from current featured models, not a prior picker override. ++ delete preserved[SPAWN_PRIORITY_FIELD]; + // Older natives kept from disk still need the mock top tiers (max + ultra always + // for subagent max spawns; wire-clamped to the model's real top rung). + if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); +@@ -1060,6 +1087,32 @@ export function mergeCatalogEntriesFromObservedState({ + // remain outside provider ownership and survive unless a fresh row replaces their exact slug. + return !isOcxAuthoredRoutedEntry(entry); + }); ++ // Retained rows bypass the builder. Recompute managed spawn ranks from current config ++ // before either display-order mode; a saved display override is not current roster authority. ++ const pickerOrder = modelPickerOrder.filter(slug => slug.trim().length > 0); ++ const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); ++ const rankOf = modelPickerRank(pickerOrder); ++ const featuredRankOf = modelPickerRank(featured); ++ const priorityStride = Math.max(accountSelectors.length, 1); ++ for (const entry of preservedRoutedEntries) { ++ const natural = entry[SPAWN_PRIORITY_FIELD]; ++ if (typeof natural === "number") { ++ entry.priority = natural; ++ delete entry[SPAWN_PRIORITY_FIELD]; ++ } ++ const slug = String(entry.slug); ++ if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; ++ const featuredRank = featuredRankOf(slug); ++ entry.priority = featuredRank !== undefined ++ ? featuredRank * priorityStride ++ : (accountSelectors.length > 0 ? 1_000 : 0) + 5; ++ if (featuredRank !== undefined || fullPickerOrder) continue; ++ const pickerIndex = rankOf(slug); ++ if (pickerIndex !== undefined) { ++ entry[SPAWN_PRIORITY_FIELD] = entry.priority; ++ entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; ++ } ++ } + let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; + finalRoutedEntries = finalRoutedEntries.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; +@@ -1134,7 +1187,7 @@ export function mergeCatalogEntriesFromObservedState({ + // Mock-max universality (260709): preserved routed entries from disk may predate + // the max rung — ensure it here so subagent max spawns validate on every + // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. +- if (!exactCombo && !reserveProjection) { ++ if (!exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { + const levels = Array.isArray(e.supported_reasoning_levels) + ? e.supported_reasoning_levels as Array<{ effort?: string }> + : []; +@@ -1161,6 +1214,7 @@ export function mergeCatalogEntriesFromObservedState({ + multiAgentV2Enabled, + { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, + ); ++ applyFullModelPickerOrder(versionedEntries, modelPickerOrder); + for (const entry of versionedEntries) { + const kind = entry.opencodex_catalog_kind; + if (trustedAccountBoundNativeCatalogSlug(entry) === undefined +@@ -1762,6 +1816,8 @@ function writeRetainedCatalogSync({ + }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) + : []; + catalog.models = mergeCatalogEntriesFromObservedState({ ++ modelPickerOrder, ++ accountSelectors, + catalogModels: catalogModelsForMerge, + baselineCatalogModels: baselineCatalog?.models ?? [], + routedEntries: goEntries, +diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts +index df765a7853..8b30bb9eb2 100644 +--- a/src/codex/convergence.ts ++++ b/src/codex/convergence.ts +@@ -342,6 +342,8 @@ function prepareCatalog( + )), + ); + const mergedModels = mergeCatalogEntriesFromObservedState({ ++ modelPickerOrder, ++ accountSelectors, + catalogModels, + baselineCatalogModels, + routedEntries, +diff --git a/src/types/config.ts b/src/types/config.ts +index 8cf1246979..425a8e095a 100644 +--- a/src/types/config.ts ++++ b/src/types/config.ts +@@ -418,17 +418,14 @@ export interface OcxConfig { + /** One-time featured-roster upgrade marker; later user ordering is preserved. */ + subagentModelsVersion?: number; + /** +- * Optional full picker ordering for the Codex model catalog, independent of the +- * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of +- * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort +- * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog +- * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and +- * account-qualified native rows are not reordered (order native rows via `subagentModels`). +- * Listed routed rows appear in array order; rows not listed keep their normal display order. +- * `subagentModels`-featured rows keep their top position. When unset or empty, catalog +- * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent +- * candidate set is derived from each row's natural priority and is provably unaffected, even +- * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster). ++ * Display-only order for the Codex picker, independent of subagentModels. ++ * Routed-only lists order non-featured routed rows; featured and native rows keep ++ * their normal positions. Including a bare native id opts into ordering the complete ++ * picker: listed ids appear first in array order, followed by unlisted rows in their ++ * natural priority order. Exact catalog ids take precedence over equivalent raw/encoded ++ * routed ids; empty entries are ignored. The separate natural spawn ++ * priority is preserved, so display order does not change subagent candidates. ++ * Unset or empty leaves catalog priorities unchanged. + */ + modelPickerOrder?: string[]; + /** +diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts +new file mode 100644 +index 0000000000..1dbda27090 +--- /dev/null ++++ b/tests/codex-integration/catalog-full-picker-order.test.ts +@@ -0,0 +1,111 @@ ++import { routedSlug } from "../../src/providers/slug-codec"; ++import { expect, test } from "bun:test"; ++import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; ++ ++test("native-first picker order preserves Go subagent ranks and is repeatable", () => { ++ const rows: any[] = [ ++ { slug: "opencode-go/glm-5.3", priority: 0 }, ++ { slug: "gpt-5.6-sol", priority: 9 }, ++ { slug: "gpt-6-astra", priority: 9 }, ++ ]; ++ const order = ["gpt-6-astra", "gpt-5.6-sol", "opencode-go/glm-5.3"]; ++ applyFullModelPickerOrder(rows, order); ++ expect([...rows].sort((a,b) => a.priority-b.priority).map(r => r.slug)).toEqual(order); ++ expect(rows.map(r => r[SPAWN_PRIORITY_FIELD])).toEqual([0,9,9]); ++ const once = structuredClone(rows); ++ applyFullModelPickerOrder(rows, order); ++ expect(rows).toEqual(once); ++}); ++ ++test("existing routed-only ordering retains its behavior", () => { ++ const rows: any[] = [{ slug: "opencode-go/glm-5.3", priority: 1000 }]; ++ applyFullModelPickerOrder(rows, ["opencode-go/glm-5.3"]); ++ expect(rows).toEqual([{ slug: "opencode-go/glm-5.3", priority: 1000 }]); ++}); ++ ++ ++test("sync refreshes native spawn rank when featured models change", () => { ++ const sol = deriveEntry(null, "gpt-5.6-sol", "Sol", 105); ++ const order = ["gpt-5.6-sol"]; ++ applyFullModelPickerOrder([sol], order); ++ expect(sol[SPAWN_PRIORITY_FIELD]).toBe(105); ++ ++ const baseline = new Map([["gpt-5.6-sol", 9]]); ++ const promoted = mergeCatalogEntriesForSync([sol], [], baseline, ["gpt-5.6-sol"], false); ++ applyFullModelPickerOrder(promoted, order); ++ expect(promoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(0); ++ ++ const demoted = mergeCatalogEntriesForSync(promoted, [], baseline, ["opencode-go/glm-5.3"], false); ++ applyFullModelPickerOrder(demoted, order); ++ expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(101); ++}); ++ ++ ++test("bare native ids and routed slugs match exactly, without suffix aliases", () => { ++ const rows: any[] = [ ++ { slug: "openai/gpt-5.6-sol", priority: 2 }, ++ { slug: "gpt-5.6-sol", priority: 9 }, ++ { slug: "other/gpt-5.6-sol", priority: 3 }, ++ ]; ++ applyFullModelPickerOrder(rows, ["gpt-5.6-sol", "openai/gpt-5.6-sol"]); ++ expect(rows.map(row => row.priority)).toEqual([1, 0, 5]); ++ expect(rows.map(row => row[SPAWN_PRIORITY_FIELD])).toEqual([2, 9, 3]); ++}); ++ ++test.each([ ++ { order: [] as string[] }, ++ { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], after: ["opencode-go/glm-5.3"] }, ++ { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], before: ["opencode-go/glm-5.3"], after: [] }, ++ { order: ["gpt-5.6-sol", "opencode-go/team/model"], modelId: "team/model", before: ["other/model", "opencode-go/team/model"], after: ["opencode-go/team/model", "other/model"] }, ++ ++ { order: ["", "opencode-go/glm-5.3"] }, ++ { order: [" ", "opencode-go/glm-5.3"] }, ++ { order: [""] }, ++ { order: ["opencode-go/team/model"], modelId: "team/model" }, ++ { order: ["opencode-go/glm-5.3"] }, ++ { order: ["other/model", "opencode-go/glm-5.3"] }, ++])("degraded discovery refreshes ranks and remains stable for %j", ({ order, modelId = "glm-5.3", before = [], after = [] }) => { ++ for (const accountSelectors of [[], ["account-a", "account-b"]]) { ++ const slug = routedSlug("opencode-go", modelId); ++ const fresh = (modelPickerOrder: readonly string[], featured: readonly string[] = []) => buildCatalogEntriesFromObservedState({ ++ template: null, gptSlugs: [], ++ goModels: [{ id: modelId, provider: "opencode-go", displayName: "GLM 5.3", reasoningEfforts: ["high", "max"] }], ++ featured, modelPickerOrder, wsEnabled: false, multiAgentMode: "default", ++ exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), ++ disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, ++ }); ++ const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: readonly string[], degraded: boolean, featured: readonly string[] = []) => ++ mergeCatalogEntriesFromObservedState({ ++ catalogModels, routedEntries, modelPickerOrder, accountSelectors, ++ baselineCatalogModels: [], baseline: new Map(), featured, wsEnabled: false, ++ template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), ++ gatheredProviderNames: new Set(["opencode-go"]), ++ degradedProviderNames: new Set(degraded ? ["opencode-go"] : []), ++ legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, ++ exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true, ++ accountBoundEntries: [], ++ policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, ++ }); ++ const fullOrder = ["gpt-5.6-sol", slug]; ++ const previous = merge([], fresh(fullOrder, before), fullOrder, false, before); ++ const saved = structuredClone(previous); ++ const healthy = merge(previous, fresh(order, after), order, false, after); ++ const degraded = merge(previous, [], order, true, after); ++ const row = (entries: Record[]) => entries.find(entry => entry.slug === slug)!; ++ expect(row(degraded).priority).toBe(row(healthy).priority); ++ expect(row(degraded)[SPAWN_PRIORITY_FIELD]).toBe(row(healthy)[SPAWN_PRIORITY_FIELD]); ++ expect(merge(degraded, [], order, true, after)).toEqual(degraded); ++ expect(previous).toEqual(saved); ++ } ++}); ++ ++ ++test("full ordering ignores empty entries and accepts raw upstream ids with slashes", () => { ++ const slug = routedSlug("vendor", "team/model"); ++ const rows = [{ slug, priority: 1000 }, { slug: "gpt-5.6-sol", priority: 9 }]; ++ applyFullModelPickerOrder(rows, ["", "gpt-5.6-sol", "vendor/team/model"]); ++ expect(rows.map(row => row.priority)).toEqual([1, 0]); ++ const exact = [{ slug, priority: 5 }]; ++ applyFullModelPickerOrder(exact, ["gpt-5.6-sol", slug, "vendor/team/model"]); ++ expect(exact[0]!.priority).toBe(1); ++}); +diff --git a/tests/codex-integration/catalog-go-exact-efforts.test.ts b/tests/codex-integration/catalog-go-exact-efforts.test.ts +new file mode 100644 +index 0000000000..5fa4da816b +--- /dev/null ++++ b/tests/codex-integration/catalog-go-exact-efforts.test.ts +@@ -0,0 +1,39 @@ ++import { expect, test } from "bun:test"; ++import { deriveEntry, mergeCatalogEntriesForSync } from "../../src/codex/catalog/sync"; ++ ++for (const template of [null, { slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "ultra" }] }]) { ++ test(`Go preserves exact configured efforts (${template ? "template" : "fallback"})`, () => { ++ for (const [id, efforts] of [ ++ ["glm-5.3", ["high", "max"]], ++ ["glm-5.3-flash", ["high", "max"]], ++ ["omen-alpha", ["high", "max"]], ++ ["deepseek-v4-flash-vision-exp", ["high", "max"]], ++ ["muse-spark-1.3-contributor", ["high", "xhigh"]], ++ ] as const) { ++ const entry = deriveEntry(template, `opencode-go/${id}`, "Go", 1, { ++ provider: "opencode-go", id, reasoningEfforts: [...efforts], defaultReasoningEffort: efforts[1], ++ }); ++ expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual([...efforts]); ++ expect(entry.default_reasoning_level).toBe(efforts[1]); ++ } ++ }); ++} ++ ++test("other providers retain their existing virtual tiers", () => { ++ const entry = deriveEntry(null, "other/model", "Other", 1, { ++ provider: "other", id: "model", reasoningEfforts: ["high"], ++ }); ++ expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "max", "ultra"]); ++}); ++ ++test("sync does not reintroduce max for Muse", () => { ++ const muse = deriveEntry(null, "opencode-go/muse-spark-1.3-contributor", "Muse", 1, { ++ provider: "opencode-go", id: "muse-spark-1.3-contributor", ++ reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", ++ }); ++ for (const [disk, fresh] of [[[muse], []], [[], [muse]]]) { ++ const entries = mergeCatalogEntriesForSync(disk, fresh, new Map(), [], false); ++ const entry = entries.find(e => e.slug === muse.slug)!; ++ expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "xhigh"]); ++ } ++}); +diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts +index 37d8c69798..cc394a548d 100644 +--- a/tests/codex-integration/codex-catalog.test.ts ++++ b/tests/codex-integration/codex-catalog.test.ts +@@ -5445,11 +5445,11 @@ describe("Codex catalog routed normalization", () => { + const expected = [ + { slug: "deepseek/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, + { slug: "deepseek/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, +- { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, +- { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, +- { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, +- { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, +- { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, ++ { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max"] }, ++ { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max"] }, ++ { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max"] }, ++ { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max"] }, ++ { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "zai/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zai/glm-5.2[1m]", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zhipu-bigmodel/glm-4.6", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, +diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts +index 8d3e2d9dc5..6e8b6a18c1 100644 +--- a/tests/codex-integration/codex-v2-gate.test.ts ++++ b/tests/codex-integration/codex-v2-gate.test.ts +@@ -100,14 +100,13 @@ function installModeHintRuntime(supported = true): string { + describe("catalog ultra (always-on)", () => { + const routed = [{ id: "glm-5.2", provider: "opencode-go", reasoningEfforts: ["low", "medium", "high", "xhigh"] }]; + +- test("routed + old natives always advertise mock max AND ultra", () => { ++ test("Go keeps declared efforts while old natives retain mock tiers", () => { + const entries = buildCatalogEntries(template(), ["gpt-5.5"], routed as never, [], false); + const native = entries.find(e => e.slug === "gpt-5.5")!; + const glm = entries.find(e => e.slug === "opencode-go/glm-5.2")!; + expect(efforts(native)).toContain("ultra"); + expect(efforts(native)).toContain("max"); +- expect(efforts(glm)).toContain("ultra"); +- expect(efforts(glm)).toContain("max"); // mock max: adapters/wire clamp keep it honest ++ expect(efforts(glm)).toEqual(["low", "medium", "high", "xhigh"]); + }); + + test("gpt-5.6-sol keeps native ultra + max; luna has max but no native ultra (upstream ladder)", () => { +diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json +index 114c699eaf..8dba5cfb55 100644 +--- a/tests/fixtures/test-layout-expected.json ++++ b/tests/fixtures/test-layout-expected.json +@@ -92,6 +92,8 @@ + "bun-stream-caps.test.ts": "lib", + "cancel-body-on-abort.test.ts": "server", + "catalog-cursor-search.test.ts": "codex-integration", ++ "catalog-full-picker-order.test.ts": "codex-integration", ++ "catalog-go-exact-efforts.test.ts": "codex-integration", + "catalog-input-modality-enum.test.ts": "codex-integration", + "catalog-llamacpp-capabilities.test.ts": "codex-integration", + "catalog-oauth-observation.test.ts": "codex-integration", + +```` + +## Consuming P refresh + +Parent preparation head is 29f98462c4a63cf217347c26668733169fd65736. Source #3571 remains OPEN at 0a935c5694229760c8c1cd5a62072107d8ae6696, and its full patch passes applicability on this parent. All four non-merge source commits identify voiys . The existing modelPickerOrder field survives config loading through the established root passthrough schema; no new persistence field is introduced. Preserve providerContextCapValues from 020. + +The initial roadmap listed source English/French edits, but six other existing model-ordering guides also contain the legacy native-order contract. MODIFY docs-site/src/content/docs/{ja,ko,ru,tr,zh-cn,zh-tw}/guides/model-ordering.md with the same complete-order opt-in, exact/equivalent matching, unchanged spawn roster and existing-list migration warning. Do not create new locales or alter unrelated routing semantics. The runtime/template output remains separately verified from any native client capture; a synthetic rendering must never be described as an actual client capture. + +Delegation: main carries the final source diff and owns SoT/commits; catalog worker supplies caller-level coverage and a captured generated-list comparison; docs worker owns the six translated guides; independent code reviewer checks priorities/retained paths; remote verifier uses isolated exact-head tests/docs plus a native client capture if the installed client can be run safely with synthetic state. No local test/build/typecheck and no real personal proxy/account calls. Final merge gates remain unchanged. + +## C evidence-driven contract clarification + +The independent native-consumer audit distinguishes three concepts: OpenCodex natural-priority guidance (must remain unchanged), native advertised five (can follow changed display priority), and exact-name override eligibility (not restricted to the advertised five). This preserves the already-recorded #1649 design while correcting the earlier unqualified wording. No wire rewriting or native-client patch is added. The source appendix above remains an immutable record of the original PR and is not a current universal native-advertisement guarantee. + +Native source d2d5b702 (local upstream checkout, not claimed to match binary0.153.4) shows both V1/exposedV2 using native priority; current valid generated before/after data demonstrates the expected displacement. The actual0.153.4 capture proves picker/data consumption only until a separate toolspec capture is obtained. V1 has no OCX preferred-roster injection; V2 guidance is conditional on catalog state. New production-writer fixture failures remain blockers for the natural-guidance criterion and cannot be waived by this wording correction. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md b/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md new file mode 100644 index 0000000000..7f68b1ca11 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md @@ -0,0 +1,9 @@ +# Ordering carry build + +Replacement #3700 carries all four source #3571 commits through `0a935c5694229760c8c1cd5a62072107d8ae6696`, retaining voiys as author and coauthor. It preserves configured canonical OpenCode Go ladders in generation/retention and separates full-picker display order from natural spawn priority. + +Production-writer tests cover both convergence and retained sync, healthy/outage equivalence, refreshed featured ranks, idempotence and the same five eligible candidates. Source review found that the new merge paths lacked the builder's runtime normalization for the existing passthrough modelPickerOrder field. All three boundaries now share the same nonarray/nonstring/blank filtering while preserving significant ID spelling. Malformed-input production-writer cases and remote causal checks verify that repair. English/French source documentation is synchronized with the six other existing ordering guides and the catalog SoT. + +Parent #3695 was admin-merged on dev as `ab6762bdb35db24efbe1ceac77a1f9e5e6139616` after every actual CI producer succeeded. The aggregation-only ci job was still queued and explicitly recorded as an owner-authorized administrative exception; no actual test was bypassed. Independent reviews and remote backend/component/typecheck/docs/browser/red-green evidence passed. Source #3654 and issue #3651 were closed after dev ancestry proof, and #3700 was safely retargeted to dev. + +Final ordering review and exact-head remote/hosted execution are pending at this checkpoint. No local repository tests, typechecks or builds were run. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md b/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md new file mode 100644 index 0000000000..1d1538cf4e --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md @@ -0,0 +1,11 @@ +# Ordering check repair + +The first remote check of 1c2616bfd failed 14 new production-writer cases; no failing result was treated as a pass. Investigation separated fixture isolation from a production defect. + +The fixture now provides a runnable deterministic Codex command through forced refresh, asserts runtime identity, uses the current featured-roster migration marker, and checks effort arrays without mutating metadata. Full catalog equality and the same five OpenCodex guidance candidates remain required. + +Fresh row derivation could copy opencodex_spawn_priority from a previously ordered native template. Assigning a new featured priority did not replace that inherited private rank, so repeated healthy writes could change the guidance window. Fresh clones now clear that previous row's private marker; retained-row markers and reader behavior are unchanged. Direct dirty-template and repeated real-writer regressions cover the cause. Remote causal confirmation and reruns are required before closing this repair. + +The native-consumer audit also corrected an overbroad explanation: OpenCodex natural-priority guidance and native Codex's advertised five are separate. Native advertisement may follow display priority on V1 and exposed V2; exact-name override eligibility is not limited to that advertisement. This clarification preserves the existing #1649 design and does not waive the failing natural-guidance assertions. Current code comments, configuration reference and eight ordering guides now make the distinction explicit; the original source-diff appendix remains historical evidence. + +No local tests, builds or typechecks were run. Verification must use the repaired committed head and retain red/green, runtime identity and teardown evidence. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md b/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md new file mode 100644 index 0000000000..ea2d9e7110 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md @@ -0,0 +1,7 @@ +# Matched retained-discovery control + +After the template-rank correction, the direct regression and repeated production-writer guidance cases passed remotely. Ten malformed-order cases still compared a static healthy catalog (14 rows in that snapshot) against a live/degraded catalog (36 rows). Maintained Go metadata augmentation is skipped for liveModels:false and enabled for liveModels:true, so changing that setting admitted additional rows independently of picker-order validity. + +The test now restores identical catalog/cache bytes before a valid-filtered retained control and a malformed retained run. Both use the same live/empty-model/failure settings; only picker order differs. It still compares complete model arrays, the full guidance roster and all original fixture models' exact effort ladders. Healthy valid-versus-malformed equality is retained. No registry model count is hardcoded and no production fallback behavior is changed. + +Previous failed outputs remain evidence. The revised counterfactual requires an exact-head remote rerun before a success claim. No local tests were run. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md b/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md new file mode 100644 index 0000000000..4b220a619d --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md @@ -0,0 +1,3 @@ +# Verification follow-up + +The ordering CI run reported an unrelated Lab supervision test failure. A bounded verification prerequisite is reviewed separately from the catalog change. Detailed pre-publication analysis and the implementation plan remain in ignored scratch under the repository security-working-note policy. Product limits and existing assertions are not relaxed. The original ordering branch and failed outputs remain preserved; no success is claimed at this planning checkpoint. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/035_ordering_landing.md b/devlog/_plan/260906_lane_b_catalog_stack/035_ordering_landing.md new file mode 100644 index 0000000000..1149e41f05 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/035_ordering_landing.md @@ -0,0 +1,25 @@ +# 035 — Ordering and verification prerequisite landed + +PR #3700 landed with an admin merge at `76356176c86aa123220c82b65321453e81897405`. +Its tree `f1950aecabdb3b73dbb4bdea18a845b27da70222` matches the tested GitHub merge +candidate. Both ordering head `e59b730b1` and Lab prerequisite head `8b5dbde02` +were verified as ancestors of dev. GitHub automatically marked #3713 merged. + +Source #3571 was closed immediately after that proof. Its refreshed head +`09acfba64596011c308f0d9cbac070123bb9faeb` rebases the carried source: eight of +twelve feature-file blobs are identical; four differences are established dev +changes, and the three rewritten follow-ups have matching stable patch IDs. +The source author and `Co-authored-by: voiys ` are retained. + +- Exact source-head [CI 33989738843](https://github.com/lidge-jun/opencodex/actions/runs/33989738843) + completed successfully, including Linux four shards, macOS two shards and all producers. +- Remote merge candidate `33ed4751` passed the canonical full suite: 19,606 pass, + 0 fail, 15 skip, plus build, typecheck and privacy checks. +- After the intervening Logs landing, candidate `88a67043` passed all dashboard + tests, lint/i18n/build, five affected root test files, typecheck, privacy and docs + build. Catalog, Lab, adapters and dependencies match the full-suite baseline. +- The earlier standalone prerequisite run is retained as cancelled: its macOS + log stopped at the client-connect file before Lab execution. It is not called green. + +Independent source, contract and security reviews passed. The ordering PABCD +cycle is closed; model management and Fable remain separate incomplete work phases. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/040_management.md b/devlog/_plan/260906_lane_b_catalog_stack/040_management.md new file mode 100644 index 0000000000..7fbc46047b --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/040_management.md @@ -0,0 +1,1299 @@ +# 040 — Provider model deletion, visibility and static default-only sync + +Class C3 with C4 review for model identity and management mutations. Active P baseline: `67fdf24eb6e661f4d9e84aaa86a4eb39c6f3ba58` (2026-09-06 KST), including the verified ordering/Lab landing `76356176c` and D Cursor description preservation. Source #3659 remains OPEN at `ff4e5cd5352b9c1bd05e3de0091f3483ca130be5`; all five original commits are by gqchen <276851182@qq.com>. This active contract supersedes conflicting proposals in the historical source appendix. The source patch authority is the actual merge base `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`; target snapshot `af50c6d3` is not a valid substitute. Current independent preparation found no need for a new endpoint, store, dependency or visual redesign. + +The previous ordering cycle is complete. The new implementation stays in bound f80e on `codex/lane-b-04-management`. User authorization includes no-verify pushes, admin merges and immediate source closure after verified dev inclusion. No local tests, suites, typechecks or builds; execution is isolated remote or hosted CI. No release/deployment, global account or service changes. + +## Accepted behavior + +Delete removes one stored custom definition by stable ID. It sends exactly one DELETE and never an automatic visibility PUT. A native or discovered counterpart can return and keep the inventory count unchanged. Hide sends exactly one visibility PUT using a confirmed server row. Add saves a definition and preserves independent visibility/selection policy. No permanent browser tombstone survives a refresh. The existing Models page is the recovery surface for hidden rows, including when the provider tab is empty. + +The frontend consumes existing /api/models DTOs once per parent refresh alongside the full /api/selected-models response. It does not add disabled to selected-models or introduce another identity classifier. Existing full available, selected and liveModelCounts retain their meanings. The only production backend change is the source static-default seed plus any narrowly demonstrated regression repair approved in this cycle. + +## Planned interfaces and owners + +- Reuse type-only ModelRow from gui/src/pages/models-shared.ts. Add a strict row-array boundary parser/grouping beside current provider-workspace helpers, with dedicated model-inventory module if size warrants. Validate nonblank provider/id/namespaced, boolean disabled and optional native/custom/pending flags, plus nonblank customId for custom rows. Invalid destructive identity fails the whole refresh. Preserve raw strings; use Map/null-prototype records and namespaced uniqueness. +- ProviderWorkspaceShell owns one paired read per refresh epoch. Adopt both successful responses together; do not claim cross-endpoint transactional consistency. Maintain a current refresh key/revision and the revision of the adopted snapshot. Invalidate readiness immediately on retry, external refresh or mutation reconciliation; deferred effect loading alone is insufficient. Keep cancellation/generation rejection and use existing bounded fetch conventions. +- Pass modelRows: ModelRow[] | null and refresh revision/readiness through DetailSlotData, Providers, ProviderDetails and keyed ProviderModels. null is unavailable; [] is a successful empty projection. Add onOpenModels from Providers using existing navigateHash("models"). +- ProviderModels refreshes its full custom-definition GET whenever the parent revision changes, and records the successful ownership revision. Controls require a current row snapshot and current custom ownership, no load error, no pending mutation and matching customId/provider/modelId for Delete. Refetch both resources after success, failure or an ambiguous response. Successful ownership GET must not erase unresolved mutation feedback. +- On confirmed snapshots, render non-disabled DTOs; key chips, copy and busy state by namespaced. Identical raw labels with distinct selectors are disambiguated using namespaced. An unavailable snapshot may retain old/read-only fallback data; successful empty must not insert configured/default/native rows. Pending/unknown identity has no mutation action. +- Rail count uses the full unique non-disabled DTO inventory before query or render cap. Detail search/truncation count is separately understood. An allowlist badge does not change inventory count, and native selection does not borrow a routed same-raw-id badge. Full available/provenance stay separate. +- Delete custom records only; other confirmed rows Hide with row.provider, row.id and row.native === true. Block both handlers and all buttons on the shared busy/readiness condition. Do not infer native from provider name or substitute Hide when custom ownership is unavailable. +- Preserve existing Add duplicate/encoded-collision checks using full raw configured/discovered/custom inputs. Do not newly reject a valid native override solely because a native DTO has the same raw id. Existing hidden custom definitions remain duplicates and use Models to restore visibility. Validate POST 201 identity and stable ID before adoption. Saved, saved-but-hidden, refresh-pending and unconfirmed-save outcomes are distinct; no automatic POST retry or implicit unhide. + +## Source carry disposition + +Preserve gqchen <276851182@qq.com> in the carry commit and Co-authored-by trailer. Carry the static default-only patch, source UI controls/icons/locales and docs with adaptation. Omit the source selected-models disabled-map API hunk and its redundant parser/prop chain. Replace source Delete-then-Hide and raw-ID tombstones with the contract above. Preserve the complete historical source appendix as evidence, explicitly superseded where it conflicts with this amendment. Refresh the screenshot from the actual amended UI; the source image is historical. + +## Verification required before completion + +- GUI exact request counts for cancel/Delete/Hide; custom-only delete/re-add/remount; native and discovered replacement after Delete; raw/encoded and account-qualified collisions; invalid DTO/native/custom metadata; pending rows; failed custom GET; three-resource refresh readiness with reversed responses, external custom-ID replacement and provider switch; malformed/ambiguous POST reconciliation without repeat writes; independent hidden/allowlist state preserved; empty and 300+ inventories; recovery link visible in empty state; counts match the canonical pre-search inventory. +- Actual management DELETE→GET round trips, preserving native/routed/account hides and existing validation. No new management write semantics or relaxation. +- Static omitted/empty models + default + retain union/dedupe, explicit list precedence, no default/no lists, forward early return, successful-empty live discovery; no extra network activity in static cases. +- Independent C4 source/security review and final UI/code review. Actual isolated compiled GUI with synthetic API state, screenshots and remount/error evidence. Root/GUI focused checks, typecheck, docs build and exact-head hosted functional CI. No local suite/test/typecheck/build. +- Update English and translations for static seeding and the amended Delete/Hide meaning, and the existing source of truth. Do not document an unimplemented disabled-map API. + +Obtain an independent full plan audit before B. One B implements this management slice only; Fable remains a separate later cycle. Preserve the original patch below as historical source evidence, not current implementation instructions. + +## Concrete component contract and file inventory + +The parent passes `modelRows: ModelRow[] | null`, `modelRevision: string` and `modelRowsReady: boolean` through its existing detail chain. `modelRevision` represents the current API base, external refresh token and local retry epoch. The adopted parent snapshot carries its own revision; readiness requires equality. The child custom-record load is keyed by the same revision and separately records successful ownership observation. A revision mismatch disables actions immediately, even before deferred loading effects run. Mutation start has an immediate single-flight guard; parent and ownership reconciliation must finish before that guard reopens. Failed/old reads cannot certify a new revision. + +The existing fetch owner gains cancellation and generation checks without a new cache/store. Reuse `readJsonOrThrow` and the shared `putModelVisibility`. Mutation responses must be read and their confirmed-save versus catalog-refresh status distinguished. Never treat abort/transport loss as a rollback. The UI keeps previous display under loading/error treatment where appropriate and has a retry path. When the removed focused chip disappears, return focus to a stable search/recovery control without stealing focus from a user who moved elsewhere. + +| Action | Exact paths and ownership | +| --- | --- | +| MODIFY | `src/codex/catalog/provider-fetch.ts`: static default seed and adjacent comment, preserving forward/static/live boundaries. | +| ADD | `gui/src/provider-workspace/model-inventory.ts`: strict existing-DTO parser, provider grouping/count projection, and narrowly needed custom-response identity parsing; no network or second native classifier. Search existing owners before each helper. | +| ADD | `gui/src/components/provider-workspace/ProviderModelChip.tsx`: focused existing chip markup with accessible copy/Delete/Hide controls; authority remains in ProviderModels. This keeps its stateful parent below the 400-line limit. | +| MODIFY | `gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx`: paired parent reads, revision-bound snapshot/readiness, canonical inventory counts and props. | +| MODIFY | `gui/src/pages/Providers.tsx`, `gui/src/components/provider-workspace/ProviderDetails.tsx`: pass rows/revision/readiness and existing Models navigation. Preserve keyed provider mounts. | +| MODIFY | `gui/src/components/provider-workspace/ProviderModels.tsx`: stable custom ownership, revision readiness, disjoint one-request mutations, confirmed identity, feedback/reconciliation, canonical row view and Add policy. No session-long removed-ID set. | +| MODIFY | `gui/src/icons.tsx`: source EyeOff utility icon, preserving existing icon grammar. | +| MODIFY | `gui/src/i18n/{en,de,fr,ja,ko,ru,tr,zh,zh-TW}.ts`: every displayed new label/outcome/confirmation across all nine files; retain D Logs keys. Use existing keys where their meaning fits. | +| MODIFY | `gui/tests/provider-model-custom-add.test.tsx`: preserve Add coverage, update realistic DTO/revision fixtures and the native override/ambiguous-save cases. | +| ADD | `gui/tests/provider-model-management.test.tsx`: stateful server-backed Delete/Hide/reload/recovery and asynchronous readiness/focus cases. | +| ADD | `gui/tests/provider-model-inventory.test.ts`: malformed DTO/identity, namespace collisions, unique inventory counts and successful-empty semantics. GUI tests are outside the root layout registry. | +| MODIFY | Existing workspace/Providers tests that render the touched chain: update their paired endpoint fixtures and verify counts/provenance. Only actual affected callers, found by search, are changed. | +| MODIFY | `tests/codex-integration/codex-catalog.test.ts`: static-default/retain/explicit/forward/live-empty behavior and no-network oracles. | +| MODIFY | `tests/codex-integration/model-visibility-management-api.test.ts`: actual custom DELETE then GET restoration/identity, independent hide state and unchanged target validation. | +| OMIT source hunk | `src/server/management/model-routes.ts`, `gui/src/provider-workspace/usage.ts`, `tests/server/model-discovery-management-api.test.ts`: do not add the proposed disabled-map API/parser/assertion; existing response and helper meanings stay intact. | +| MODIFY | The source's provider-reference and codex-integration guide changes in English plus fr/ja/ko/ru/tr/zh-cn/zh-tw: static seeding and existing visibility policy. | +| MODIFY | `docs-site/src/content/docs/{,fr/,ja/,ko/,ru/,tr/,zh-cn/,zh-tw/}guides/web-dashboard.md`: Delete definition versus Hide, count semantics and existing Models recovery; retain D Logs descriptions. | +| MODIFY | `structure/03_catalog-and-subagents.md`: ordered static seed union and provider-workspace inventory/identity/Delete/Hide contract. No direct-routing permission change. | +| REPLACE artifact | `docs-site/public/pr-screenshots/3659-provider-model-removal.png`: capture the actual amended UI in isolated compiled QA; the source image is historical. Add only necessary state/viewport evidence. | + +Canonical count is unique non-disabled projected inventory before search and the 300-chip cap. SelectedModels remains a routed allowlist badge; native rows do not borrow a routed same-ID selection. A successful empty DTO never activates raw fallback. Native-only DTO raw IDs must not newly enter Add's duplicate set; preserve the pre-existing configured/discovered/custom and encoded-collision validation. Custom/native equal raw IDs remain distinct namespaced chips where both are projected. + +## Design and verification contract + +Keep the existing wrapping chip layout, typography, tokens and icons. Delete confirmation explicitly says it removes the custom definition and may reveal an underlying model. Hide confirmation explains catalog visibility and preserves direct routing policy. Keep an always-visible, keyboard-accessible Models recovery action, including empty/after-Hide/error states. No hidden panel, new deep-link protocol, additional permission flow or visual redesign. + +Final GUI checks run on the remote exact branch: `cd gui && bun test --isolate tests`, `bun run lint`, `bun run lint:i18n`, `bun run build`. Root focused checks include catalog, model-discovery management, model-visibility API and the import-connected set; source-oracle/subprocess paths are explicitly covered. Root typecheck/privacy and docs build run remotely. Hosted exact-head Linux/macOS functional CI and actual target composition remain merge gates; final all-Windows dispatch remains in 060. + +Browser QA uses the actual remotely compiled dashboard with an isolated synthetic management state. Capture desktop and narrow Korean layouts plus: custom-only deletion/re-add, custom/native and custom/live restoration, independent Hide and remount, existing Models recovery, error/ambiguous-save and pending-ownership states. Observe requests and resulting rows/counts; screenshots alone do not prove persistence. No real provider accounts, global proxy or deployment are touched. + +Delegation after A: main owns source carry/static seed/branch/FSM/PR integration; frontend writer owns the component/helper chain; separate GUI test writer owns behavioral fixtures; catalog/API worker owns backend regression cases; docs worker owns translations and public guides; independent reviewer owns C4 identity/security and final source audit; remote QA owns exact-head execution and browser evidence. Workers inherit the model and may delegate bounded subwork; no worker mutates main FSM or pushes/merges. + +## Reviewable PR layers within this work phase + +Use two dependent PRs for the two capabilities in source #3659. This is one management work-phase/PABCD cycle, not the Fable cycle. Layer 1 (`codex/lane-b-04-static-default`) carries static default seeding, source catalog regressions and static-provider documentation. Layer 2 (`codex/lane-b-04-management`) builds on layer 1 and contains the canonical DTO workspace, Delete/Hide controls, UI/API regression coverage, translations and rendered evidence. Both preserve gqchen attribution. The UI layer remains one coherent interaction contract; its larger regression matrix is necessary to review the three asynchronous resources and identity boundaries together. + +Main commits and publishes the static parent before switching the same bound checkout to its UI child, then delegates UI writers. No branch movement occurs under a running test/build. Verification for both prepared heads is collected in C; land the reviewed stack bottom-up (or a separately audited verified composition), retarget children before parent cleanup, and close original #3659 only after both capabilities are verified on dev. No source closure is claimed for the partial static landing. + +## Structural decision and dependency map + +The pressure is adding trusted row actions and refresh state to the existing 276-line ProviderModels while preserving a single server identity owner. Current edges are `ProviderWorkspaceShell -> usage/report`, `Providers -> ProviderDetails -> ProviderModels -> report/slug-codec`; `/api/models` identity comes from `src/server/management/model-rows.ts -> catalog/config`. The new parser is an HTTP read boundary, not a second identity policy. + +Chosen edges: the parent and child import the colocated pure `provider-workspace/model-inventory.ts`; its ModelRow dependency is type-only from `pages/models-shared.ts`. ProviderModels imports the colocated ProviderModelChip; that leaf uses existing icons/i18n and receives callbacks, never fetches or chooses authority. No barrel/public export, runtime server-to-GUI import, shared mutable store or backend layering change is introduced. Blast radius is this provider-workspace feature and its existing prop/test callers. + +The source disabled-map alternative is rejected because it duplicates native/custom identity policy and misses fallback/native state. Putting every new parser, state transition and chip into ProviderModels is rejected because it mixes response validation, authority and markup while approaching the 400-line boundary. The selected split leaves operation/state ownership visible in the existing parent/child. Existing component fetch conventions are retained rather than adding a query-library dependency or migrating unrelated server/cache ownership. Verify the paired read cost, no duplicate per-row request, exact prop callers, strict boundary parsing, focus and state generations in the planned remote tests/browser QA. + +## Historical source snapshot and full source patch + +Everything below is the original roadmap snapshot and public upstream diff. Its superseded two-write removal, disabled-map API, unconditional native inference and always-decrement count are not accepted implementation requirements. Active requirements are above. + + +Read on 2026-09-06 KST in `/Users/jun/.codex/worktrees/f80e/opencodex` at `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Pinned source: [PR #3659](https://github.com/lidge-jun/opencodex/pull/3659), head `ff4e5cd5352b9c1bd05e3de0091f3483ca130be5`, source base `af50c6d3451078a7d298b044c08fd2684c9e8eeb`. Inputs are captured `.tmp/lane-b/3659.json` and `.patch`; no claim of a fresh remote status check is made. `git show -s` independently confirmed the head commit author below. + +| Source commit | Actual commit author | Subject | +|---|---|---| +| `2a41fea0229f7d2bcc9e90d6b614ad94bbd6802f` | gqchen <276851182@qq.com> | feat(gui): remove models from provider catalog | +| `34ace947a31aa154d77cd6d0eac67669304dd72b` | gqchen <276851182@qq.com> | fix(codex): sync static default-only providers | +| `13e6ea29e6904afff68c6eccd177c9e929f494d7` | gqchen <276851182@qq.com> | docs(pr): add provider model removal screenshot | +| `e005d028d3b4697676f17817b10de4c8ea4e2987` | gqchen <276851182@qq.com> | fix(gui): address provider model removal review | +| `ff4e5cd5352b9c1bd05e3de0091f3483ca130be5` | gqchen <276851182@qq.com> | fix(gui): distinguish hidden provider models | + +Preserve original commit authors on a clean replay; for reimplementation or squash put `Co-authored-by: gqchen <276851182@qq.com>` in each carried logical commit and the final squash body. PR login alone is not an author trailer. + +Safe carry strategy: main revalidates pinned source/head and incoming parent; replay the complete reviewed source series in order or reproduce its exact delta with attribution. Preserve source follow-up commits, not only the initial feature commit. Publish a child against its still-open parent; after parent squash, rebuild the child on the new dev ancestry and re-run exact-head CI. Retarget surviving children before parent branch deletion. Push uses the user's authorized `--no-verify` to avoid local hooks; this does not substitute for CI. Once dev contains the complete carried behavior, close the superseded source PR with a carry reference; do not close it for a partial/default-only slice. No linked issue is invented. + +## Exact change ledger + +Every source changed file is accounted for below. “Same base” means a read-only `git hash-object` of current file bytes matches the patch's old blob prefix; it does not prove future cherry-pick cleanliness. “Drift” requires contextual reconciliation. Source binary is explicitly unreviewed. All textual hunks were inspected as source behavior; appendix preserves exact before/after, including complete NEW test content. No source production file was edited. + +| Operation | Exact path | Baseline / disposition | +|---|---|---| +| NEW | `docs-site/public/pr-screenshots/3659-provider-model-removal.png` | Binary skipped: payload absent from text patch; visual proof required | +| MODIFY | `docs-site/src/content/docs/fr/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/fr/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ja/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ja/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ko/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ko/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/reference/configuration/providers.md` | Drift; preserve current unrelated edits | +| MODIFY | `docs-site/src/content/docs/ru/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ru/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/tr/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/tr/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-cn/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-cn/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-tw/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-tw/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `gui/src/components/provider-workspace/ProviderDetails.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/components/provider-workspace/ProviderModels.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/i18n/de.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/en.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/fr.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/ja.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/ko.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/ru.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/tr.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/zh-TW.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/zh.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/icons.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/pages/Providers.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/provider-workspace/usage.ts` | Same base; reviewed textual delta | +| MODIFY | `gui/tests/provider-model-custom-add.test.tsx` | Same base; reviewed textual delta | +| MODIFY | `src/codex/catalog/provider-fetch.ts` | Same base; reviewed textual delta | +| MODIFY | `src/server/management/model-routes.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/codex-integration/codex-catalog.test.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/server/model-discovery-management-api.test.ts` | Same base; reviewed textual delta | +| MODIFY (planned SoT addition) | `structure/03_catalog-and-subagents.md` | Public contract prose delta specified above; not in source PR | +| MODIFY (planned API doc addition) | `docs-site/src/content/docs/reference/management-api.md` | Add disabled response field alongside preceding phases | + +## Execution boundary and verifier + +This is a docs-only roadmap deliverable, not an implementation or merge receipt. The main agent owns 000, the goal/FSM, branch operations, integration, and final acceptance. No local tests, suites, typecheck, builds, hooks, provider requests, commits, pushes, or GitHub writes were run by this researcher. The next implementation cycle must re-read this plan against its actual parent tip. + +Loop archetype: spec-satisfaction repair. Trigger: carry the pinned public source PR into lane B. Stop: required behaviors, exact-head CI, reviewer disposition and parent integration all have durable evidence. Expected result: DONE only after verified dev ancestry; NOOP only if equivalent behavior is already landed; unresolved correctness/CI evidence is pending, not DONE. Scope and unattended resource bounds are inherited from main's 000; this document does not arm or amend the goal. Upward escalation: return concrete caller/test evidence to main if the carry contract cannot be satisfied; additional worker dispatch requires main's planned scope. + +CI-only verifier, inspected at the baseline below: + +- `.github/workflows/ci.yml:5` uses unfiltered `pull_request`, so child PR bases are supported. `changes` at lines 181-218 admits `src/**`, `tests/**`, `scripts/**`, `gui/**`; source changes also admit packaging. +- Linux `test 1/4..4/4`, lines 255-316, invokes `scripts/ci/run-bun-test-batches.sh`. That runner enumerates `tests` (line 197), admits `.test.ts` files (lines 46-68), and excludes only storage/API-usage families into dedicated jobs; the catalog/management files in this plan are included. Each actual batch runs `bun test --isolate --timeout 60000` under a process timeout (lines 121-126). Read logs to prove the named files ran; aggregate green alone is insufficient. +- `gates`, lines 390-449, runs root TypeScript (`bun x tsc --noEmit`), GUI tests (`cd gui && bun test --isolate tests`), privacy scan and skill-surface check. GUI changes additionally run `bun run lint` and `bun run build`; `gui/package.json:8` expands build to `tsc -b && vite build`. GUI lint is `oxlint .`, including changed locale/UI inputs; the separately named `lint:i18n` script is not a dedicated CI step. +- macOS runs two full-suite shards (lines 451-547). Windows full-suite six shards (lines 658-769) run only on `workflow_dispatch` with lane `all`; do not infer Windows full-suite coverage from packaging smoke or PR aggregate success. Main must obtain an exact-ref dispatch if Windows full-suite evidence is required, then verify the run head. +- `.github/workflows/deploy-docs.yml:3-10,24-33` builds Astro only on main push or manual dispatch and then deploys. Normal PR CI has no Astro docs build. Do not trigger this deploy workflow merely to obtain a pre-merge check. Main must arrange an approved non-deploy CI verifier on the exact candidate commit or explicitly retain this as a readiness gap; this research does not add workflow code or authorize deployment. +- Save head SHA, parent/base SHA, run URL, executed job conclusions, named test logs, approvals and unresolved-thread disposition. Source-author reported passes are historical claims, not carried-head validation. Never attest local checks that were intentionally prohibited. + + +## Pinned public source delta + +Reconcile only the touched hunks at the implementation parent. The conceptual deltas above and acceptance amendments take precedence over copying this source verbatim. This appendix records public source-PR behavior only. + +````diff +diff --git a/docs-site/public/pr-screenshots/3659-provider-model-removal.png b/docs-site/public/pr-screenshots/3659-provider-model-removal.png +new file mode 100644 +index 0000000000..dafe828720 +Binary files /dev/null and b/docs-site/public/pr-screenshots/3659-provider-model-removal.png differ +diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md +index 091a63a787..fe0a5d687a 100644 +--- a/docs-site/src/content/docs/fr/guides/codex-integration.md ++++ b/docs-site/src/content/docs/fr/guides/codex-integration.md +@@ -311,8 +311,9 @@ S'il manque un modèle dans Codex, ou si l'ordre ou la visibilité du catalogue + d'autorisation n'atteint jamais le catalogue. + 2. **`disabledModels`** au niveau supérieur — masque les modèles dans le catalogue comme dans `/v1/models`, et + fait passer les identifiants GPT natifs non qualifiés à `visibility: "hide"`. +-3. **`liveModels: false` avec `models` vide** — lorsque la découverte en direct est désactivée et que `models` +- est vide ou absent, opencodex n'expose aucun modèle routé pour ce fournisseur. ++3. **`liveModels: false`** — lorsque la découverte en direct est désactivée, les modèles routés proviennent de ++ `models` et `retainModels`. Si `models` est vide ou absent, un `defaultModel` configuré est également inclus ; ++ si aucun de ces champs ne fournit d'identifiant, opencodex n'expose aucun modèle routé. + 4. **Cursor `GetUsableModels`** — l'adaptateur Cursor découvre les modèles par son appel RPC protobuf + `GetUsableModels`, et non par `/models` ; une modification côté Cursor peut donc changer les identifiants visibles + indépendamment des autres fournisseurs. +diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md +index bec7932c09..dcfcc0af63 100644 +--- a/docs-site/src/content/docs/fr/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md +@@ -93,7 +93,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Style de l'en-tête de clé Anthropic. La valeur par défaut est l'en-tête natif `x-api-key` ; ce champ n'est valable que pour les fournisseurs `anthropic` authentifiés par clé. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Pool multi-clés. `apiKey` reflète l'entrée active ; chaque élément a `id`, `key`, `label` facultatif et `addedAt` numérique facultatif. | + | `defaultModel?` | `string` | Modèle utilisé lorsque ce fournisseur est sélectionné sans modèle explicite. | +-| `models?` | `string[]` | Liste initiale ou de repli des modèles. Avec `liveModels: false`, ce sont les seuls modèles découverts. | ++| `models?` | `string[]` | Liste initiale ou de repli. Avec `liveModels: false`, les modèles routés proviennent de `models` et `retainModels` ; `defaultModel` est aussi inclus lorsque `models` est vide. | + | `liveModels?` | `boolean` | Récupère le catalogue actif au démarrage et lors de la synchronisation (true par défaut). Les fournisseurs personnalisés utilisent `${baseUrl}/models` ; les fournisseurs intégrés peuvent employer une URL de registre et un filtre. | + | `selectedModels?` | `string[]` | Liste autorisée du catalogue après la découverte. Non vide expose uniquement ces identifiants ; vide ou omis expose tous les modèles découverts. | + | `contextWindow?` | `number` | Repli contextuel à l’échelle du fournisseur lorsque les métadonnées en amont sont absentes ; sinon, un plafond qui conserve des métadonnées en direct plus petites. Le tableau de bord Modèles expose cela séparément de `providerContextCaps`. | +@@ -435,8 +435,8 @@ modèle. Le même mappage s'applique à un sélecteur natif `vercel/` + + ## Listes autorisées de modèles statiques + +-Réglez `liveModels: false` pour exposer uniquement `models`. Si `models` est vide ou omis, le fournisseur n'expose +-aucun modèle routé. La découverte dynamique rejette plus de 4 Mio ou 2 000 lignes de modèle brutes avant leur mise en cache ; ++Réglez `liveModels: false` pour exposer uniquement les modèles configurés dans `models` et `retainModels`. Si `models` est vide ou omis, ++un `defaultModel` configuré est également inclus. Si aucun de ces champs ne fournit d'identifiant, aucun modèle routé n'est exposé. La découverte dynamique rejette plus de 4 Mio ou 2 000 lignes de modèle brutes avant leur mise en cache ; + les préréglages intégrés peuvent appliquer des limites inférieures et filtrer les lignes admissibles à la conversation. Les résultats trop volumineux ou mal formés + utilisent le catalogue obsolète ou configuré comme solution de repli. Un résultat valide ne contenant aucun modèle admissible fait autorité et n'est pas + silencieusement remplacé ou tronqué. +diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md +index b2ae7fcb91..62f5b0a662 100644 +--- a/docs-site/src/content/docs/guides/codex-integration.md ++++ b/docs-site/src/content/docs/guides/codex-integration.md +@@ -454,8 +454,9 @@ If a model is missing from Codex, or the catalog order/visibility looks wrong, c + catalog. + 2. **`disabledModels`** (top level) — hides models from both the catalog and `/v1/models`, and flips + bare native GPT slugs to `visibility: "hide"`. +-3. **`liveModels: false` with empty `models`** — when live discovery is off and `models` is empty or +- omitted, opencodex exposes no routed models for that provider. ++3. **`liveModels: false`** — with live discovery off, routed models come from `models` and ++ `retainModels`. When `models` is empty or omitted, a configured `defaultModel` is included too; ++ if none of those fields supplies an id, opencodex exposes no routed models. + 4. **Cursor `GetUsableModels`** — the Cursor adapter discovers models through its protobuf + `GetUsableModels` RPC, not `/models`, so a Cursor-side change can alter which ids are visible + independently of other providers. +diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md +index 06cd590084..d49fae078c 100644 +--- a/docs-site/src/content/docs/ja/guides/codex-integration.md ++++ b/docs-site/src/content/docs/ja/guides/codex-integration.md +@@ -197,8 +197,9 @@ ocx sync-cache + 空または省略すると、検出されたすべてのモデルが公開されます。ホワイトリストにない ID はカタログに到達しません。 + 2. **`disabledModels`** (トップレベル) — カタログと `/v1/models` の両方からモデルを非表示にし、反転します + 裸のネイティブ GPT スラッグを `visibility: "hide"` にします。 +-3. **`liveModels: false` と空の `models`** — ライブ検出がオフで、`models` が空の場合、または +-省略すると、opencodex はそのプロバイダーのルーティング モデルを公開しません。 ++3. **`liveModels: false`** — ライブ検出がオフの場合、ルーティングモデルは `models` と ++`retainModels` から取得されます。`models` が空または省略されている場合は構成済みの `defaultModel` も含まれ、 ++いずれのフィールドにも ID がない場合のみルーティングモデルを公開しません。 + 4. **Cursor `GetUsableModels`** — Cursor アダプターはその protobuf を通じてモデルを検出します。 + `/models` ではなく `GetUsableModels` RPC であるため、カーソル側の変更により、他のプロバイダーとは独立して表示される ID が変更される可能性があります。 + 5. **キャッシュと `ocx sync`** - ライブ カタログは約 5 分間キャッシュされます (`modelCacheTtlMs`、 +diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md +index bd33d34a3f..41e1de7aa3 100644 +--- a/docs-site/src/content/docs/ja/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md +@@ -81,7 +81,7 @@ account を削除しても mapping は保持され、同じ id を再追加す + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic キーのヘッダー スタイル。デフォルトはネイティブ `x-api-key` です。キー認証 `anthropic` プロバイダーにのみ有効です。 | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` |マルチキープール。 `apiKey` はアクティブなエントリをミラーリングします。各項目には `id`、`key`、オプションの `label`、およびオプションの数値 `addedAt` があります。 | + | `defaultModel?` | `string` |このプロバイダーが明示的なモデルなしで選択された場合に使用されるモデル。 | +-| `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 | ++| `models?` | `string[]` |シード/フォールバック モデルのリスト。`liveModels: false` ではルーティングモデルは `models` と `retainModels` から取得され、`models` が空の場合は `defaultModel` も含まれます。 | + | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | + | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | + | `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。マップは検出上限と同じ 2,000 件までです。 | +@@ -354,7 +354,7 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ + + ## 静的モデルのホワイトリスト + +-`models` のみを公開するように `liveModels: false` を設定します。 `models` が空であるか省略されている場合、プロバイダーはルーティングされたモデルを公開しません。ライブ ディスカバリは、キャッシュする前に 4 MiB または 2,000 を超える生のモデル行を拒否します。組み込みのプリセットは下限を使用し、チャットに適した行にフィルターをかけることができます。サイズが大きすぎる、または形式が正しくない結果は、古い/構成されたフォールバックに続きます。ゼロに適格な有効な結果は引き続き権威を持ち、暗黙的に置き換えられたり切り捨てられたりすることはありません。 ++構成済みモデルのみを公開するように `liveModels: false` を設定します。ルーティングモデルは `models` と `retainModels` から取得され、`models` が空または省略されている場合は構成済みの `defaultModel` も含まれます。いずれのフィールドにも ID がない場合のみ、ルーティングモデルを公開しません。ライブ ディスカバリは、キャッシュする前に 4 MiB または 2,000 を超える生のモデル行を拒否します。組み込みのプリセットは下限を使用し、チャットに適した行にフィルターをかけることができます。サイズが大きすぎる、または形式が正しくない結果は、古い/構成されたフォールバックに続きます。ゼロに適格な有効な結果は引き続き権威を持ち、暗黙的に置き換えられたり切り捨てられたりすることはありません。 + + 検出を実行する必要があるが、選択した ID のみが Codex および `/v1/models` に表示される必要がある場合は、`selectedModels` を使用します。ダッシュボードには、後で許可リストを変更できるように、検出された完全なリストが保持されます。 + +diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md +index 3f777153ec..ece36df6af 100644 +--- a/docs-site/src/content/docs/ko/guides/codex-integration.md ++++ b/docs-site/src/content/docs/ko/guides/codex-integration.md +@@ -197,7 +197,7 @@ Codex에서 model이 빠졌거나 catalog 순서/가시성이 이상해 보이 + + 1. provider의 **`selectedModels`** - 비어 있지 않은 allowlist는 해당 id만 Codex에 노출합니다. 비어 있거나 생략하면 발견된 model이 모두 노출됩니다. allowlist에 없는 id는 catalog에 절대 들어가지 않습니다. + 2. **`disabledModels`**(top level) - catalog와 `/v1/models`에서 model을 숨기고, bare native GPT slug는 `visibility: "hide"`로 바꿉니다. +-3. **`liveModels: false`와 비어 있는 `models`** - live discovery가 꺼져 있고 `models`가 비어 있거나 생략되면, opencodex는 그 provider에 대해 routed model을 하나도 노출하지 않습니다. ++3. **`liveModels: false`** - live discovery가 꺼져 있으면 routed model은 `models`와 `retainModels`에서 가져옵니다. `models`가 비어 있거나 생략되면 구성된 `defaultModel`도 포함되며, 어느 필드에도 ID가 없을 때만 routed model을 노출하지 않습니다. + 4. **Cursor `GetUsableModels`** - Cursor adapter는 `/models`가 아니라 protobuf `GetUsableModels` RPC로 model을 찾습니다. 그래서 Cursor 쪽 변경이 다른 provider와 무관하게 어떤 id가 보이는지 바꿀 수 있습니다. + 5. **캐시와 `ocx sync`** - live catalog는 약 5분(`modelCacheTtlMs`, 기본값 `300000`) 동안 캐시됩니다. `ocx sync`를 실행하면 새로 가져와서 catalog를 즉시 다시 쓸 수 있습니다. + 6. **실행 중인 Codex `app-server`** - 오래 살아 있는 Codex `app-server`(Desktop / CLI background host)가 이전 목록을 메모리에 쥐고 있으면 디스크 catalog를 다시 쓰는 것만으로는 부족합니다. `ocx sync`와 `ocx sync-cache`는 그런 process를 감지하면 경고합니다. `ocx sync --restart-codex`로 다시 시작하거나(아니면 일치하는 `app-server` process를 직접 중지한 뒤), Codex가 다시 만들게 해서 새 목록이 보이게 하세요. +diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md +index 3d65dbfb4d..49b911d2c8 100644 +--- a/docs-site/src/content/docs/ko/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md +@@ -81,7 +81,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 키 헤더 형식입니다. 기본값은 네이티브 `x-api-key`이며, 키 인증 `anthropic` 공급자에만 유효합니다. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 다중 키 풀입니다. `apiKey`는 활성 항목을 그대로 반영하며, 각 항목에는 `id`, `key`, 선택적 `label`, 선택적 숫자 `addedAt`가 들어갑니다. | + | `defaultModel?` | `string` | 이 공급자를 선택할 때 모델을 따로 지정하지 않으면 사용하는 모델입니다. | +-| `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. | ++| `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 라우팅 모델은 `models`와 `retainModels`에서 가져오며, `models`가 비어 있으면 `defaultModel`도 포함됩니다. | + | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | + | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | + | `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | +@@ -361,7 +361,7 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 + + ## 정적 모델 허용 목록 + +-`liveModels: false`로 두면 `models`만 노출합니다. `models`가 비어 있거나 생략되면 공급자는 어떤 라우팅 모델도 노출하지 않습니다. 라이브 발견은 캐싱 전에 4 MiB 또는 원시 모델 행 2,000개를 넘으면 거부합니다. 내장 프리셋은 더 낮은 한도를 쓰고 chat 가능한 행만 필터링할 수 있습니다. 너무 크거나 형식이 잘못된 결과는 오래된/설정된 폴백을 따릅니다. 유효하지만 선택 가능한 항목이 0개인 결과는 그대로 권위가 있으며, 조용히 다른 값으로 바꾸거나 잘라내지 않습니다. ++`liveModels: false`로 두면 구성된 모델만 노출합니다. 라우팅 모델은 `models`와 `retainModels`에서 가져오며, `models`가 비어 있거나 생략되면 구성된 `defaultModel`도 포함됩니다. 어느 필드에도 ID가 없을 때만 라우팅 모델을 노출하지 않습니다. 라이브 발견은 캐싱 전에 4 MiB 또는 원시 모델 행 2,000개를 넘으면 거부합니다. 내장 프리셋은 더 낮은 한도를 쓰고 chat 가능한 행만 필터링할 수 있습니다. 너무 크거나 형식이 잘못된 결과는 오래된/설정된 폴백을 따릅니다. 유효하지만 선택 가능한 항목이 0개인 결과는 그대로 권위가 있으며, 조용히 다른 값으로 바꾸거나 잘라내지 않습니다. + + `selectedModels`는 발견은 계속하되, 선택된 id만 Codex와 `/v1/models`에 나타나게 하고 싶을 때 사용합니다. 대시보드는 나중에 허용 목록을 바꿀 수 있도록 발견된 전체 목록을 보관합니다. + +diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md +index ab8a154ecb..2a56f4ceb9 100644 +--- a/docs-site/src/content/docs/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/reference/configuration/providers.md +@@ -136,7 +136,7 @@ predictions. Explicit provider/model price overrides still take precedence. + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | + | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | +-| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | ++| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, routed models come from `models` and `retainModels`; `defaultModel` is also included when `models` is empty. | + | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | + | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | + | `retainModels?` | `string[]` | Ids kept in the catalog even when live discovery omits them. They need not be repeated in `models`. Empty or omitted keeps today's behavior. | +@@ -738,8 +738,8 @@ container usually has no unlocked keychain session, so requests would fail close + `${ENV_VAR}` reference in the service environment there instead. Env references are left untouched + by `store`. + +-Set `liveModels: false` to expose only `models`. If `models` is empty or omitted, the provider exposes +-no routed models. Live discovery rejects more than 4 MiB or 2,000 raw model rows before caching; ++Set `liveModels: false` to expose only configured models from `models` and `retainModels`. If `models` ++is empty or omitted, a configured `defaultModel` is included too. If none of those fields supplies an id, the provider exposes no routed models. Live discovery rejects more than 4 MiB or 2,000 raw model rows before caching; + built-in presets may use lower limits and filter to chat-eligible rows. Oversized or malformed results + follow stale/configured fallback. A valid zero-eligible result remains authoritative and is not + silently replaced or truncated. +diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md +index e33bb6c835..ebdec89431 100644 +--- a/docs-site/src/content/docs/ru/guides/codex-integration.md ++++ b/docs-site/src/content/docs/ru/guides/codex-integration.md +@@ -304,8 +304,9 @@ Codex на встроенный провайдер `openai` и удалите л + allowlist, никогда не попадёт в каталог. + 2. **`disabledModels`** (верхний уровень) — скрывает модели и из каталога, и из `/v1/models`, а у + голых нативных GPT-slug устанавливает `visibility: "hide"`. +-3. **`liveModels: false` и пустой `models`** — если живое обнаружение выключено, а `models` пуст +- или отсутствует, opencodex не показывает ни одной маршрутизируемой модели этого провайдера. ++3. **`liveModels: false`** — если живое обнаружение выключено, маршрутизируемые модели берутся из ++ `models` и `retainModels`. Если `models` пуст или отсутствует, также включается настроенный `defaultModel`; ++ если ни одно из этих полей не содержит идентификатор, opencodex не показывает маршрутизируемых моделей. + 4. **Cursor `GetUsableModels`** — адаптер Cursor получает модели через protobuf RPC + `GetUsableModels`, а не через `/models`, поэтому изменение на стороне Cursor может менять + видимые id независимо от остальных провайдеров. +diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md +index a058fdb842..f31d0ffc13 100644 +--- a/docs-site/src/content/docs/ru/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md +@@ -94,7 +94,7 @@ cross-route credential fallback не существует. Строки API GPT- + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Header-style для ключа Anthropic. По умолчанию нативный `x-api-key`; допустим только для key-auth-провайдеров `anthropic`. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Пул из нескольких ключей. `apiKey` зеркалит активную запись; каждый элемент содержит `id`, `key`, необязательный `label` и необязательное числовое `addedAt`. | + | `defaultModel?` | `string` | Модель, используемая когда этот провайдер выбран без явной модели. | +-| `models?` | `string[]` | Seed/fallback-список моделей. При `liveModels: false` это и есть единственный список обнаруженных моделей. | ++| `models?` | `string[]` | Seed/fallback-список. При `liveModels: false` маршрутизируемые модели берутся из `models` и `retainModels`; если `models` пуст, также включается `defaultModel`. | + | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | + | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | + | `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Карта содержит не более 2 000 записей, как и discovery. | +@@ -439,8 +439,8 @@ Chat-запросов не добавляют поле `provider`, а Vercel AI + + ## Статические allowlist'ы моделей + +-Задайте `liveModels: false`, чтобы показывать только `models`. Если `models` пуст или отсутствует, +-провайдер не будет показывать ни одной маршрутизируемой модели. Live-discovery отвергает ответы ++Задайте `liveModels: false`, чтобы показывать только настроенные модели из `models` и `retainModels`. Если `models` пуст или отсутствует, ++также включается настроенный `defaultModel`. Если ни одно из этих полей не содержит идентификатор, провайдер не показывает маршрутизируемых моделей. Live-discovery отвергает ответы + размером более 4 MiB или более 2000 сырых model-row до кэширования; built-in preset'ы могут + использовать меньшие лимиты и фильтровать список до chat-совместимых строк. Oversized или + malformed-результаты откатываются к stale/configured fallback. Валидный результат с нулём +diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md +index 7692980e91..39ef68406d 100644 +--- a/docs-site/src/content/docs/tr/guides/codex-integration.md ++++ b/docs-site/src/content/docs/tr/guides/codex-integration.md +@@ -353,9 +353,9 @@ sırayla kontrol edin: + 2. **`disabledModels`** (üst düzey) — modelleri hem katalogdan hem de + `/v1/models` listesinden gizler ve yalın yerel GPT slug'larını `visibility: + "hide"` olarak değiştirir. +-3. **Boş `models` ile `liveModels: false`** — canlı keşif kapalı olduğunda ve +- `models` boş veya atlandığında opencodex bu sağlayıcı için hiçbir +- yönlendirilmiş model göstermez. ++3. **`liveModels: false`** — canlı keşif kapalı olduğunda yönlendirilmiş modeller `models` ve ++ `retainModels` alanlarından gelir. `models` boş veya atlanmışsa yapılandırılmış `defaultModel` da eklenir; ++ bu alanların hiçbiri bir kimlik sağlamıyorsa opencodex yönlendirilmiş model göstermez. + 4. **Cursor `GetUsableModels`** — Cursor adaptörü modelleri `/models` üzerinden + değil, protobuf `GetUsableModels` RPC'si üzerinden keşfeder; bu nedenle + Cursor tarafındaki bir değişiklik diğer sağlayıcılardan bağımsız olarak hangi +diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md +index 4213ab6001..27fe115fc1 100644 +--- a/docs-site/src/content/docs/tr/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md +@@ -100,7 +100,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic anahtar başlığı stili. Varsayılan olarak yerel `x-api-key`; yalnızca anahtar kimlik doğrulamalı `anthropic` sağlayıcıları için geçerlidir. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Çoklu anahtar havuzu. `apiKey` aktif girdiyi yansıtır; her öğe `id`, `key`, isteğe bağlı `label` ve isteğe bağlı sayısal `addedAt` değerine sahiptir. | + | `defaultModel?` | `string` | Bu sağlayıcı açık bir model olmadan seçildiğinde kullanılan model. | +-| `models?` | `string[]` | Tohum/geri dönüş model listesi. `liveModels: false` olduğunda bunlar keşfedilen tek modellerdir. | ++| `models?` | `string[]` | Tohum/geri dönüş listesi. `liveModels: false` iken yönlendirilen modeller `models` ve `retainModels` alanlarından gelir; `models` boşsa `defaultModel` da eklenir. | + | `liveModels?` | `boolean` | Başlatmada/senkronizasyonda canlı kataloğu getirin (varsayılan `true`). Özel sağlayıcılar `${baseUrl}/models` kullanır; yerleşikler bir kayıt defteri URL'si ve filtresi kullanabilir. | + | `selectedModels?` | `string[]` | Keşiften sonra katalog izin listesi. Boş olmaması yalnızca bu kimlikleri gösterir; boş veya atlanmış olması keşfedilen tüm modelleri gösterir. | + | `contextWindow?` | `number` | Yukarı akış meta verileri olmadığında sağlayıcı genelinde bağlam geri dönüşü; aksi takdirde daha küçük canlı meta verileri koruyan bir sınır. Modeller kontrol paneli bunu `providerContextCaps` alanından ayrı olarak gösterir. | +@@ -476,8 +476,8 @@ uygulamadan önce yerel `zai/glm-5.2` kimliğini geri yükler. Aynı eşleme yer + + ## Statik model izin listeleri + +-Yalnızca `models`'ı göstermek için `liveModels: false` ayarlayın. `models` boşsa +-veya atlanırsa sağlayıcı yönlendirilen hiçbir modeli göstermez. Canlı keşif, ++Yalnızca yapılandırılmış modelleri göstermek için `liveModels: false` ayarlayın. Yönlendirilen modeller `models` ve `retainModels` alanlarından gelir; ++`models` boşsa veya atlanırsa yapılandırılmış `defaultModel` da eklenir. Bu alanların hiçbiri bir kimlik sağlamıyorsa yönlendirilmiş model gösterilmez. Canlı keşif, + önbelleğe almadan önce 4 MiB'den veya 2.000 ham model satırından fazlasını + reddeder; yerleşik önayarlar daha düşük sınırlar kullanabilir ve sohbete uygun + satırlara filtre uygulayabilir. Büyük boyutlu veya hatalı biçimlendirilmiş +diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +index e2a5601d62..e9ddcf3307 100644 +--- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md ++++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +@@ -260,8 +260,8 @@ provider 形式一样,从 `OPENCODEX_API_AUTH_TOKEN` 传入 `x-opencodex-api-k + 所有发现到的模型。一个不在 allowlist 里的 id 永远不会进入 catalog。 + 2. **`disabledModels`**(顶层) - 会同时隐藏 catalog 和 `/v1/models` 中的模型,并把裸原生 GPT slug + 切成 `visibility: "hide"`。 +-3. **`liveModels: false` 且 `models` 为空** - 当 live discovery 关闭而 `models` 为空或省略时,opencodex +- 不会为那个 provider 暴露任何路由模型。 ++3. **`liveModels: false`** - 关闭 live discovery 后,路由模型来自 `models` 和 `retainModels`。 ++ 当 `models` 为空或省略时,还会包含已配置的 `defaultModel`;这些字段都没有提供 id 时,opencodex 才不暴露路由模型。 + 4. **Cursor `GetUsableModels`** - Cursor adapter 通过它的 protobuf `GetUsableModels` RPC 发现模型,而不是 + `/models`,所以 Cursor 侧的变动会独立于其他 provider 改变哪些 id 可见。 + 5. **缓存和 `ocx sync`** - live catalog 的缓存时间大约是五分钟(`modelCacheTtlMs`,默认 `300000`)。 +diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +index f2d245b5ec..142faba847 100644 +--- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +@@ -81,7 +81,7 @@ selector,而不是分配一个新名称。 + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key 头部样式。默认使用原生 `x-api-key`;仅对 key-auth `anthropic` 提供者有效。 | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多 key 池。`apiKey` 会镜像当前激活条目;每个条目都有 `id`、`key`、可选 `label`,以及可选的数值 `addedAt`。 | + | `defaultModel?` | `string` | 当选择该提供者但未显式指定模型时使用的模型。 | +-| `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 | ++| `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,路由模型来自 `models` 和 `retainModels`;`models` 为空时还会包含 `defaultModel`。 | + | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | + | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | + | `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。该映射最多可包含 2,000 个条目,与发现上限相同。 | +@@ -357,7 +357,7 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 + + ## 静态模型允许列表 + +-将 `liveModels: false` 设为只暴露 `models`。如果 `models` 为空或省略,该提供者将不暴露任何路由模型。实时发现会在缓存前拒绝超过 4 MiB 或 2,000 条原始模型行;内置预设可能使用更低的限制,并过滤为可聊天的行。过大或格式错误的结果会走陈旧/配置回退。合法的、零可用结果的发现仍然具有权威性,不会被静默替换或截断。 ++将 `liveModels: false` 设为只暴露已配置模型。路由模型来自 `models` 和 `retainModels`;如果 `models` 为空或省略,还会包含已配置的 `defaultModel`。这些字段都没有提供 id 时才不暴露路由模型。实时发现会在缓存前拒绝超过 4 MiB 或 2,000 条原始模型行;内置预设可能使用更低的限制,并过滤为可聊天的行。过大或格式错误的结果会走陈旧/配置回退。合法的、零可用结果的发现仍然具有权威性,不会被静默替换或截断。 + + 当需要继续运行发现,但只有选定 id 应该出现在 Codex 和 `/v1/models` 中时,请使用 `selectedModels`。仪表板会保留完整的已发现列表,以便之后调整允许列表。 + +diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +index 4166276fbc..f096f22502 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md ++++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +@@ -266,8 +266,8 @@ OpenCodex 直接注入路由,請先將 Codex 切回內建 `openai` provider, + 已發現模型。不在 allowlist 中的 id 永遠不會進入目錄。 + 2. **`disabledModels`(頂層)**:會同時從目錄與 `/v1/models` 隱藏模型,並把裸原生 GPT slug 設為 + `visibility: "hide"`。 +-3. **`liveModels: false` 且 `models` 為空**:當即時探索關閉,且 `models` 為空或省略時,opencodex +- 不會為該 provider 暴露任何路由模型。 ++3. **`liveModels: false`**:關閉即時探索後,路由模型來自 `models` 和 `retainModels`。 ++ 當 `models` 為空或省略時,還會包含已設定的 `defaultModel`;這些欄位皆未提供 id 時,opencodex 才不暴露路由模型。 + 4. **Cursor `GetUsableModels`**:Cursor adapter 透過 protobuf `GetUsableModels` RPC 探索模型,而不是 + `/models`,所以 Cursor 端變更可獨立改變可見 id。 + 5. **cache 與 `ocx sync`**:即時目錄約快取五分鐘(`modelCacheTtlMs`,預設 `300000`)。執行 +diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +index 7a27de4f61..5154957052 100644 +--- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +@@ -63,7 +63,7 @@ ocx models provider openrouter on + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 金鑰標頭風格。預設為原生 `x-api-key`;僅對 key-auth `anthropic` 供應商有效。 | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多金鑰池。`apiKey` 反映現用項目;每個項目有 `id`、`key`、可選 `label` 與可選數值 `addedAt`。 | + | `defaultModel?` | `string` | 在未指定明確模型時選擇此供應商所使用的模型。 | +-| `models?` | `string[]` | 播種/後備模型清單。在 `liveModels: false` 時,這些是唯一探索的模型。 | ++| `models?` | `string[]` | 播種/後備模型清單。`liveModels: false` 時,路由模型來自 `models` 和 `retainModels`;`models` 為空時還會包含 `defaultModel`。 | + | `liveModels?` | `boolean` | 在啟動/同步時擷取即時目錄(預設 `true`)。自訂供應商使用 `${baseUrl}/models`;內建可能使用 registry URL 並過濾。 | + | `selectedModels?` | `string[]` | 探索後的目錄允許清單。非空時僅暴露那些 id;空或省略時暴露所有探索的模型。 | + | `contextWindow?` | `number` | 供應商範圍的 Codex 可見 context 上限。較小的即時中繼資料被保留。 | +@@ -324,7 +324,7 @@ Vercel AI Gateway 可在多個底層推論供應商之間路由一個模型。`v + + ## 靜態模型允許清單 + +-設定 `liveModels: false` 以僅暴露 `models`。若 `models` 為空或省略,供應商暴露無路由模型。即時探索在快取前拒絕超過 4 MiB 或 2,000 個原始模型列;內建預設可能使用較低限制並過濾到 chat 合格列。過大或格式錯誤的結果遵循過時/設定的後備。有效的零合格結果恆為權威,且不被靜默取代或截斷。 ++設定 `liveModels: false` 以僅暴露已設定模型。路由模型來自 `models` 和 `retainModels`;若 `models` 為空或省略,還會包含已設定的 `defaultModel`。這些欄位皆未提供 id 時才不暴露路由模型。即時探索在快取前拒絕超過 4 MiB 或 2,000 個原始模型列;內建預設可能使用較低限制並過濾到 chat 合格列。過大或格式錯誤的結果遵循過時/設定的後備。有效的零合格結果恆為權威,且不被靜默取代或截斷。 + + 當探索應仍然執行但只有 selected id 應出現在 Codex 與 `/v1/models` 時,請使用 `selectedModels`。儀表板保留完整的探索清單供日後允許清單變更。 + +diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx +index 645511f69d..7a1f66a213 100644 +--- a/gui/src/components/provider-workspace/ProviderDetails.tsx ++++ b/gui/src/components/provider-workspace/ProviderDetails.tsx +@@ -32,6 +32,7 @@ export default function ProviderDetails({ + availableModels, + hasLiveModels, + selectedModels, ++ disabledModels, + modelsLoading, + modelsLoadFailed, + onRetryModels, +@@ -65,6 +66,7 @@ export default function ProviderDetails({ + /** Server-reported live-catalog provenance; see filterModels(). */ + hasLiveModels: boolean; + selectedModels: string[]; ++ disabledModels: string[]; + modelsLoading?: boolean; + modelsLoadFailed?: boolean; + onRetryModels?: () => void; +@@ -293,6 +295,7 @@ export default function ProviderDetails({ + availableModels={availableModels} + hasLiveModels={hasLiveModels} + selectedModels={selectedModels} ++ disabledModels={disabledModels} + modelsLoading={modelsLoading} + modelsLoadFailed={modelsLoadFailed} + needsReauth={ +diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx +index 56cc588292..2f89b886d6 100644 +--- a/gui/src/components/provider-workspace/ProviderModels.tsx ++++ b/gui/src/components/provider-workspace/ProviderModels.tsx +@@ -7,14 +7,19 @@ import { useEffect, useMemo, useRef, useState } from "react"; + import { useT } from "../../i18n/shared"; + import type { WorkspaceItem } from "../../provider-workspace/catalog"; + import { filterModels } from "../../provider-workspace/report"; ++import { IconEyeOff, IconTrash } from "../../icons"; ++import { putModelVisibility } from "../../model-visibility"; + import { encodedModelIdCollides } from "../../../../src/providers/slug-codec"; + ++type CustomModelRef = { id?: string; modelId: string }; ++ + export default function ProviderModels({ + item, + apiBase, + availableModels, + hasLiveModels, + selectedModels, ++ disabledModels, + modelsLoading = false, + modelsLoadFailed = false, + needsReauth = false, +@@ -25,6 +30,7 @@ export default function ProviderModels({ + apiBase: string; + availableModels: string[]; + selectedModels: string[]; ++ disabledModels: string[]; + /** Server-reported: did the last successful discovery return any rows? */ + hasLiveModels: boolean; + modelsLoading?: boolean; +@@ -38,16 +44,20 @@ export default function ProviderModels({ + const [query, setQuery] = useState(""); + const [customModelId, setCustomModelId] = useState(""); + const [customSaving, setCustomSaving] = useState(false); ++ const [removingModelId, setRemovingModelId] = useState(null); ++ const [removedModelIds, setRemovedModelIds] = useState>(() => new Set()); + const [customError, setCustomError] = useState(""); + const [customSuccess, setCustomSuccess] = useState(""); +- const [customModelIds, setCustomModelIds] = useState([]); ++ const [customModels, setCustomModels] = useState([]); + const [customModelsReady, setCustomModelsReady] = useState(false); + const [customModelsLoadFailed, setCustomModelsLoadFailed] = useState(false); + const [customModelsLoadEpoch, setCustomModelsLoadEpoch] = useState(0); + const [copiedId, setCopiedId] = useState(null); + const copyResetRef = useRef(null); + const selectedSet = useMemo(() => new Set(selectedModels), [selectedModels]); ++ const hiddenSet = useMemo(() => new Set([...disabledModels, ...removedModelIds]), [disabledModels, removedModelIds]); + const configuredModels = useMemo(() => item.models ?? [], [item.models]); ++ const customModelIds = useMemo(() => customModels.map(model => model.modelId), [customModels]); + const trimmedCustomModelId = customModelId.trim(); + const knownModelIds = [ + ...availableModels, +@@ -63,8 +73,9 @@ export default function ProviderModels({ + || item.defaultModel === trimmedCustomModelId + || encodedModelIdCollides(trimmedCustomModelId, knownModelIds); + const models = useMemo( +- () => filterModels(availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels), +- [availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels], ++ () => filterModels(availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels) ++ .filter(modelId => !hiddenSet.has(modelId)), ++ [availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels, hiddenSet], + ); + + useEffect(() => { +@@ -76,17 +87,20 @@ export default function ProviderModels({ + const rows: unknown = await response.json(); + if (!Array.isArray(rows)) throw new Error("Invalid custom model list"); + if (!active) return; +- setCustomModelIds(rows.flatMap(row => { ++ setCustomModels(rows.flatMap(row => { + if (!row || typeof row !== "object") return []; +- const model = row as { provider?: unknown; modelId?: unknown }; +- return model.provider === item.name && typeof model.modelId === "string" ? [model.modelId] : []; ++ const model = row as { id?: unknown; provider?: unknown; modelId?: unknown }; ++ return model.provider === item.name ++ && typeof model.modelId === "string" ++ ? [{ ...(typeof model.id === "string" ? { id: model.id } : {}), modelId: model.modelId }] ++ : []; + })); + setCustomModelsLoadFailed(false); + setCustomError(""); + setCustomModelsReady(true); + } catch { + if (!active) return; +- setCustomModelIds([]); ++ setCustomModels([]); + // Without this the component stays permanently unable to add a model: `customModelsReady` + // never flips back and the effect has no trigger left, so a single transient GET failure + // disabled Add until the whole panel remounted. +@@ -136,7 +150,20 @@ export default function ProviderModels({ + body: JSON.stringify({ provider: item.name, modelId: trimmedCustomModelId }), + }); + if (response.ok) { +- setCustomModelIds(ids => ids.includes(trimmedCustomModelId) ? ids : [...ids, trimmedCustomModelId]); ++ const added: unknown = await response.json(); ++ if (!added || typeof added !== "object" || typeof (added as { id?: unknown }).id !== "string") { ++ setCustomError(t("models.customSaveFailed")); ++ return; ++ } ++ const id = (added as { id: string }).id; ++ setCustomModels(models => models.some(model => model.modelId === trimmedCustomModelId) ++ ? models ++ : [...models, { id, modelId: trimmedCustomModelId }]); ++ setRemovedModelIds(ids => { ++ const next = new Set(ids); ++ next.delete(trimmedCustomModelId); ++ return next; ++ }); + setCustomModelId(""); + setCustomSuccess(t("models.customAdded")); + onRetryModels?.(); +@@ -150,6 +177,39 @@ export default function ProviderModels({ + } + }; + ++ const removeModel = async (modelId: string) => { ++ const customModel = customModels.find(model => model.modelId === modelId && model.id); ++ if (removingModelId || !window.confirm(t(customModel ? "models.customDeleteConfirm" : "models.hideConfirm", { name: modelId }))) return; ++ const visibilityTarget = { id: modelId, ...(item.name === "openai" ? { native: true } : {}) }; ++ setRemovingModelId(modelId); ++ setCustomError(""); ++ setCustomSuccess(""); ++ try { ++ if (customModel?.id) { ++ const deleteResponse = await fetch(`${apiBase}/api/custom-models/${encodeURIComponent(customModel.id)}`, { method: "DELETE" }); ++ if (!deleteResponse.ok) { ++ setCustomError(t("models.customSaveFailed")); ++ return; ++ } ++ setCustomModels(models => models.filter(model => model.modelId !== modelId)); ++ } ++ const visibilityResponse = await putModelVisibility(apiBase, "models", item.name, [visibilityTarget], false); ++ if (!visibilityResponse.ok) { ++ onRetryModels?.(); ++ setCustomError(t("models.saveFailed")); ++ return; ++ } ++ setRemovedModelIds(ids => new Set(ids).add(modelId)); ++ setCustomSuccess(t(customModel ? "models.customDeleted" : "models.applied")); ++ onRetryModels?.(); ++ } catch { ++ onRetryModels?.(); ++ setCustomError(t("models.networkError")); ++ } finally { ++ setRemovingModelId(null); ++ } ++ }; ++ + const emptyBase = availableModels.length === 0 + && configuredModels.length === 0 + && customModelIds.length === 0 +@@ -247,7 +307,9 @@ export default function ProviderModels({ + {visibleModels.map(modelId => { + const isDefault = modelId === item.defaultModel; + const isSelected = selectedSet.has(modelId); ++ const isCustom = customModels.some(model => model.modelId === modelId && model.id); + const copied = copiedId === modelId; ++ const removeLabel = t(isCustom ? "models.customDelete" : "models.hide"); + return ( +
  • + +
  • + ); + })} +diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +index 91faecb6fc..24fe187f94 100644 +--- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx ++++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +@@ -24,7 +24,7 @@ import { + import { providerKind } from "../../provider-workspace/kind"; + import { readJsonIfOk, readJsonOrThrow } from "../../fetch-json"; + import { readSessionListCache, writeSessionListCache } from "../../session-list-cache"; +-import { buildProviderModelUsage, buildProviderUsageTotals, countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; ++import { buildProviderModelUsage, buildProviderUsageTotals, countAvailableModels, parseAvailableModels, parseDisabledModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderDisabledModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; + import { + freshQuotaReportRecord, + freshQuotaReportsFromResponse, +@@ -47,6 +47,7 @@ export interface DetailSlotData { + /** Did the last successful discovery return rows? Server-reported, never inferred. */ + hasLiveModels: boolean; + selectedModels: string[]; ++ disabledModels: string[]; + modelsLoading: boolean; + modelsLoadFailed: boolean; + onRetryModels?: () => void; +@@ -141,6 +142,7 @@ export default function ProviderWorkspaceShell({ + const [availableModels, setAvailableModels] = useState({}); + const [liveModelCounts, setLiveModelCounts] = useState({}); + const [selectedModels, setSelectedModels] = useState({}); ++ const [disabledModels, setDisabledModels] = useState({}); + const [modelsLoading, setModelsLoading] = useState(false); + const [modelsLoadFailed, setModelsLoadFailed] = useState(false); + const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`; +@@ -189,6 +191,7 @@ export default function ProviderWorkspaceShell({ + setAvailableModels(parseAvailableModels(data)); + setLiveModelCounts(parseLiveModelCounts(data)); + setSelectedModels(parseSelectedModels(data)); ++ setDisabledModels(parseDisabledModels(data)); + setModelsLoadFailed(false); + succeeded = true; + } catch { +@@ -559,6 +562,7 @@ export default function ProviderWorkspaceShell({ + availableModels: availableModels[selectedItem.name] ?? [], + hasLiveModels: (liveModelCounts[selectedItem.name] ?? 0) > 0, + selectedModels: selectedModels[selectedItem.name] ?? [], ++ disabledModels: disabledModels[selectedItem.name] ?? [], + modelsLoading, + modelsLoadFailed, + onRetryModels: retryModels, +diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts +index 4d4b79e5d5..c29aad7355 100644 +--- a/gui/src/i18n/de.ts ++++ b/gui/src/i18n/de.ts +@@ -577,6 +577,8 @@ export const de: Record = { + "models.customEdit": "Bearbeiten", + "models.customDelete": "Löschen", + "models.customDeleteConfirm": "Modell {name} löschen?", ++ "models.hide": "Ausblenden", ++ "models.hideConfirm": "Modell {name} aus dem Katalog ausblenden?", + "models.customBadge": "Benutzerdefiniert", + "models.customSummary": "{count} benutzerdefiniert", + "models.customFieldModelId": "Modell-ID (Endpunkt-Slug)", +diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts +index cf9eb253dd..ae4761051f 100644 +--- a/gui/src/i18n/en.ts ++++ b/gui/src/i18n/en.ts +@@ -602,6 +602,8 @@ export const en = { + "models.customEdit": "Edit", + "models.customDelete": "Delete", + "models.customDeleteConfirm": "Delete the {name} model?", ++ "models.hide": "Hide", ++ "models.hideConfirm": "Hide the {name} model from the catalog?", + "models.customBadge": "Custom", + "models.customSummary": "{count} custom", + "models.customFieldModelId": "Model ID (endpoint slug)", +diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts +index 2d90382f1a..455898688e 100644 +--- a/gui/src/i18n/fr.ts ++++ b/gui/src/i18n/fr.ts +@@ -587,6 +587,8 @@ export const fr: Record = { + "models.customEdit": "Modifier", + "models.customDelete": "Supprimer", + "models.customDeleteConfirm": "Supprimer le modèle {name} ?", ++ "models.hide": "Masquer", ++ "models.hideConfirm": "Masquer le modèle {name} du catalogue ?", + "models.customBadge": "Personnalisé", + "models.customSummary": "{count} personnalisés", + "models.customFieldModelId": "ID du modèle (slug du point de terminaison)", +diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts +index c9d2e9ea4a..d413daa401 100644 +--- a/gui/src/i18n/ja.ts ++++ b/gui/src/i18n/ja.ts +@@ -2315,6 +2315,8 @@ export const ja: Record = { + "models.customEdit": "Edit", + "models.customDelete": "Delete", + "models.customDeleteConfirm": "Delete the {name} model?", ++ "models.hide": "非表示", ++ "models.hideConfirm": "モデル {name} をカタログから非表示にしますか?", + "models.customBadge": "Custom", + "models.customSummary": "{count} custom", + "models.customFieldModelId": "Model ID (endpoint slug)", +diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts +index d9c983a5fb..7ffa869a59 100644 +--- a/gui/src/i18n/ko.ts ++++ b/gui/src/i18n/ko.ts +@@ -588,6 +588,8 @@ export const ko: Record = { + "models.customEdit": "편집", + "models.customDelete": "삭제", + "models.customDeleteConfirm": "{name} 모델을 삭제하시겠습니까?", ++ "models.hide": "숨기기", ++ "models.hideConfirm": "{name} 모델을 카탈로그에서 숨기시겠습니까?", + "models.customBadge": "커스텀", + "models.customSummary": "커스텀 {count}개", + "models.customFieldModelId": "모델 ID (엔드포인트 슬러그)", +diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts +index 950cea7a81..324eae13fd 100644 +--- a/gui/src/i18n/ru.ts ++++ b/gui/src/i18n/ru.ts +@@ -590,6 +590,8 @@ export const ru: Record = { + "models.customEdit": "Изменить", + "models.customDelete": "Удалить", + "models.customDeleteConfirm": "Удалить модель {name}?", ++ "models.hide": "Скрыть", ++ "models.hideConfirm": "Скрыть модель {name} из каталога?", + "models.customBadge": "Пользовательская", + "models.customSummary": "Пользовательских: {count}", + "models.customFieldModelId": "ID модели (slug эндпоинта)", +diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts +index 5db3ca23b5..37865f57c8 100644 +--- a/gui/src/i18n/tr.ts ++++ b/gui/src/i18n/tr.ts +@@ -593,6 +593,8 @@ export const tr: Record = { + "models.customEdit": "Düzenle", + "models.customDelete": "Sil", + "models.customDeleteConfirm": "{name} modeli silinsin mi?", ++ "models.hide": "Gizle", ++ "models.hideConfirm": "{name} modeli katalogda gizlensin mi?", + "models.customBadge": "Özel", + "models.customSummary": "{count} özel", + "models.customFieldModelId": "Model ID", +diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts +index 06f8e6fa4b..b8a842bbcf 100644 +--- a/gui/src/i18n/zh-TW.ts ++++ b/gui/src/i18n/zh-TW.ts +@@ -456,6 +456,8 @@ export const zhTW: Record = { + "models.customEdit": "編輯", + "models.customDelete": "刪除", + "models.customDeleteConfirm": "要刪除模型 {name} 嗎?", ++ "models.hide": "隱藏", ++ "models.hideConfirm": "要從目錄中隱藏模型 {name} 嗎?", + "models.customBadge": "自訂", + "models.customSummary": "{count} 個自訂模型", + "models.customFieldModelId": "模型 ID(端點標識)", +diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts +index 315869d88d..28758e6157 100644 +--- a/gui/src/i18n/zh.ts ++++ b/gui/src/i18n/zh.ts +@@ -585,6 +585,8 @@ export const zh: Record = { + "models.customEdit": "编辑", + "models.customDelete": "删除", + "models.customDeleteConfirm": "要删除模型 {name} 吗?", ++ "models.hide": "隐藏", ++ "models.hideConfirm": "要从目录中隐藏模型 {name} 吗?", + "models.customBadge": "自定义", + "models.customSummary": "{count} 个自定义模型", + "models.customFieldModelId": "模型 ID(端点标识)", +diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx +index 6ec2ebf096..70afe03db9 100644 +--- a/gui/src/icons.tsx ++++ b/gui/src/icons.tsx +@@ -24,6 +24,7 @@ export const IconRefresh = (p: P) => (); + export const IconPlay = (p: P) => (); + export const IconTrash = (p: P) => (); ++export const IconEyeOff = (p: P) => (); + export const IconPencil = (p: P) => (); + export const IconAlert = (p: P) => (); + export const IconInfo = (p: P) => (); +diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx +index 8701b3acd3..84832e2d1b 100644 +--- a/gui/src/pages/Providers.tsx ++++ b/gui/src/pages/Providers.tsx +@@ -459,6 +459,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { + availableModels={data.availableModels} + hasLiveModels={data.hasLiveModels} + selectedModels={data.selectedModels} ++ disabledModels={data.disabledModels} + modelsLoading={data.modelsLoading} + modelsLoadFailed={data.modelsLoadFailed} + onRetryModels={data.onRetryModels} +diff --git a/gui/src/provider-workspace/usage.ts b/gui/src/provider-workspace/usage.ts +index 033bdbcee2..4cae58417a 100644 +--- a/gui/src/provider-workspace/usage.ts ++++ b/gui/src/provider-workspace/usage.ts +@@ -16,6 +16,7 @@ import type { ProviderModelUsageRow } from "../components/provider-workspace/typ + export type ProviderModelCounts = Record; + export type ProviderAvailableModels = Record; + export type ProviderSelectedModels = Record; ++export type ProviderDisabledModels = Record; + + /** Parse `/api/selected-models` available map into provider -> model id list. */ + export function parseAvailableModels(data: unknown): ProviderAvailableModels { +@@ -64,10 +65,26 @@ export function parseSelectedModels(data: unknown): ProviderSelectedModels { + return models; + } + ++/** Parse `/api/selected-models` disabled map into provider -> hidden model id list. */ ++export function parseDisabledModels(data: unknown): ProviderDisabledModels { ++ if (!data || typeof data !== "object") return {}; ++ const disabled = (data as { disabled?: unknown }).disabled; ++ if (!disabled || typeof disabled !== "object" || Array.isArray(disabled)) return {}; ++ ++ const models: ProviderDisabledModels = {}; ++ for (const [provider, ids] of Object.entries(disabled)) { ++ if (!Array.isArray(ids)) continue; ++ models[provider] = ids.filter((id): id is string => typeof id === "string"); ++ } ++ return models; ++} ++ + export function countAvailableModels(data: unknown): ProviderModelCounts { + const counts: ProviderModelCounts = {}; ++ const disabled = parseDisabledModels(data); + for (const [provider, models] of Object.entries(parseAvailableModels(data))) { +- counts[provider] = models.length; ++ const hidden = new Set(disabled[provider] ?? []); ++ counts[provider] = models.filter(model => !hidden.has(model)).length; + } + return counts; + } +diff --git a/gui/tests/provider-model-custom-add.test.tsx b/gui/tests/provider-model-custom-add.test.tsx +index 6ebc04134a..ee3046845b 100644 +--- a/gui/tests/provider-model-custom-add.test.tsx ++++ b/gui/tests/provider-model-custom-add.test.tsx +@@ -5,6 +5,7 @@ import type { Root } from "react-dom/client"; + import { LanguageProvider } from "../src/i18n/provider"; + import ProviderModels from "../src/components/provider-workspace/ProviderModels"; + import type { WorkspaceItem } from "../src/provider-workspace/catalog"; ++import { countAvailableModels } from "../src/provider-workspace/usage"; + + const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const originalFetch = globalThis.fetch; +@@ -40,11 +41,17 @@ const item = { + defaultModel: "claude-opus-5", + } as WorkspaceItem; + ++test("provider model counts exclude removed models", () => { ++ expect(countAvailableModels({ available: { vendor: ["a", "b", "c"] }, disabled: { vendor: ["b"] } })) ++ .toEqual({ vendor: 2 }); ++}); ++ + async function mountProviderModels( + availableModels = ["claude-opus-5"], + onRetryModels?: () => void, + providerItem = item, + hasLiveModels = true, ++ disabledModels: string[] = [], + ): Promise<{ root: Root; container: HTMLElement; input: HTMLInputElement; addButton: HTMLButtonElement }> { + const container = document.createElement("div"); + document.body.append(container); +@@ -59,6 +66,7 @@ async function mountProviderModels( + availableModels={availableModels} + hasLiveModels={hasLiveModels} + selectedModels={[]} ++ disabledModels={disabledModels} + apiBase="http://localhost:10100" + onRetryModels={onRetryModels} + /> +@@ -205,6 +213,194 @@ test("custom-only catalog keeps configured fallback models visible", async () => + await act(async () => { root.unmount(); }); + }); + ++test("custom models use their stable id and persist discovered-model visibility when deleted", async () => { ++ const requests: Array<{ url: string; method: string; body: unknown }> = []; ++ globalThis.fetch = (async (input, init) => { ++ if (!init?.method || init.method === "GET") { ++ return Response.json([ ++ { id: "custom-1", provider: "AiCodeWith", modelId: "claude-opus-5.1-custom" }, ++ ]); ++ } ++ requests.push({ ++ url: String(input), ++ method: init.method, ++ body: typeof init.body === "string" ? JSON.parse(init.body) : undefined, ++ }); ++ return Response.json({ ok: true }); ++ }) as typeof fetch; ++ testWindow.confirm = () => true; ++ ++ let refreshes = 0; ++ const { root, container } = await mountProviderModels( ++ ["claude-opus-5", "claude-opus-5.1-custom"], ++ () => { refreshes += 1; }, ++ ); ++ await act(async () => { await Promise.resolve(); }); ++ ++ const customChip = [...container.querySelectorAll(".pws-model-chip")] ++ .find(chip => chip.querySelector(".pws-model-id")?.textContent === "claude-opus-5.1-custom")!; ++ const deleteButton = customChip.querySelector('button[aria-label="Delete"]')!; ++ await act(async () => { ++ deleteButton.click(); ++ await Promise.resolve(); ++ await Promise.resolve(); ++ }); ++ ++ expect(requests).toEqual([ ++ { ++ url: "http://localhost:10100/api/custom-models/custom-1", ++ method: "DELETE", ++ body: undefined, ++ }, ++ { ++ url: "http://localhost:10100/api/model-visibility", ++ method: "PUT", ++ body: { ++ scope: "models", ++ provider: "AiCodeWith", ++ targets: [{ id: "claude-opus-5.1-custom" }], ++ enabled: false, ++ }, ++ }, ++ ]); ++ expect(refreshes).toBe(1); ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5"]); ++ expect(container.querySelector('[role="status"]')?.textContent).toContain("Custom model deleted"); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("a custom model stays visible when its visibility update fails", async () => { ++ globalThis.fetch = (async (_input, init) => { ++ if (!init?.method || init.method === "GET") { ++ return Response.json([ ++ { id: "custom-1", provider: "AiCodeWith", modelId: "claude-opus-5.1-custom" }, ++ ]); ++ } ++ if (init.method === "DELETE") return Response.json({ ok: true }); ++ return Response.json({ error: "failed" }, { status: 500 }); ++ }) as typeof fetch; ++ testWindow.confirm = () => true; ++ ++ const { root, container } = await mountProviderModels(["claude-opus-5.1-custom"]); ++ await act(async () => { await Promise.resolve(); }); ++ ++ await act(async () => { ++ container.querySelector('button[aria-label="Delete"]')!.click(); ++ await Promise.resolve(); ++ await Promise.resolve(); ++ }); ++ ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5.1-custom"]); ++ expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save failed"); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("discovered models are labeled as hidden and removed from the local catalog", async () => { ++ const requests: Array<{ url: string; method: string; body: unknown }> = []; ++ globalThis.fetch = (async (input, init) => { ++ if (!init?.method || init.method === "GET") return Response.json([]); ++ requests.push({ ++ url: String(input), ++ method: init.method, ++ body: typeof init.body === "string" ? JSON.parse(init.body) : undefined, ++ }); ++ return Response.json({ ok: true }); ++ }) as typeof fetch; ++ let confirmation = ""; ++ testWindow.confirm = message => { ++ confirmation = String(message); ++ return true; ++ }; ++ ++ let refreshes = 0; ++ const { root, container } = await mountProviderModels( ++ ["claude-opus-5", "claude-sonnet-5"], ++ () => { refreshes += 1; }, ++ ); ++ await act(async () => { await Promise.resolve(); }); ++ ++ const discoveredChip = [...container.querySelectorAll(".pws-model-chip")] ++ .find(chip => chip.querySelector(".pws-model-id")?.textContent === "claude-sonnet-5")!; ++ const hideButton = discoveredChip.querySelector('button[aria-label="Hide"]')!; ++ expect(hideButton.title).toBe("Hide"); ++ await act(async () => { ++ hideButton.click(); ++ await Promise.resolve(); ++ }); ++ ++ expect(confirmation).toBe("Hide the claude-sonnet-5 model from the catalog?"); ++ expect(requests).toEqual([{ ++ url: "http://localhost:10100/api/model-visibility", ++ method: "PUT", ++ body: { ++ scope: "models", ++ provider: "AiCodeWith", ++ targets: [{ id: "claude-sonnet-5" }], ++ enabled: false, ++ }, ++ }]); ++ expect(refreshes).toBe(1); ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5"]); ++ expect(container.querySelector('[role="status"]')?.textContent).toContain("Applied"); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("native OpenAI models use a native visibility target when removed", async () => { ++ const requests: unknown[] = []; ++ globalThis.fetch = (async (_input, init) => { ++ if (!init?.method || init.method === "GET") return Response.json([]); ++ requests.push(typeof init.body === "string" ? JSON.parse(init.body) : undefined); ++ return Response.json({ ok: true }); ++ }) as typeof fetch; ++ testWindow.confirm = () => true; ++ const openAiItem = { ++ ...item, ++ name: "openai", ++ models: ["gpt-5.5"], ++ defaultModel: "gpt-5.5", ++ } as WorkspaceItem; ++ ++ const { root, container } = await mountProviderModels(["gpt-5.5"], undefined, openAiItem); ++ await act(async () => { await Promise.resolve(); }); ++ ++ await act(async () => { ++ container.querySelector('button[aria-label="Hide"]')!.click(); ++ await Promise.resolve(); ++ }); ++ ++ expect(requests).toEqual([{ ++ scope: "models", ++ provider: "openai", ++ targets: [{ id: "gpt-5.5", native: true }], ++ enabled: false, ++ }]); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("disabled discovered models stay out of the provider model list", async () => { ++ globalThis.fetch = (async () => Response.json([])) as typeof fetch; ++ const { root, container } = await mountProviderModels( ++ ["claude-opus-5", "claude-sonnet-5"], ++ undefined, ++ item, ++ true, ++ ["claude-sonnet-5"], ++ ); ++ await act(async () => { await Promise.resolve(); }); ++ ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5"]); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ + // A single transient GET used to leave `customModelsReady` false forever: the effect had no + // remaining trigger, so Add stayed disabled until the whole panel remounted. Drive the full + // recovery in one mount: failed load -> retry -> successful load -> Add enabled -> exactly one POST. +diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts +index f810bfb422..55b90cfea4 100644 +--- a/src/codex/catalog/provider-fetch.ts ++++ b/src/codex/catalog/provider-fetch.ts +@@ -1506,11 +1506,14 @@ async function fetchProviderModelsWithAuth( + && prov.googleMode === "vertex" + && (prov.models?.length ?? 0) === 0 + && Boolean(prov.defaultModel); +- // Ordered dedupe union: Vertex seed, then `models`, then `retainModels`. `configured` is the ++ const seedStaticDefault = prov.liveModels === false ++ && (prov.models?.length ?? 0) === 0 ++ && Boolean(prov.defaultModel); ++ // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the + // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints, + // so a retain-only id must enter here or it never exists to be retained (#1690). + const configuredIds = [...new Set([ +- ...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []), ++ ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []), + ...(prov.models ?? []), + ...(prov.retainModels ?? []), + ])]; +@@ -1563,9 +1566,8 @@ async function fetchProviderModelsWithAuth( + : resolveAuth.resolve(name, prov)); + const apiKey = auth.apiKey; + // A configured default is a real callable selector and must remain discoverable when a +- // compatible provider's live /models request fails (issue #308). Keep this separate from the +- // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero +- // rows, while a failed live discovery may degrade to the default selector. ++ // compatible provider's live /models request fails (issue #308). Static providers already seed ++ // their default selector above when no explicit model list exists. + const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic" + ? configured + : [{ +diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts +index e9ea26a90e..fd3f1eb43c 100644 +--- a/src/server/management/model-routes.ts ++++ b/src/server/management/model-routes.ts +@@ -787,7 +787,14 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise = {}; +- for (const m of models) (available[m.provider] ??= []).push(m.id); ++ const disabled: Record = {}; ++ const disabledSlugs = config.disabledModels ?? []; ++ for (const m of models) { ++ (available[m.provider] ??= []).push(m.id); ++ if (disabledSlugs.some(slug => slugEquals(slug, m.provider, m.id))) { ++ (disabled[m.provider] ??= []).push(m.id); ++ } ++ } + const selected: Record = {}; + // Live-catalog provenance. The GUI cannot infer this by subtracting known custom ids: an id + // that is both custom and discovered would make a real live catalog look custom-only. +@@ -797,7 +804,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { + .toEqual([]); + }); + ++ test("filters dashboard-hidden provider models before catalog sync", () => { ++ const models = [ ++ { provider: "vendor", id: "visible-model" }, ++ { provider: "vendor", id: "hidden-model" }, ++ ]; ++ ++ expect(filterCatalogVisibleModels(models, { ++ disabledModels: ["vendor/hidden-model"], ++ providers: { vendor: {} }, ++ })).toEqual([{ provider: "vendor", id: "visible-model" }]); ++ }); ++ + test("repairs a provider row after its shadowing combo alias is disabled", () => { + const alias = "vendor/deepseek-v4-flash"; + const combo = deriveComboCatalogModel( +@@ -4061,6 +4073,36 @@ describe("Codex catalog routed normalization", () => { + } + }); + ++ test("liveModels false uses the default model when no static list is configured", async () => { ++ const originalFetch = globalThis.fetch; ++ let fetchCalls = 0; ++ globalThis.fetch = (() => { ++ fetchCalls += 1; ++ throw new Error("fetch should not be called"); ++ }) as typeof fetch; ++ try { ++ const models = await gatherRoutedModels({ ++ providers: { ++ "static-default": { ++ baseUrl: "https://example.invalid/v1", ++ adapter: "openai-chat", ++ authMode: "key", ++ liveModels: false, ++ defaultModel: "only-model", ++ }, ++ }, ++ }); ++ ++ expect(fetchCalls).toBe(0); ++ expect(models.map(m => `${m.provider}/${m.id}`)).toEqual([ ++ "static-default/only-model", ++ ]); ++ } finally { ++ globalThis.fetch = originalFetch; ++ clearModelCache("static-default"); ++ } ++ }); ++ + test("Google Antigravity honors an explicit static catalog and suppresses stale discovery", async () => { + const providerName = "google-antigravity"; + const provider = structuredClone(OAUTH_PROVIDERS[providerName].providerConfig); +diff --git a/tests/server/model-discovery-management-api.test.ts b/tests/server/model-discovery-management-api.test.ts +index ad132847bb..6d44c2f9ea 100644 +--- a/tests/server/model-discovery-management-api.test.ts ++++ b/tests/server/model-discovery-management-api.test.ts +@@ -39,4 +39,12 @@ describe("model discovery management API", () => { + expect(live.modelDiscovery.recentArrivals?.vendor).toEqual([]); + expect(live.disabledModels).toEqual(["vendor/new"]); + }); ++ ++ test("selected-models reports disabled discovered model ids by provider", async () => { ++ const live = config(); ++ live.disabledModels = ["vendor/known", "other/ignored"]; ++ const result = await call(live, "/api/selected-models"); ++ expect(result.json.available).toEqual({ vendor: ["known"] }); ++ expect(result.json.disabled).toEqual({ vendor: ["known"] }); ++ }); + }); + +```` diff --git a/devlog/_plan/260906_lane_b_catalog_stack/041_static_verification.md b/devlog/_plan/260906_lane_b_catalog_stack/041_static_verification.md new file mode 100644 index 0000000000..bc52d90e7b --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/041_static_verification.md @@ -0,0 +1,17 @@ +# 041 — Static default verification boundary + +Head `2e9006609` produced 281 passing catalog tests and one failure in both remote +and hosted verification. Its old Go fixture expected no models, but existing +registry ownership and capture-time enrichment already supplied `kimi-k2.7-code` +as that provider's effective default. The new static seed intentionally publishes it. + +The production seed remains unchanged. The replacement assertion requires exactly +that one inherited default and no metadata-roster augmentation, for omitted and +empty model lists, with zero upstream requests. A distinct custom MiMo destination +checks the existing strict transport guard, no inherited default or list, and an +exactly empty authoritative result. Unregistered no-default, explicit-list, +retention, forward and OAuth no-request controls remain in place. + +Independent source review confirmed this is an obsolete expectation for the +intentional new contract, not evidence of a new endpoint-ownership defect. The +initial failing result stays recorded; the amended tests require fresh verification. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/042_management_build.md b/devlog/_plan/260906_lane_b_catalog_stack/042_management_build.md new file mode 100644 index 0000000000..5dd7c2a5a1 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/042_management_build.md @@ -0,0 +1,23 @@ +# 042 — Canonical provider-workspace model controls + +The UI child carries the remaining source #3659 changes by gqchen, then adapts +them to the existing model-row authority. The proposed disabled-map API, parser +and server assertion were removed; their net diff from the static parent is zero. + +The workspace adopts `/api/models` with the full selection/provenance response. +Actions require the current parent revision and matching custom ownership. +Delete removes one custom definition; Hide changes the represented row's +visibility. Neither adds a second mutation, an implicit unhide or a browser-only +removal marker. Namespaced identity separates account-native/custom collisions. + +The implementation preserves ordinary raw-ID copying, native default badges and +the configured-fallback hint's prior condition. Missing discovery provenance +remains unknown; malformed present data and invalid action identity are rejected. +Icon-only actions expose their exact target in accessible names. + +Regression additions cover real API persistence/restoration and UI response-order, +single-flight, uncertain-result, remount, count, focus and identity scenarios. +All nine locales and eight dashboard guides describe the same contract, retaining +other lanes' changes. Independent C4 source review passed; execution and actual +compiled-browser evidence remain C gates. The inherited source screenshot is +historical and must be replaced before this child is review-ready. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/043_carry_boundary.md b/devlog/_plan/260906_lane_b_catalog_stack/043_carry_boundary.md new file mode 100644 index 0000000000..2bf9f77144 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/043_carry_boundary.md @@ -0,0 +1,20 @@ +# 043 — Preserve changes already integrated before the source carry + +The first UI verification at `1b90dba7` passed 23 API tests and 157 GUI tests, +with one GUI locator failure. Its build also exposed three missing Grok text keys. + +The carry used the PR's target-tip snapshot `af50c6d3` as a diff base, but source +`ff4e5cd5` actually shares merge base `6585e6a7` with that target. This accidentally +reversed an already integrated change in nine locale files and one English +provider-reference row. The original contributor's feature did not make those +reversals; this was a carry-boundary error. + +Restore only that established target delta, preserving the new management keys +and controls. Future carries use the actual Git merge base and review target +drift separately. The earlier B sources #3653, #3654 and #3571 were checked: +their recorded bases equal their actual merge bases, with no overlapping drift. + +The GUI locator must use the displayed provider name rather than assuming its +capitalization; all inventory, search/cap and native-selection assertions remain. +The repaired head requires fresh GUI execution and build evidence. Initial +failures remain recorded and are not reported as passing checks. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/044_react_doctor.md b/devlog/_plan/260906_lane_b_catalog_stack/044_react_doctor.md new file mode 100644 index 0000000000..26c2c72c74 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/044_react_doctor.md @@ -0,0 +1,18 @@ +# 044 — Separate React Doctor gate + +The cross-platform workflow passed at `b33d9347`, but the separate pinned React +Doctor 0.9.11 workflow failed. A cold remote reproduction reported one repeated +array-lookup warning, three test-render global-publication errors, and one unused +timestamp binding in a changed test file. Ordinary lint/build success did not +resolve this gate. + +Use a Set for selected-model membership while preserving the native exclusion. +Publish test controls from an effect, release only their owned handles on unmount, +and call the current harness setter directly from its retry callback. Remove the +unused pure timestamp calculation without changing the stale-coverage fixture. +No assertions, waiting bounds, scanner rules or warning threshold are relaxed. + +The repaired candidate needs a cold pinned scan, the affected GUI tests and its +current-head hosted checks. Existing browser evidence remains evidence of the +recorded source; reuse requires an explicit comparison of the small membership +lookup change rather than pretending the application bytes are unchanged. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/050_fable.md b/devlog/_plan/260906_lane_b_catalog_stack/050_fable.md new file mode 100644 index 0000000000..24e6e61080 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/050_fable.md @@ -0,0 +1,344 @@ +# 050 — Preserve Fable 1M picker selection (source PR #3649) + +Status: implementation plan only; no implementation or runtime validation performed. +Anchored 2026-09-06 to checkout `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +All paths below are repository-relative. Main owns 000, the goalplan, integration, and phase transitions. + +## Loop specification + +- Class: C3 catalog-to-ingress contract; the native passthrough portion requires explicit security review under MAINTAINERS.md:57-71. This research task is docs-only. +- Archetype: spec-satisfaction repair; one later PABCD cycle for this entire numbered plan. +- Trigger: Fable base and marked canonical selectors collapse into one client picker family (public source-PR report). +- Goal: retain independently selectable canonical Fable and 1M rows while forwarding canonical Fable on Messages/count_tokens. +- Non-goals: other Anthropic families, model-cap changes, generalized alias rewrite, Desktop registry repair (#3646), auth-policy changes, GUI component work, release operations. +- Verifier: exact-head hosted Cross-platform CI with actual Linux/macOS tests and gates; client picker evidence is a separate required artifact, not inferred from a unit test. +- Stop: all acceptance rows below have evidence, source contribution survives carry/squash, and main proves the carry is on dev before closing #3649. DONE is not available from this document alone. +- Memory artifact: this document plus main's 000 index and scratch review `.tmp/lane-b/plan-fable-review.md`. +- Bounds for this delegated research: local read-only source/metadata, two permitted documentation writes, no credentials or paid provider requests, no recursive workers needed; finish one bounded research pass. The consuming implementation inherits 000_plan.md: no numeric token/cost cap, six-hour work-phase checkpoint; restate this at the consuming P. +- Escalation: return concrete blockers to main; downstream delegation requires a P amendment; main reclaims a packet after two distinct workers fail it. +- Terminal meanings: DONE = later verified landing; NOOP = fresh dev already supplies the exact behavior/tests; BLOCKED = missing external CI/client evidence; UNSAFE/NEEDS_HUMAN = unresolved review decision; resource bounds never imply DONE. + +## Source and attribution + +Public source: https://github.com/lidge-jun/opencodex/pull/3649 + +- Source base: `45f3bed84be10a7e045a20aae1db46ab822bf7d0`. +- Source head: `95becce94255982667cef10308806770d49cc05b`. +- Behavior commit: `9a7795aa34df219654512366040d87a219fb4ada` (`fix(gateway): preserve Fable 1M picker selection`). +- Follow-up regression: `284fe8ca0b793d51c6cdd609f1c9acf219f2eaf9` (`test(gateway): cover marked Fable picker alias`). +- Head is a merge of `284fe8ca0` and `45f3bed84`; do not cherry-pick that merge as a third implementation change. +- Actual Git author AND committer of all three: `Éverton Toffanetto ` (`everton-dgn`). Verified with `git show -s --format=fuller` against locally available commit objects, corroborated by the supplied JSON commit list. +- Any squash or rewritten carry must include `Co-authored-by: Éverton Toffanetto `. Preserve original authors on cherry-picked commits; attribution in prose alone is insufficient. +- Input evidence: `.tmp/lane-b/3649.json` and `.tmp/lane-b/3649.patch`. JSON reports OPEN/MERGEABLE; this is a supplied snapshot, not a new live readiness claim. It contains no successful hosted CI evidence. + +## Current owners and activation path + +1. `src/server/index.ts:1479-1516` serves Anthropic discovery and calls `buildAnthropicModelInfos`. `?ids=cli` or a `claude-code/` UA selects readable IDs; explicit desktop/unknown UA retains Desktop IDs. Native registry setup remains untouched. +2. `src/claude/model-info.ts:146-165` owns 1M row generation. It requires authoritative context >= 1M, rejects already-marked IDs, deduplicates, and caps advertised input at min(1M, maxInputTokens). +3. `src/claude/model-info.ts:196-227` owns routed row emission; `listedModelId` already reflects the Cursor Fast exception. Put the Fable condition here, not in the generic alias encoder. +4. `src/claude/alias.ts:141-148` leaves canonical Anthropic IDs bare and exposes reversible native aliases. `resolveAlias` at :112-128 returns bare slugs for the native pseudo-provider. Reuse these functions without changing their public exports. +5. `src/claude/inbound-model-options.ts:39-68` resolves aliases before modelMap. `src/claude/inbound.ts` exports the resolver used by the server. Preserve that order. +6. `src/server/claude-messages.ts:634-668` strips `[1m]`, honors ocx-route, then parses Fast/effort. Insert the narrowly scoped Fable restoration between route override and synthetic-row parsing. `wantsNativePassthrough` at :151-168 subsequently examines the canonical model. +7. `src/server/claude-messages.ts:1069-1096` has the corresponding count_tokens path; insert restoration after countRoute and before Fast-only normalization. + +No configuration-only remedy repairs the emitted selector identity. No-op is ruled out by the current generic `${base.id}[1m]` at model-info.ts:153 and the absent helper/call sites. Reuse wins over a new registry, provider, flag, or global decoder. + +## Exact change map for the later implementation cycle + +| Operation | Path | Change | +|---|---|---| +| MODIFY | `src/claude/model-info.ts` | Optional selectorId in local push1mVariant; readable canonical Anthropic Fable-only 1M alias | +| MODIFY | `src/server/claude-messages.ts` | Import existing encoder; private Fable decoder; two ingress call sites | +| MODIFY | `tests/claude-integration/claude-model-info.test.ts` | Source positive test plus Fable-specific style/window regressions below | +| MODIFY | `tests/claude-integration/claude-native-passthrough.test.ts` | Source three-request regression plus canonical legacy/marker compatibility assertions | +| MODIFY | `docs-site/src/content/docs/guides/claude-code.md` | Explain the bounded Fable 1M exception in both canonical-ID statements | +| NEW | none (production/tests) | Existing owners and registered tests suffice | + +This research writes only this numbered document and the delegated scratch report. No schema/layout manifest update is needed: both test basenames already exist in `scripts/test-layout/layout.json:296-298` and `tests/fixtures/test-layout-expected.json:133-135`. + +## Focused source patch to carry + +Use the complete public four-file patch below. It includes the follow-up marked Messages case; carrying only the first commit drops that regression. Context line numbers belong to the source patch; match current owners above and refresh at P. + +```diff +diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts +index cbecf8c60a..bcd047a281 100644 +--- a/src/claude/model-info.ts ++++ b/src/claude/model-info.ts +@@ -143,14 +143,19 @@ export function buildAnthropicModelInfos( + // the auto-context widening that let a 372K route carry the marker (and be + // over-filled) is the #854 defect and does not come back. Guards (audit R1#11): + // same dedupe set, never double-suffix. +- const push1mVariant = (base: AnthropicModelInfo, contextWindow: number | undefined, maxInputTokens?: number) => { ++ const push1mVariant = ( ++ base: AnthropicModelInfo, ++ contextWindow: number | undefined, ++ maxInputTokens?: number, ++ selectorId?: string, ++ ) => { + // The [1m] marker makes Claude Code account 1e6 tokens for the row, so it + // may only name models whose AUTHORITATIVE effective window is >= 1M — + // never the auto-context widening, which would mark a 372K route and have + // Claude Code over-fill it (the #854 defect). + if (contextWindow === undefined || contextWindow < ONE_MILLION) return; + if (base.id.includes("[1m]")) return; +- const id = `${base.id}[1m]`; ++ const id = selectorId ?? `${base.id}[1m]`; + if (seen.has(id)) return; + seen.add(id); + // The marker fixes Claude Code's accounting at 1e6, but a model may accept less input +@@ -220,7 +225,15 @@ export function buildAnthropicModelInfos( + out.push(info); + // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude + // routes — only a genuine >=1M window earns the variant row there. +- push1mVariant(info, m.contextWindow, routedMaxInput); ++ // Claude Code groups canonical Fable ids before it compares the [1m] marker. This ++ // reversible alias only separates picker families; it is not an OpenAI-native route. ++ // The Messages ingress restores the canonical Anthropic id before passthrough. ++ const oneMillionSelector = idStyle === "readable" ++ && m.provider === "anthropic" ++ && listedModelId.startsWith("claude-fable-") ++ ? `${claudeCodeNativeAlias(listedModelId)}[1m]` ++ : undefined; ++ push1mVariant(info, m.contextWindow, routedMaxInput, oneMillionSelector); + // The whole model is passed, not a (provider, id) pair: a combo row lives in its own + // namespace with no config.providers entry, so the caller classifies it from the + // aggregated supportsServiceTier the row already carries. +diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts +index 70572f9c67..d2eedfa4ab 100644 +--- a/src/server/claude-messages.ts ++++ b/src/server/claude-messages.ts +@@ -12,6 +12,7 @@ import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/a + import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; + import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; + import { resolveDesktop3pAlias } from "../claude/desktop-3p"; ++import { claudeCodeNativeAlias } from "../claude/alias"; + import { recordDesktopRequest } from "../claude/desktop-health"; + import { stripOneMillionMarker } from "../claude/context-windows"; + import { captureClaudeInbound } from "../claude/inbound-debug"; +@@ -78,6 +79,13 @@ function decodeClaudeFastSelector(raw: string, cc?: OcxConfig["claudeCode"]): st + return decodedBase === bare ? exact : `${decodedBase}--fast`; + } + ++/** Restore the reversible Fable picker alias before Anthropic passthrough checks. */ ++function decodeFablePickerAlias(raw: string, cc?: OcxConfig["claudeCode"]): string { ++ const decoded = resolveInboundModel(raw, cc); ++ if (!decoded.startsWith("claude-fable-")) return raw; ++ return claudeCodeNativeAlias(decoded) === raw ? decoded : raw; ++} ++ + function isRec(v: unknown): v is Rec { + return !!v && typeof v === "object" && !Array.isArray(v); + } +@@ -648,6 +656,9 @@ async function handleClaudeMessagesWithBudget( + effortOverride = extractOcxEffortDirective(anthropicBody); + } + } ++ if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { ++ anthropicBody.model = decodeFablePickerAlias(anthropicBody.model, config.claudeCode); ++ } + if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + requestedModel = anthropicBody.model; + // Decode for Fast only. A Claude alias is `claude-ocx---`, so it +@@ -1070,6 +1081,8 @@ export async function handleClaudeCountTokens( + model = stripOneMillionMarker(countRoute); + raw.model = model; + } ++ model = decodeFablePickerAlias(model, config.claudeCode); ++ raw.model = model; + // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a + // token estimate and sends no tier, so only the IDENTITY is corrected - without this the + // synthetic id reaches native passthrough as a model Anthropic has never heard of. +diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts +index 0a2d151a1b..b9a23342af 100644 +--- a/tests/claude-integration/claude-model-info.test.ts ++++ b/tests/claude-integration/claude-model-info.test.ts +@@ -44,6 +44,22 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => + expect(info!.capabilities.effort.max.supported).toBe(true); + }); + ++ test("readable Fable rows keep base and 1M selections distinct in Claude Code", () => { ++ const infos = buildAnthropicModelInfos([], [{ ++ provider: "anthropic", ++ id: "claude-fable-5-1", ++ contextWindow: 1_000_000, ++ maxInputTokens: 1_000_000, ++ }], undefined, "readable"); ++ ++ expect(infos.map(info => info.id)).toEqual([ ++ "claude-fable-5-1", ++ "claude-ocx-native--claude-fable-5-1[1m]", ++ ]); ++ expect(infos[1]!.display_name).toBe("claude-fable-5-1 (anthropic) · 1M"); ++ expect(infos[1]!.max_input_tokens).toBe(1_000_000); ++ }); ++ + test("native effective ladder only advertises clamp-identity rungs (audit R4#1)", () => { + for (const slug of ["gpt-5.5", "gpt-5.4", "gpt-5.6-sol"]) { + for (const rung of nativeEffectiveLadder(slug)) { +diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts +index c8c798ac9a..af87aa83cb 100644 +--- a/tests/claude-integration/claude-native-passthrough.test.ts ++++ b/tests/claude-integration/claude-native-passthrough.test.ts +@@ -206,6 +206,47 @@ test("count_tokens passes through with native credentials", async () => { + } + }); + ++test("Fable 1M picker alias preserves native passthrough on both Messages endpoints", async () => { ++ const captured: Captured[] = []; ++ const upstream = mockAnthropicUpstream(captured); ++ saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); ++ const server = startServer(0); ++ const pickerModel = "claude-ocx-native--claude-fable-5-1"; ++ try { ++ const messagesWithoutMarker = await fetch(new URL("/v1/messages", server.url), { ++ method: "POST", ++ headers: OAUTH_HEADERS, ++ body: JSON.stringify({ ...claudeBody(), model: pickerModel }), ++ }); ++ expect(messagesWithoutMarker.status).toBe(200); ++ await messagesWithoutMarker.text(); ++ ++ const messagesWithMarker = await fetch(new URL("/v1/messages", server.url), { ++ method: "POST", ++ headers: OAUTH_HEADERS, ++ body: JSON.stringify({ ...claudeBody(), model: `${pickerModel}[1m]` }), ++ }); ++ expect(messagesWithMarker.status).toBe(200); ++ await messagesWithMarker.text(); ++ ++ const countTokens = await fetch(new URL("/v1/messages/count_tokens", server.url), { ++ method: "POST", ++ headers: OAUTH_HEADERS, ++ body: JSON.stringify({ model: `${pickerModel}[1m]`, messages: [{ role: "user", content: "hi" }] }), ++ }); ++ expect(countTokens.status).toBe(200); ++ expect(await countTokens.json()).toEqual({ input_tokens: 4242 }); ++ ++ expect(captured).toHaveLength(3); ++ expect(captured[0]!.body.model).toBe("claude-fable-5-1"); ++ expect(captured[1]!.body.model).toBe("claude-fable-5-1"); ++ expect(captured[2]!.body.model).toBe("claude-fable-5-1"); ++ } finally { ++ await server.stop(true); ++ upstream.stop(true); ++ } ++}); ++ + test("exposed native passthrough requires dedicated admission and never forwards admission credentials", async () => { + const admissionSecret = "sk-ant-api03-key"; + const providerBearer = "sk-ant-oat01-provider"; +``` + +## Additional bounded acceptance edits + +In the existing model-info test file, directly after the carried Fable test, add this behavioral matrix (existing imports suffice): + +```ts +test("Fable 1M aliases preserve style, window and input-ceiling boundaries", () => { + const fable = { provider: "anthropic", id: "claude-fable-5-1", contextWindow: 1_000_000, maxInputTokens: 922_000 }; + const readable = buildAnthropicModelInfos([], [fable], undefined, "readable"); + expect(readable[1]!.id).toBe("claude-ocx-native--claude-fable-5-1[1m]"); + expect(readable[1]!.max_input_tokens).toBe(922_000); + const desktop = buildAnthropicModelInfos([], [fable], undefined, "desktop3p"); + expect(desktop[1]!.id).toBe(`${desktop[0]!.id}[1m]`); + const smaller = buildAnthropicModelInfos([], [{ ...fable, contextWindow: 200_000 }], undefined, "readable"); + expect(smaller.map(row => row.id)).toEqual(["claude-fable-5-1"]); + const unknown = buildAnthropicModelInfos([], [{ provider: "anthropic", id: "claude-fable-5-1" }], undefined, "readable"); + expect(unknown.map(row => row.id)).toEqual(["claude-fable-5-1"]); + const other = buildAnthropicModelInfos([], [{ ...fable, id: "claude-opus-5" }], undefined, "readable"); + expect(other.map(row => row.id)).toEqual(["claude-opus-5", "claude-opus-5[1m]"]); +}); +``` + +Extend the carried native-passthrough test after its first three requests with the following legacy selectors. Adjust captured length from 3 to 6; assert every captured model is canonical. Existing mock upstream and credential fixture are reused. + +```ts +for (const model of ["claude-fable-5-1", "claude-fable-5-1[1m]", `${pickerModel}[1M]`]) { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), model }), + }); + expect(response.status).toBe(200); + await response.text(); +} +expect(captured).toHaveLength(6); +for (const call of captured) { + expect(call.body.model).toBe("claude-fable-5-1"); + expect(call.headers.get("anthropic-beta")).toBe(OAUTH_HEADERS["anthropic-beta"]); +} +``` + +The round-trip guard and prefix guard must remain; do not replace them with generic decoding of every alias. Existing alias, mapped-model, disabled-passthrough, and exposed-listener regressions remain required CI coverage. Review-specific additions, if needed, are recorded only in scratch. + +## Documentation diff and GUI evidence + +The source PR modifies no docs even though its body checks the docs box. Add the bounded exception to the English guide; do not claim all canonical 1M rows are encoded or that base Fable loses its native ID. + +```diff +--- a/docs-site/src/content/docs/guides/claude-code.md ++++ b/docs-site/src/content/docs/guides/claude-code.md +@@ +-Desktop's third-party gateway mode can offer its effort selector. Real Anthropic models keep their +-canonical ids. The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases ++Desktop's third-party gateway mode can offer its effort selector. Real Anthropic base rows keep ++their canonical ids. For Claude Code, a canonical Fable model with an authoritative 1M window ++uses `claude-ocx-native--claude-fable-5-1[1m]` for its separate 1M selection. This reversible ++selector distinguishes picker families; Messages and count_tokens restore the canonical Fable ++id before native passthrough. Desktop 3P selectors keep their existing format. ++The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases +@@ +-(reasoning-effort ladder, thinking types) in the official `ModelInfo` shape. Real Anthropic models +-keep their canonical ids on both surfaces. ++(reasoning-effort ladder, thinking types) in the official `ModelInfo` shape. Real Anthropic base ++rows keep their canonical ids; the readable Fable 1M exception is described above. +``` + +Do not describe the unmarked row as necessarily a 200K upstream model: both rows may advertise a genuine 1M window; the marker controls client accounting. Preserve the existing rule that beta headers, not a model suffix alone, convey provider context semantics. + +Translation coordination before readiness: main's documentation owner must inspect `docs-site/src/content/docs/{ko,ja,zh-cn,zh-tw,fr,ru,tr}/guides/claude-code.md` for equivalent unconditional canonical-ID statements and add the same scoped exception where needed. No translation edits are delegated to this research worker; record the final exact locale touch set at consuming P. This is a readiness debt, not permission to leave contradictory translations. + +No OCX React component changes are required. Capture client evidence for fresh selection, saved old bare/marked selection, switching Fable→Opus→Fable 1M, settings persistence, and restart. Record Claude Code version, discovery payload, selected ID and resulting upstream model in a sanitized artifact. Screenshots should show both picker rows and retained selection. Do not present source-author harness claims as current reproduced evidence. If the PR description mentions GUI, include its screenshot as required by repository PR policy. + +## CI-only verification contract + +NO local tests, typecheck, builds, suite helpers or provider probes. Commands below describe CI execution, not commands to execute on the local workstation. + +- `.github/workflows/ci.yml:7` triggers on every pull_request base, including stacked children; no dev-only base filter. +- `changes` at :182-187 includes src/tests, so this implementation triggers expensive jobs. A docs-only roadmap CI success can legitimately skip tests and proves no runtime behavior. +- Linux `test` at :255-267 is four shards; :314-316 invokes `bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"`. +- The helper at :46-68 excludes only storage-policy/storage/api-usage families, not these tests. At :196-211 it sorts all test files and distributes every eligible file exactly once across shard indices; :122-125 executes `bun test --isolate --timeout 60000` on the selected filenames. Both existing changed test paths are included. +- `gates` at :392-431 runs `bun x tsc --noEmit`, the additional contract tsconfig, GUI tests, and privacy scan. GUI lint is conditional on gui changes; do not mistake that skip for a failure on this backend-only layer. +- macOS at :451-465 runs two shards; :532 invokes `bun test --isolate --timeout 60000 tests --shard=.../2`. +- Windows at :658-686 is **dispatch-only**, lane `all`; :754 invokes six shards. Ordinary PR green is not Windows full-suite evidence. Main decides/dispatches any required exact-head Windows run; this research starts none. +- Focused failure diagnosis target, if a CI runner needs it: `bun test tests/claude-integration/claude-model-info.test.ts tests/claude-integration/claude-native-passthrough.test.ts tests/claude-integration/claude-models-discovery.test.ts tests/claude-integration/claude-alias.test.ts`. Do not add a workflow solely to run this command. +- Save run URL, event, head_sha, checkout/merge SHA, non-skipped job conclusions and test log paths. Resolve fork `action_required` via main's normal approval process; hygiene labels/author checkboxes/old-head approval are not product-test evidence. + +| Acceptance | Activation and observable evidence | +|---|---| +| Distinct rows | Readable Anthropic Fable >=1M emits bare base and reversible `[1m]` sibling with honest display name | +| Narrow family/style | Opus retains canonical marker; Desktop retains its prior selector; no other provider is rewritten | +| Capacity guard | 200K or undefined Fable window emits no sibling; 922K input cap under 1M window remains 922K | +| Marker compatibility | New alias with/without marker plus upper-case marker and legacy canonical selectors reach canonical Fable on mock upstream | +| Both endpoints | Messages status/stream consumed; count_tokens returns upstream 4242, not local estimate; captured model canonical | +| Routing coexistence | Existing alias/modelMap/Fast/effort regressions stay green; no global resolver or Desktop registry edit | +| Client persistence | Versioned client evidence demonstrates saved selection after switching and restart, including legacy behavior | +| Gates | All required exact-head CI jobs execute/pass; Windows status reported separately | + +## Dependencies, carry and close-out + +The four source paths plus English guide have **zero touched-path overlap** with supplied source PRs #3653/#3654/#3571/#3659 (JSON file-list intersection inspected). Fable is independently reviewable: no semantic requirement to land context persistence or Go ordering first. Per 000_plan.md, the user-requested stack uses `codex/lane-b-05-fable` based on `codex/lane-b-04-management`; do not invent a runtime dependency. Discovery still consumes the current predecessor's catalog and limits, so rerun exact-head gates after restack. + +Lane D owns #3646 remote Desktop aliases. Coordinate before altering `claude-messages.ts` or decoder ordering; do not carry its unknown-registry fallback into this Fable slice. This plan deliberately changes neither `desktop-3p.ts` nor `inbound-model-options.ts`. + +Safe later carry: apply the two non-merge source commits in order on main's selected layer, preserving author metadata, then add the bounded tests/docs as own commits. Do not reset the bound checkout, cherry-pick the source merge parent, or replace other lanes' edits. A lower-layer squash means refresh onto the new dev ancestry and replay only unique Fable commits, then obtain fresh CI/review. Main owns `--no-verify` pushes and bottom-up merges. Retarget children before removing a parent branch. Close source PR #3649 only after proving its replacement commit is in dev, and link the replacement. No linked issue is specified in the source body; do not close #3646 or unrelated issues. + +## Unresolved items for main + +- Exact-head hosted verification and actual picker compatibility are not established by this research. +- Legacy request forwarding is testable with the additions above; saved client selection migration remains a separate client-level observation. +- The old review's missing marked Messages case is already resolved by `284fe8ca0`; Fable-only scope is explicit in the current source body and comments. Do not repeat these as open code defects. +- Translation touch set must be fixed at P before implementation readiness. +- Relevant security review is tracked only in `.tmp/lane-b/plan-fable-review.md`; this tracked plan contains public source behavior, not unpublished findings. + +Roadmap handoff: this document is ready for main's A audit. The unresolved checks above are explicit later B/C acceptance work, not a request to implement during the docs-only cycle. Final landing and closure follow `060_landing.md`. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/060_landing.md b/devlog/_plan/260906_lane_b_catalog_stack/060_landing.md new file mode 100644 index 0000000000..4a1ca51e5d --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/060_landing.md @@ -0,0 +1,31 @@ +# Land the verified catalog stack + +## Before and after + +Before: five original contribution PRs are open, their carried changes form separate reviewed branches, and dev may have advanced from peer lanes. After: all five behavior contracts are reachable from dev, source authors remain credited, replacements and originals are closed appropriately, and resolved issues #3650/#3651 are closed with landing proof. + +## Exact change map + +- MODIFY this unit's numbered completion record with replacement PR numbers, source/current SHAs, CI URLs, merge SHAs and issue closure results. +- MODIFY child PR base refs from the open parent branch to dev after parent landing. Keep local and remote parent refs until all children are safely retargeted. +- MODIFY a branch only for demonstrated integration conflicts or failing current-head checks. Preserve unrelated A/C/D work; resolve shared config fields, locale keys and alias helpers by combining contracts, never choosing an entire side blindly. +- CLOSE original #3653/#3654/#3571/#3659/#3649 as superseded only after the respective replacement's merge commit is on dev. +- CLOSE #3650/#3651 only after full visibility/context acceptance criteria are satisfied. +- MOVE the finished public unit from `_plan` to `_fin` only at terminal completion. No credentials or private audit material enters this unit. + +## Sequence and activation scenarios + +1. Fetch dev; compare each queued replacement to its reviewed head. Trigger: peer dev advanced. Effect: inspect actual overlap, merge/reconcile dev into the affected layer, cascade to children and rerun exact-head checks when the tested tree changed. +2. Confirm each lower layer's CI has actual typecheck, functional tests and GUI gates where applicable; inspect required review findings including security review. No stale approval is reused after code changes. +3. Merge the bottom PR with a merge commit when allowed to preserve ancestry; verify GitHub merge state plus `git merge-base --is-ancestor origin/dev` after fetching. +4. Immediately close the carried source PR with replacement and landing evidence; close linked issue if its complete report is addressed. Preserve original contribution trailers in merge/squash content. +5. Retarget the child before cleanup; compare `git diff ...` to its intended layer. If a squash changed ancestry, restack rather than leaving already-squashed content in the child diff. +6. Repeat until all five are landed. Assert final ancestry, original PR/issue states and clean tracked work. Record remaining unrelated items without expanding scope. + +## Verification + +Before final landing, dispatch `ci.yml` with `lane=all` on the final integrated stack head and verify all six Windows test shards in addition to Linux/macOS. Ordinary PR runs skip Windows test shards; Windows keyring/package smoke alone is not full Windows test evidence. + +Read-only `gh pr view`, `gh run view` and `gh issue view` supply fresh state; assertions operate on exact numbers/SHAs, not titles. Git ancestry checks run locally; repository tests do not. A C receipt wraps the read-only verifier and must fail if any required original remains open, intended merge is absent, CI did not execute real tests, or attribution is missing. + +Expected outcome is DONE. Pending CI, a conflict or a repairable review finding continues the same goal. An external blocker is recorded with exact evidence rather than closing the remaining work as complete. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/061_landed.md b/devlog/_plan/260906_lane_b_catalog_stack/061_landed.md new file mode 100644 index 0000000000..a55d1bc5a9 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/061_landed.md @@ -0,0 +1,25 @@ +# 061 — Lane B integration outcome + +Recorded 2026-09-06. All five assigned source contributions are integrated into dev. Source PRs are closed; issues #3650 and #3651 are closed. + +| Source | Replacement | Merge commit | +| --- | --- | --- | +| #3653 | #3685 | `9115b179a29f1366561139b8502cebb17bf816e9` | +| #3654 | #3695 | `ab6762bdb35db24efbe1ceac77a1f9e5e6139616` | +| #3571 | #3700 | `76356176c86aa123220c82b65321453e81897405` | +| #3659 | #3721 | `330bf609790c968006fb8922ab30cd75a680b06e` | +| #3649 | #3722 | `73190c20443876fe1dbf4e9dde5d25644e48e71a` | + +## Attribution + +Original contributions retain Robin Bially (#3653/#3654), voiys (#3571), gqchen (#3659), and Éverton Toffanetto (#3649) through original commit authorship or Co-authored-by trailers. The static model-management parent #3717 is superseded by #3721; its rebased changes are included by content, not old-SHA ancestry. + +## Verification and final maintainer direction + +The first three replacements passed their recorded hosted functional CI before landing. Model-management pre-rebase head 9af03d0c passed CI 33998617606 and React Doctor 33998617609; its earlier remote API, browser and responsive evidence remains tied to the documented tested revisions. + +The maintainer then explicitly requested only rebasing onto dev and admin merging. #3721 was rebased onto 2f124a167 and landed at 330bf6097; the rebased tree dbb5c190 matches the inspected clean composition. #3722 preserves the original two Fable commits as an unchanged feature patch (stable patch ID ceed1fa86d57fac9706aaae7fe9de7b5d6f8802c) on dev330bf6097. Independent bounded static review found no composition blocker in that tree. + +No local suites, typechecks or builds were run. No post-rebase CI wait or new runtime verification is claimed for these last two landings. Fable source-author test reports are historical and were not reproduced here. Newer work from other lanes is outside that evidence. + +Actual merge commits were verified as ancestors of fetched origin/dev, and original PR/linked-issue states were refreshed through GitHub. This final record is documentation only; it does not certify a release, deployment, or final Windows run. diff --git a/devlog/_plan/260906_opaque_transport_finality/000_plan.md b/devlog/_plan/260906_opaque_transport_finality/000_plan.md new file mode 100644 index 0000000000..3ff99fa965 --- /dev/null +++ b/devlog/_plan/260906_opaque_transport_finality/000_plan.md @@ -0,0 +1,21 @@ +# Opaque preflight transport and terminal outcomes + +Class C4. Mandatory parent-PR review repair under the existing authorized release +chain; work phase opaque-transport-finality, criterion c-2. Parent #3753 remains +open/draft at b73809f7e, child #3754 remains open/draft at f5c88beb9 with its parent +base restored. No parent merge occurred. The original #3535 was briefly closed +by an out-of-order follow-up, immediately reopened, and its comment corrected. +No completion, approval or release gate is waived. + +Public review references: PRRT_kwDOS-0Gi86fqEUo (preflight read failure escapes) +and PRRT_kwDOS-0Gi86fqEUq (tee EOF reports incomplete despite failed client tail). +The earlier full CI and independent reviews did not cover these paths. The +unfinished combo cycle is preserved and must consume the repaired parent before +its final verification. All execution remains hosted; no local suite/typecheck/ +build or live Kiro request. + +Implementation is one bounded failure-contract unit in 010_failure_boundaries.md. +Update the existing parent PR, run exact-head CI, cascade its commit into #3754, +and require fresh composed CI and review before bottom-up integration. Do not +close an original or retarget a child as a side effect of an unverified merge: +verify each preceding command and actual merged state before dependent actions. diff --git a/devlog/_plan/260906_opaque_transport_finality/010_failure_boundaries.md b/devlog/_plan/260906_opaque_transport_finality/010_failure_boundaries.md new file mode 100644 index 0000000000..ccc1552b5b --- /dev/null +++ b/devlog/_plan/260906_opaque_transport_finality/010_failure_boundaries.md @@ -0,0 +1,67 @@ +# Preserve preflight read failures and inspection finality + +## Current ownership + +Core selects native encrypted-output candidates and awaits combo-stream-preflight +before exposing headers. The preflight owns a bounded retained prefix and one +reader; replayBufferedResponse already emits that prefix and forwards later read +errors. Client relays own synthetic failed tails. consumeForInspection owns the +independent tee terminal callback used by native account health. The shared SSE +inspector reports real terminals and exposes parsed payload callbacks. + +## Planned change + +- src/server/responses/combo-stream-preflight.ts: native-only replayReadErrors + option, default false. Catch only reader.read rejection; opted-in callers get + an accepted reconstructed stream retaining the bounded prefix and the errored + reader. Never cancel that errored reader: its original rejection must survive + the replay into relay/inspection. Default combo callers preserve their prior throw behavior. Do not retry + or classify a read reset as a decrypt rejection, swallow it, or grow buffers. +- src/server/responses/core.ts: enable that option only on the native opaque + preflight. After its await, caller abort takes the existing cancellation cleanup + path before any replay/rebuild. Other read failures reach the normal mid-stream + relay and inspection path, not a connect-phase error classifier. +- src/server/relay.ts: reuse a bounded/redacted bare-error message helper at the + client boundary and within consumeForInspection's parsed-payload callback. + Keep that evidence local to this reader rather than borrowing stale log state. + At clean EOF without a real terminal, a witnessed bare error reports failed + using the shared terminal HTTP mapper; an error-free EOF remains incomplete. + Preserve the caller's parsed-payload callback. Real terminals and cancellation + retain precedence; no extra terminal callback or healthy-account reset. + +## Rejected alternatives and scope + +A blanket core catch mapped as a connect error can misclassify an already-started +response's account outcome. Globally replaying all preflight errors changes combo +behavior. Reporting failure at the first bare error would override a later real +terminal. Borrowing the client relay's mutable state revives tee scheduling races. +Use the existing preflight/relay ownership and callback seams instead; no new +public inspector method, provider policy or retry budget. + +## Verification + +Existing native request fixtures add created-then-reset and created-then-caller- +abort cases: no uncaught handleResponses rejection, no sanitize resend, normal +failed stream or 499 cancellation and appropriate attempt/terminal metadata. +Run tee/eager variants where selected by the existing harness. Preflight tests +prove default read-error behavior is unchanged and native opt-in preserves prefix +and exact failure. Inspection/account-health fixtures cover flat/nested bare +errors at EOF, prior failure/avoidance not cleared, real-terminal precedence, +error-free EOF compatibility and cancellation neutrality. Existing redaction, +byte bounds, no-persistence and one-shot recovery tests remain. + +Independent plan/source/final review; exact parent and cascaded child hosted +Linux/macOS/gates CI. Final Windows six-shard and release gates remain mandatory. + +## Usage-marker parity amendment + +Source review confirms the account-health blocker is closed by failed EOF. The +existing eager callback still labels every synthetic failure as streamAborted, +though a clean EOF after an explicit upstream error is a semantic failure, not a +body-read reset (PersistedUsageAttempt documents that distinction). Criterion c-2 +also requires usage outcome parity, so include this small related correction: +relay-eager passes optional upstream_error provenance only for that clean-EOF tail; +core records its semantic failed status without streamAborted. Ordinary reset +callbacks retain their one-argument shape, 502 and streamAborted. Add request-level +tee/eager assertions for repeated bare errors versus actual reset; do not infer +this marker from a stale log message or change real-terminal precedence. diff --git a/devlog/_plan/260906_release_244_followups/000_plan.md b/devlog/_plan/260906_release_244_followups/000_plan.md new file mode 100644 index 0000000000..3b807cdba3 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/000_plan.md @@ -0,0 +1,47 @@ +# Release 2.44 follow-up integration + +## Loop contract + +- Archetype: spec-satisfaction repair; class C4 for governance, replay and release; C3 for bounded client changes. +- Trigger: owner authorized the named backlog, bottom-up stacked PR integration, --no-verify pushes, admin merges, maintainer dev policy and release on 2026-09-06. +- Goal: publish the verified next release after these narrowly scoped fixes. +- Non-goals: new providers, authless Desktop defaults (#3689), Anthropic replay/cache redesign (#3719), unrelated branch cleanup, direct live Kiro calls. +- Verification: GitHub Actions only for all test/typecheck/build/privacy commands. Local reads, git diff --check, JSON validation and review are allowed. User prohibition overrides local verification defaults. Existing ci.yml dispatch lane=all is the Windows six-shard authority; service-lifecycle.yml has workflow_dispatch. Command existence checked by reading workflow inputs and scripts, not running prohibited suites. +- Stop: every mapped criterion proved, final npm/tag/provenance validation complete. A red gate is repaired, never relabeled green. Old bug reports without current reproduction receive explicit evidence-limited outcomes. +- Memory: numbered unit docs and session-bound .codexclaw goalplan/ledger. Sensitive log analysis and draft security reviews stay in .tmp/release-244. +- Delegation: xai/grok-4.6 only, bounded disjoint workers and independent reviewers. Main owns every FSM edge, commits, pushes and GitHub writes. Reclaim after two distinct failed agents; delegation changes enter at P. +- Resources: existing GitHub account, repository and release OIDC only; no new credentials/purchases. Unlimited requested-model delegation within available concurrency; no owner token/cost cap. Each subprocess <=30 minutes, CI polls <=60 seconds, each phase investigation checkpoint at 60 minutes with evidence-based continuation. No implicit exhausted outcome. + +## Snapshot and sequence + +Baseline dev: af344a28eabcee09a5e04c48ab897449792719c2, version 2.44.0. Latest published stable is 2.43.0. Refresh before every layer. + +| Work phase | Design | Dependency / independent proof | +|---|---|---| +| roadmap | this unit | Lock all decade designs; docs only | +| policy | 010_policy.md | Establish truthful maintainer integration authority | +| task-input | 020_task_input.md | Shared Responses parser contract | +| task-guidance | ../260906_stateful_task_guidance/010_raw_boundary.md | Review follow-up: align stored raw guidance before Kiro resumes | +| kiro-results | 030_kiro_results.md | Consume parsed tool-result sequence | +| opaque-recovery | 040_opaque_recovery.md | Retry and terminal semantics on composed routing | +| combo-recovery | 050_combo_recovery.md | Route recoverable parsed payloads | +| grok-terminal | 060_grok_terminal.md | Client terminal reconstruction on composed relay | +| quota-proxy | 070_quota_proxy.md | Refresh network-path evidence on integrated runtime | +| usage-source | 080_usage_source.md | Attribute actual selected transport after routing | +| dashboard | 090_dashboard.md | Presentation on integrated behavior | +| release | 100_release.md | Final ancestry, Windows, lifecycle, publish | + +One work-phase is one PABCD cycle. Publish short dependency stacks; use merge commits for parents with live children, squash bounded terminal carries if safe, and recascade after any squash. Independent presentation/governance slices remain their own PRs even though execution is sequential. Every carried contributor receives account-linked Co-authored-by credit. Preserve snapshots of source heads. + +## Evidence boundaries + +#3735/#3734 are public current-SHA reports; independently inspect code, author local-pass statements remain reports. Kiro proof is recorded-log shape plus synthetic CI tests, never a live quota-consuming request. #3644 has a network A/B report and landed diagnostic #3693; do not claim a Windows runtime reproduction from mocked tests. Detailed private logs are never committed. + +## Owner steering: asynchronous CI + +From the Grok unit onward, implementation/review and PR publication proceed +without waiting for hosted CI. Each cycle records exact-head CI submission; its +runtime acceptance criterion stays open under release convergence. CI failures +are handled asynchronously and stacks cascade after repairs. Bottom-up merges +and release publication still require successful checks on their final heads. +This changes scheduling only; no test, platform or release criterion is removed. diff --git a/devlog/_plan/260906_release_244_followups/001_roadmap_lock.md b/devlog/_plan/260906_release_244_followups/001_roadmap_lock.md new file mode 100644 index 0000000000..5a9a7483f5 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/001_roadmap_lock.md @@ -0,0 +1,26 @@ +# Roadmap lock and audit dispositions + +The docs-only cycle locks 000_plan and all ten decade designs. Three independent +Grok discovery lanes checked protocol, recovery and integration scopes; a fourth +reviewed maintainer authority and a fifth reviewed the complete roadmap. + +The roadmap review returned GO-WITH-FIXES with two accepted amendments: every +criterion now identifies its own proof, and Windows lane=all is required on each +actual publish SHA as well as the frozen candidate. The quota investigation must +explicitly retain an open field-validation outcome when the original failure is +unreproduced. Publication cannot stand in for that evidence. + +The policy review was corrected and independently rechecked PASS: Maintain is +GitHub role id 2, Admin is 5; Write is 4 and is outside the authorized exception. +The opt-in CLI path parses its flag independently of positional arguments and +skips only the two approval requirements, retaining identity, role, objection and +race checks. No repository rules have been changed in this documentation cycle. + +Verification is documentation structure and independent source review only. +Runtime tests, typecheck, builds and privacy scanning will execute in hosted CI; +no local suite or live Kiro call was made. Existing log metadata is historical +evidence and does not establish current live Kiro behavior. + +Next cycle: consume 010_policy.md, implement the helper/docs and verify the exact +head in CI, then apply and read back the authorized dev-only role configuration. +All remaining runtime and release criteria stay open. diff --git a/devlog/_plan/260906_release_244_followups/010_policy.md b/devlog/_plan/260906_release_244_followups/010_policy.md new file mode 100644 index 0000000000..dc7b655aaa --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/010_policy.md @@ -0,0 +1,29 @@ +# Maintainer dev integration policy + +Depends on roadmap. Class C4; spec-satisfaction. Owner authorizes maintain/admin integration through PRs without a second maintainer approval, including self-authored PRs. Actual inspected roles for both rostered maintainers are admin; current dev rules already permit role 5 PR bypass. The contradiction is primarily normative documentation, plus future Maintain role coverage. + +## Exact change map + +- MODIFY MAINTAINERS.md review policy and dated change log: distinguish contributor approvals from explicitly opted-in maintainer integration to dev. Preserve actual independent technical/security review and CI duties; do not call self-integration a second-person approval. Main/preview promotions retain existing rules. +- MODIFY AGENTS.md branch/review summary: align with maintainer dev exception; PRs still required, force pushes and deletions still blocked. +- MODIFY scripts/ci/assert-mergeable-review.sh: parse explicit --maintainer-integration in any argv position, retaining optional repository positional argument. Default strict contributor-review path unchanged. Opt-in skips exactly the reviewDecision=APPROVED and qualified non-self approval checks, not review retrieval, objections or race checks. For override, require baseRefName=dev, current authenticated human actor from gh api user, membership in trusted base dev MAINTAINERS roster, and live maintain/admin role. Preserve complete review parsing, maintainer CHANGES_REQUESTED blocking and final head/base/actor authorization recheck. Print only a truthful validation snapshot with head/base/actor; do not emit a privileged merge recipe because head matching cannot atomically bind the PR base. Never accept a CLI-supplied actor, PR-authored roster, bot or unknown role. +- MODIFY tests/ci-workflows/assert-mergeable-review.test.ts: extend fake gh with actor/base/permissions APIs and cases while retaining all existing default strict cases. +- MODIFY docs-site/src/content/docs/contributing.md and structure/06_docs-and-release.md: link canonical exception and correct Windows dispatch-only whole-suite description found stale in structure. +- External UPDATE dev ruleset 20763889 only: add RepositoryRole actor_id=2 bypass_mode=pull_request, preserve actor_id=5 and all conditions/rules. Read snapshot immediately before update; compare after. Verify role names through GraphQL repositoryRoleName: maintain=2, admin=5; role4 is write and must never be added. Do not change main 20764415 or preview 20764486. Rollback is the saved before JSON projected to accepted API fields. + +## Activation matrix and verifier + +CI test fixture: authorized admin and maintain actors with no second approval on dev pass ONLY opt-in; write/outsider/bot/missing actor/role API error fail; main/preview/stack base fail; pending maintainer objections, API pagination failures, head/base races fail. Default no flag retains all prior strict failures. shell syntax can be read/checked; Bun tests and typecheck run remotely. Live REST readback proves only dev actor list changed; compare main/preview snapshots unchanged. + +## Trust / bypass record + +Assets repository integration history; entry script and authenticated GitHub rules API; boundary contributor metadata versus trusted dev roster/live permissions. E7 human policy plus E8 GitHub branch rules; admin can alter rules outside this helper, so helper is an early review check, not universal enforcement. PR bypass does not remove deletion/non-fast-forward rules outside PRs. Security review recorded independently in scratch; final disposition may be published after diff is public. + + +## Policy-cycle P refresh and delegation + +Current dev remains the roadmap baseline; source helper and ruleset snapshots were reread. The preceding D locked the roadmap and made policy the next cycle. Worker owns only scripts/ci/assert-mergeable-review.sh and tests/ci-workflows/assert-mergeable-review.test.ts; main owns MAINTAINERS.md, AGENTS.md, contributing.md, structure/06_docs-and-release.md and GitHub settings. No overlapping writes or local tests. An independent reviewer audits the final script/docs delta before remote CI and dev-only ruleset application. + +## Dispatch repair / policy P amendment + +The first helper+tests worker repeatedly read unrelated plan pages and produced no source delta after a scope correction and bounded waits. It was retired without edits. Main now owns scripts/ci/assert-mergeable-review.sh in addition to documentation/settings; a fresh worker owns ONLY tests/ci-workflows/assert-mergeable-review.test.ts. The protocol remains --maintainer-integration in any argv slot, optional repo, actor from gh api user (login/type), baseRefName from PR metadata, maintain/admin role_name from collaborator permission. Final metadata rechecks head/base/author, then actor/role/roster authorization again. Default strict path does not require new fields. No expected evidence or scope was removed. This replan changes dispatch ownership, not the approved policy. diff --git a/devlog/_plan/260906_release_244_followups/011_policy_implementation.md b/devlog/_plan/260906_release_244_followups/011_policy_implementation.md new file mode 100644 index 0000000000..2a21aaa922 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/011_policy_implementation.md @@ -0,0 +1,31 @@ +# Maintainer integration implementation + +The helper retains its strict approval path by default. Explicit +--maintainer-integration parses independently of positional arguments and requires +the authenticated human actor, the trusted dev roster and live maintain/admin +permission. It retains complete review parsing and maintainer objections. Before +emitting a validation snapshot, it reloads the roster and actor authorization, +rejects roster/actor drift, then checks the final PR head, dev base and author. + +The existing regression matrix is preserved. Thirty-one additional scenarios +cover authorized integration, refusal cases, argument order and concurrent state +changes. Passing fixtures also verify the dev-bound roster and repeated identity +queries, so an accidental default-branch lookup cannot satisfy the tests. + +MAINTAINERS, AGENTS, the contributing guide and architecture notes now distinguish +maintainer integration from approving one's own work. The dev-only GitHub payload +adds Maintain role 2 alongside Admin role 5, both PR-only. The reviewed applicator +checks fresh before/after state and verifies role names without writing main or +preview. Settings application is recorded separately after hosted verification. + +No local test suite, typecheck, build or live provider request was run. The shell +syntax and diff were checked locally; behavioral proof is the exact-head hosted +CI recorded on PR #3739 and in the session's source-bound evidence receipt. The +first broad worker was retired without edits; main implemented the helper and a +fresh bounded worker supplied the regression matrix. + +Final C review removed the opt-in copy-paste admin merge recipe. Head matching +does not bind a PR's base at execution time, and another read in the same shell +command would only move that race. The helper now states its snapshot boundary; +the separately authorized integration step must revalidate current actor/base. +Passing fixtures require that no privileged merge command is emitted. diff --git a/devlog/_plan/260906_release_244_followups/020_task_input.md b/devlog/_plan/260906_release_244_followups/020_task_input.md new file mode 100644 index 0000000000..4c9b62f501 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/020_task_input.md @@ -0,0 +1,35 @@ +# External Codex task-input envelopes + +Depends on policy; class C4 for protocol admission. Fix public issue #3735, observed on baseline dev. Preserve the existing unpaired-tool HTTP 400 guard from #3471. + +## Diff-level change map + +- MODIFY src/responses/parser.ts at function_call_output classification before tool lookup: route only a complete external task-input envelope to an Ocx user message. Eligibility: type function_call_output, no call_id property (including inherited properties for direct helper calls), nonempty string id/name/namespace, nonempty fully representable text/image output. Do not require specific names, prefixes, namespaces or XML content. Existing standard tool results and custom_tool_call_output keep current path. +- NEW src/responses/task-input.ts: pure recognition returning supported Ocx user content or undefined, no request mutation/network/storage. Reuse existing content converters only when they preserve every accepted output part and reject invalid mixed arrays rather than silently drop them. +- MODIFY tests/responses/responses-parser.test.ts, tests/responses/responses-compaction-routing.test.ts and tests/responses/openai-responses-passthrough.test.ts with narrow positive/negative fixtures. No new test file or layout registry entry is needed. +- MODIFY docs-site/src/content/docs/reference/adapters.md and docs-site/src/content/docs/guides/sub-agent-surface.md and structure/04_transports-and-sidecars.md: describe external task input as user-supplied task coordination, not fabricated tool completion. Keep passthrough/compaction raw-body contracts. + +Before: result-shaped external task input enters toolResult branch with undefined call id, then translated-adapter guard returns 400. After: the complete external shape enters user message with intact supported text/images; malformed/orphan tool results still fail. No secret or raw logged transcript is copied to tests. + +## Activation / verifier + +Remote parser tests exercise arbitrary tool names/namespaces, blank/empty content remains ineligible, multiple ordered text parts and supported images; retain exact content without orphan marker. Explicit call_id empty/null/number/undefined-own-property remain invalid, as do custom outputs missing identity, partial provenance, unsupported/mixed malformed arrays. Existing genuine call ids remain tool results. Remote endpoint/compaction/passthrough fixtures prove unchanged raw body forwarding and guard failures. ci.yml runtime jobs + typecheck/privacy establish fresh proof. Local saved log provides provenance only; no live Kiro request. + +## Boundary / alternatives + +No-op leaves current task creation unusable; configuration cannot distinguish this parser envelope; generic orphan-to-user repair would reverse #3471 and is rejected. Reuse current message types; no persisted schema fields. Classification is compatibility handling, not authentication: no privilege is granted by envelope metadata. + + +## Source follow-up folded at roadmap lock + +Author yrlan-montagnier (Yrlan), GitHub id 71253160: preserve Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com>. Posted helper may manufacture an encrypted-content-omitted marker that makes encrypted-only input look usable; reject encrypted-only and mixed opaque/unsupported input, never use placeholder text as eligibility. Keep every pre-existing #3471 regression, adding tests rather than replacing them. Add tests/responses/responses-compaction-routing.test.ts and tests/responses/openai-responses-passthrough.test.ts to explicit remote verification. Prefer a dedicated small predicate over relocating passthrough helpers unless byte-for-byte behavior is proved. + +## Task-input cycle P refresh at 25c8d2b4e + +The preceding D landed policy #3739 and actual Maintain/Admin settings. Issue #3735 is still open and the author has no open PR; retain the account-linked Yrlan trailer. Source parser at lines 150-160 currently recognizes only message/agent_message as the continuation conversation boundary. Compute the optional external content once near effectiveType and include a recognized envelope in that existing boundary predicate. In the function_call_output branch, clear pendingReasoning, emit a user message and continue; leave the ordinary result branch and core guard unchanged. + +Concrete new leaf: src/responses/task-input.ts exports externalTaskInputContent(item: unknown): string | OcxContentPart[] | undefined. It imports only type OcxContentPart and existing isObj/inputContentParts. Require exact function_call_output, no call_id property, nonblank id/name/namespace, and a nonblank string or fully supported array. Array parts are input_text/text/output_text with string text or input_image with nonblank string image_url and optional auto/low/high/original detail. Normalize output_text to input_text before calling the existing input converter; original image detail maps to high by that converter. Require at least one nonblank text or usable image. Reject any unsupported/opaque/malformed member, invalid detail or file-id-only reference as a whole; placeholder text never establishes eligibility. Preserve accepted text bytes, order and image references; no raw-body mutation or helper relocation from passthrough. + +Field chain: external JSON shape -> pure leaf validation -> parser user message + existing `_continuationConversationMessageIndex` -> translated adapter's existing user-content serialization. No new persisted field/schema/config. Passthrough and compact use unchanged raw body. Tests include pending reasoning reset and previous_response_id boundary index=0 for a new envelope without a replay prefix, alongside all old #3471 controls. + +Dispatch: main owns new leaf, parser, endpoint/passthrough regressions and English/structure docs; a bounded worker owns only tests/responses/responses-parser.test.ts. Independent A/C reviewer reads named leaf/parser boundaries. No local tests/typecheck/build; remote ci.yml runtime/gates and existing parser/compaction/passthrough suites provide proof. Parser leaves add no core/Lab dependency. No-op/configuration cannot fix this shape; existing input converter is reused behind strict validation. diff --git a/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md b/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md new file mode 100644 index 0000000000..df34b94500 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md @@ -0,0 +1,25 @@ +# External task input implementation + +The pure task-input leaf validates the complete external envelope before using the +existing input-content converter. It accepts text and URL-backed images, rejects +partial or opaque arrays as a whole, and preserves accepted content order. The +parser uses the result for both the continuation boundary and a user turn that +clears pending reasoning. Ordinary tool results, the core call-id guard and raw +passthrough handling remain unchanged. + +Existing unpaired-result regressions remain in place. Added parser cases cover +shape/content controls, original image detail, frozen input, continuation and +reasoning separation; HTTP cases exercise accepted text/images and rejected +envelopes before upstream work. A passthrough case verifies the existing raw +orphan-output behavior alongside the new parsed user representation. + +The implementation preserves Yrlan's contributor attribution from the public +issue and supplied proposal. Protocol/security review and hosted CI are recorded +on the fixing PR and source-bound cycle receipt. No local test suite, typecheck, +build or live Kiro request is part of this validation. + +The first hosted run exposed two invalid HTTP test stimuli: short text in an +encrypted_content slot follows the existing plaintext normalization path before +the parser. The negative fixtures now use synthetic ciphertext-shaped content +with an explicit classifier check; a separate positive control retains plaintext +slot compatibility. The 400/no-upstream assertions and production logic are unchanged. diff --git a/devlog/_plan/260906_release_244_followups/022_management_auth_port_fixture.md b/devlog/_plan/260906_release_244_followups/022_management_auth_port_fixture.md new file mode 100644 index 0000000000..f74adf55c4 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/022_management_auth_port_fixture.md @@ -0,0 +1,27 @@ +# Check-phase port allocation repair + +CI34011124632 passed the corrected task-input cases and Linux shards, but two +unchanged macOS management-auth tests failed at the public Bun.serve bind with +EADDRINUSE. Both tests used findAvailablePort, whose Node probe closes its socket +before returning a number. The probes bind 127.0.0.1 while remoteConfig makes the public listener bind 0.0.0.0, so a loopback-only availability check also has the wrong address scope. reservedPort prevents the two selected numbers from +being equal; it does not keep either port reserved until Bun binds. The identity +of the intervening occupier is not established by the CI log. + +This is a prerequisite repair to the failing verification instrument, not a +change to authentication or production port policy. Modify only +tests/server/server-management-auth.test.ts: replace those two probe-close +setups with a small test helper that wraps Bun.serve synchronously, changes only +port to zero, calls the real Bun.serve and captures the real public/management +listeners while preserving each original hostname and fetch handler. Restore the spy before requests or any awaited cleanup. Derive the +management URL from its actual listener port and assert distinct live listeners. +Keep a valid positive configured ingress port so production config validation +remains unchanged; the fixture explicitly owns ephemeral bind allocation. + +The helper joins captured-listener cleanup if startup/fixture validation fails; +the existing finally blocks continue using the real composite server.stop. +Retain every trust, origin, credential, health, consent and pairing assertion. +No retry, sleep, skip, wider auth rule, or production test seam is added. + +Verification: independent fixture review followed by fresh exact-head hosted +CI. The same two real HTTP tests must pass, along with the new task-input cases +and full Linux/macOS checks. No local test suite is run. diff --git a/devlog/_plan/260906_release_244_followups/030_kiro_results.md b/devlog/_plan/260906_release_244_followups/030_kiro_results.md new file mode 100644 index 0000000000..c66ba1fccf --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/030_kiro_results.md @@ -0,0 +1,54 @@ +# Adjacent Kiro result coalescing + +Depends on task-input; class C4 for protocol identity. Fix #3734 from recorded Codex code-mode output shape, never by spending live Kiro quota. + +## Diff-level change map + +- MODIFY src/adapters/kiro.ts pushUser/turn-construction helper: when adding results in immediately adjacent parsed messages, tracked separately from collapsed user turns, combine only adjacent results with identical normalized toolUseId. Append content in exact input order and propagate error if any constituent is error. Preserve images via the adapter's supported representation; ensure no image is dropped or reordered relative to supported content semantics. +- Preserve the pendingToolUses.delete validation: call-a, call-b, call-a remains invalid. Do not globally deduplicate by id or merge across assistant/tool boundaries, intervening ordinary input, or unrelated result. +- MODIFY tests/providers/kiro/kiro-adapter.test.ts and relevant kiro-images.test.ts fixtures for three adjacent results, error later in group, different ids and nonadjacent repeats, text+image preservation. No new fixture uses real call ids or messages. +- MODIFY docs-site/src/content/docs/reference/adapters.md and structure/04_transports-and-sidecars.md with narrow multi-output contract. + +Before: pushUser appends each result, wire validation consumes the first matching toolUseId and rejects the next duplicate. After: consecutive same-call outputs become one ordered result before validation. Opaque encrypted output rejection remains unchanged. + +## Activation / verifier + +CI tests feed one assistant exec call followed by notify/notify/final results; assert one toolResult and ordered content. Mixed error/success reduces to error; unrelated result boundaries cannot be crossed. Same-id nonadjacent repeat still throws matching error. Exercise retained images using existing adapter representation; enforce maximum/shape constraints already owned by Kiro wire. Run existing Kiro adapter/image suites through ci.yml, plus full typecheck/privacy. Saved local log shape is supporting evidence only; live Kiro correctness remains untested and explicitly reported. + +## Non-goals + +No Kiro account/OAuth/quota changes, no aggressive malformed-history healing, no parser changes beyond prior layer, no global result deduplication. + + +## Source follow-up folded at roadmap lock + +Track adjacency in original message iteration; reset on every non-toolResult message including user/developer/assistant, even if pushUser collapses it into one user turn. Retain Kiro images on the current user image list as the existing wire format requires; do not promise unsupported text/image interleaving in the wire. Preserve Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com>. Local log metadata contains old Kiro activity and is not a current live reproduction. + +## Kiro-cycle P refresh on parent b24ed35a + +Parent #3743 is verified and ready, still open as this branch base; fixture prerequisite #3745 is merged. Issue #3734 remains open without an author PR. kiroPayloadMessages currently returns parsed.context.messages unchanged, so tracking adjacency at the top of its loop observes original Ocx message barriers even when a reasoning-only assistant is later skipped or user/developer turns collapse. + +Concrete source edits in src/adapters/kiro.ts only: priorCalls values retain rawId alongside wireName; validate each result against that exact raw id after normalizing for wire lookup. This rejects different raw ids sharing a replacement/truncation result without banning legitimate paired non-wire ids. Track adjacentRawToolResultId, reset it for every non-toolResult before any early continue; for matching adjacent raw id and last user turn/last wire result, append text content and images, set status error if any constituent isError. Otherwise retain pushUser and final conversation validation. No global dedup, cross-turn merge or normalizer change. + +MODIFY tests/providers/kiro/kiro-adapter.test.ts only for regressions: parse a real Codex custom_call plus three adjacent custom outputs (optionally preceded by the parent external task input), assert one ordered result; error remains sticky and images survive including image-only later output; single-result control; A/B/A and user/developer/assistant/reasoning-only barriers reject. Raw-id controls cover pipe/underscore, whitespace, truncation and case mismatches; exact raw pairs still normalize and merge. Keep every orphan/encrypted and catalog test. No new test/layout files. + +MODIFY docs-site/src/content/docs/reference/adapters.md Kiro section and structure/04_transports-and-sidecars.md with this bounded contract. Preserve Co-authored-by: Yrlan <71253160+yrlan-montagnier@users.noreply.github.com>. Resolve roadmap review thread PRRT_kwDOS-0Gi86fozIF only after the raw identity fix is verified. + +Local evidence limit: saved Kiro conversation data and OCX diagnostic artifacts were inspected for field shapes only; no current Codex multi-output Kiro trace was available. No raw message, id or credential was emitted, and no live Kiro request was made. Synthetic CI fixtures are protocol regression evidence, not a field-success claim. + +Dispatch: main owns adapter/docs; bounded worker owns only kiro-adapter.test.ts. Independent A/C reviewers inspect raw identity, original-message adjacency, error/image propagation and unchanged encrypted rejection. Full runtime CI is remote only, including existing Kiro image/adapter tests; live Kiro is forbidden. + +## Resumed P after verified guidance parent b7e67d84d + +The separate task-guidance cycle is complete, parent3743 P1 is resolved and CI34014313740 is green. Its verified head was merged into this preserved Kiro branch before implementation. Prior Euler review is folded below and must be rechecked before B. + +A contiguous group is finalized before any non-toolResult (including skipped reasoning-only assistant), before a different raw id, and after the loop. Track only local bookkeeping: rawId, reference to the fresh KiroToolResult, count, raw text parts and whether this group carried images; never put these fields on wire objects. A single-result group keeps the exact existing normalized text/fallback. For 2+ results, preserve ordered raw text parts except successful empty-exec wrappers, append images and keep any isError sticky. If the whole group has meaningful text, use those parts and remove any first-chunk empty fallback. Preserve whitespace text parts when meaningful text exists. If all text is empty, retain one existing fallback; use the neutral KIRO_EMPTY_TOOL_RESULT_MESSAGE when images or an error flag make an empty-success exec hint inappropriate. Failed exec wrappers are meaningful failure information and remain raw text in multi-result groups even when the incoming isError flag is false; preserve existing FAILED_EXEC_OUTPUT_MESSAGE for a single result. No new normalizer or message template. + +Read evidence: normalizeEmptyExecToolResultText distinguishes EMPTY_EXEC_OUTPUT_MESSAGE from FAILED_EXEC_OUTPUT_MESSAGE, and failed wrappers can arrive with isError=false. The wire validator requires at least one nonblank text part for each result; finalize groups before that unchanged validator. Keep the encrypted-content throw ahead of every grouping branch, and enforce exact raw id for every result, not only on coalescing. + +Additional regressions: later image-only/empty/success-empty wrapper does not inject placeholders into an already-populated result; initial empty then real text removes the empty hint; all-empty groups retain a valid nonblank result; multi-result failed wrapper retains its failure signal; later encrypted adjacent result still rejects; whitespace between meaningful chunks survives. Existing single empty/failed exec normalization tests must pass unchanged. + +## Resumed A dispositions + +Accept whitespace concern: collect a nonzero-length raw text part when trim is empty OR the shared normalizer did not classify it as EMPTY_EXEC_OUTPUT_MESSAGE. This preserves whitespace between/before actual text while discarding only true empty-success wrapper text; failed wrappers are never in that drop category. Finalization decides whether the aggregate has meaningful text. +Rebut the need for duplicated tool-name bookkeeping: create the first fresh wire result using the EXISTING normalizeEmptyExecToolResultText(text,{toolName,toolNamespace}) call before registering the group. A one-result group is never rewritten at finalization, so its exact precomputed fallback is retained; no normalization without identity occurs. Multi-result finalization replaces that initial content only with raw aggregate parts (or neutral empty text for image/error groups). Tests pin the existing single-result behavior and no bookkeeping keys on wire. diff --git a/devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md b/devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md new file mode 100644 index 0000000000..39dde71c1c --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md @@ -0,0 +1,19 @@ +# Adjacent Kiro result implementation + +The adapter retains each original call ID beside its normalized wire ID and checks +that exact identity on every result. A local group tracks only adjacent results; +non-tool messages, another ID and end-of-input finalize it before the existing +conversation validator runs. The encrypted-result rejection still happens first. + +Single results keep their precomputed, tool-identity-aware normalization. Multiple +results keep ordered raw text, real whitespace and failed-exec wrapper information, +while empty-success wrappers do not become extra messages. An initial empty hint +is replaced when later text exists. Images remain on the user turn with existing +limits, error status is sticky, and entirely text-empty image/error groups use one +neutral fallback. Group bookkeeping remains outside Kiro wire objects. + +Regression coverage is added to the existing adapter test file, including the +parent task-input plus code-mode-output sequence, collision controls, barriers, +empty/failed wrappers and images. Yrlan's source contribution is attributed. +Proof is independent review and exact-head hosted CI; saved local metadata did not +contain a current multi-output reproduction and no live Kiro call is performed. diff --git a/devlog/_plan/260906_release_244_followups/032_review_doc_format.md b/devlog/_plan/260906_release_244_followups/032_review_doc_format.md new file mode 100644 index 0000000000..ba50f43360 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/032_review_doc_format.md @@ -0,0 +1,11 @@ +# Review documentation formatting + +C0 follow-up for PR3743 review threads: add blank lines after headings, format +replay-field identifiers as inline code, and correct the audit heading/references. +The same heading pattern is normalized only within this release's two owned units. +No runtime, test behavior or release gate changes. Validation is diff inspection +and git diff --check; no local test suite is required or run. + +Publish as a documentation-only layer above the Kiro PR so the verified runtime +heads remain stable. Resolve the parent formatting notes with this concrete fix +and land the layer bottom-up before release. diff --git a/devlog/_plan/260906_release_244_followups/033_web_search_deadline_fixture.md b/devlog/_plan/260906_release_244_followups/033_web_search_deadline_fixture.md new file mode 100644 index 0000000000..d67cca89f6 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/033_web_search_deadline_fixture.md @@ -0,0 +1,24 @@ +# Check-phase cumulative deadline fixture repair + +CI34016017020 passed Kiro checks but macOS1 failed an unrelated elapsed <500ms +assertion (644ms) in web-search-timeout-contract.test.ts. The contract uses a +45ms response-header deadline; the wall measurement also includes preparation +and host scheduling. Source still starts one deadline before the rotation loop +and does not await the first response body's cancellation promise. + +Modify only that test file. For this one cancellation/rotation case, spy on the +existing clearableDeadline export and provide a controlled original deadline. +Hold the body-cancel promise until fixture cleanup; queue controlled expiry at the +next timer task when cancellation is requested. The immediate rotated fetch must +record that cancellation is still pending and that the same signal is unexpired. +This catches an added timer wait as well as awaiting the broken cancellation. +Assert one deadline factory call, +one real rotated fetch, cancellation/rotation/expiry ordering, cleanup and the +same exact504 response. Keep the existing1000ms test timeout unchanged. + +The real-timer header-timeout and abort-library tests stay unchanged. The fixture +tests deadline ownership and nonblocking cancellation rather than a loaded host's +wall time. Restore the spy and release/abort controlled resources in both finally +and afterEach, including a failing or timed-out test. No production timeout, +retry, skip or local suite is introduced. Verify by independent review and fresh +hosted CI in a separate prerequisite test-only PR beneath Kiro. diff --git a/devlog/_plan/260906_release_244_followups/040_opaque_recovery.md b/devlog/_plan/260906_release_244_followups/040_opaque_recovery.md new file mode 100644 index 0000000000..e3f994d7a9 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/040_opaque_recovery.md @@ -0,0 +1,56 @@ +# Opaque output rejection and terminal error recovery + +Depends on parsed-input/Kiro integration; C4. Carry #3535 2396829bded6d2aaf319e67dddb5918d83d1d3a0 (base 7e7ab281cca35600b41f1f80222f3462a87dd4e1), Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>. + +## Exact diff map + +- MODIFY src/lib/errors.ts: one ENCRYPTED_FUNCTION_OUTPUT_REJECTION constant, flat error message extraction alongside existing nested form. +- MODIFY src/server/responses/combo-stream-preflight.ts: optional narrow retryableTerminal predicate; existing two-argument callers retain default behavior. Bare error events count as uncommitted only where correctly retryable, not blanket authorization to replay effects. +- MODIFY src/server/relay.ts createSseTerminalOutputBoundary/upstreamErrorTailFrame and src/server/relay-eager.ts: observe upstream error on their own bounded client frame reader; at repeated bare-error EOF emit response.failed carrying real error instead of adapter_eof. Avoid async inspection branch race. +- MODIFY src/server/responses/core.ts encrypted function/custom outputs and agent_message detection, prepareOpaqueBlobRecovery, preflight: one sanitized rebuild before client output commitment; exact decrypt rejection predicate, not all HTTP 502. Mutate existing raw-body identity to preserve nonpersistable WeakSet marker. Preserve current rewrite ordering, cancellation and missing-call-id rejection. +- MODIFY tests/responses/responses-opaque-blob-recovery.test.ts, sse-failed-tail.test.ts, passthrough-abort.test.ts, tests/routing/combo-stream-preflight.test.ts as needed. +- MODIFY docs-site/src/content/docs/guides/sub-agent-surface.md and structure/04_transports-and-sidecars.md with bounded recovery/terminal behavior. + +Before: encrypted function output rejection can terminate without Responses terminal and surface adapter_eof; recovery handles fewer opaque shapes. After: one narrow sanitize/rebuild attempt; a repeated error is surfaced as failed with the actual message from the reader that delivers output. + +## Activation / review + +Remote tests: encrypted function-output or agent_message + exact decrypt failure permits one recovery; nondecrypt 502 stays unchanged; repeated flat/nested bare errors in tee and eager produce response.failed once; valid existing terminal wins; after client output commit no retry; raw-body identity/no-persist preserved; default combo caller compatibility maintained; caller cancellation remains cancellation. +Existing maintainer CHANGES_REQUESTED targeted older 2d90f9684 reader race; independent review of port must confirm remedy rather than asserting GitHub approval was granted. Remaining review threads checked for substance against final head. No preemptive stripping of all previous_response_id history, no broader retry policy. + +## Stack and proof + +Owner explicitly requests stacked PR workflow; use this relay foundation before combo-recovery and Grok terminal integration as an integration-validation stack, even though fixes are independently useful. Each layer remains independently tested via exact-head ci.yml runtime/gates. Security analysis stays scratch until public diff; no live Kiro. + + +## Current-dev carry amendment (2026-09-06) + +The carry starts at adb696197 after the task-input, Kiro and fixture layers. +The source remains 2396829bd. The current core rewrite order also contains +tool-search restoration and function completion repair; preserve both and the +shared prompt-cache cohort field. Source review is not current-head approval. + +Default two-argument preflight callers keep their previous event classification. +Only the explicitly supplied exact decrypt predicate may make a matching bare +error replayable; unrelated errors still commit the stream, and an existing +unrelated response.failed stays an SSE terminal. The retry predicate accepts +only error/failed/incomplete envelopes, never output events carrying a message. +The new failed tail uses existing redactSecretString before the 512-character +limit. Test bounded synthesized messages in tee and eager paths with synthetic +credential canaries; retain original upstream frame passthrough semantics. + +This cohesive carry exceeds the default 500-line review size because the source +includes a large request-level regression matrix. Keep source and regression +commits distinct inside this one layer, with independent protocol/security review; +splitting the tests into a later PR would leave recovery unverified. Existing +large core/relay files retain their current ownership for this bounded carry: +no export moves or broad refactor amid replay/cancellation changes. A new generic +retry abstraction or core extraction would enlarge the behavior under review. +Remote CI verifies all source and tests together; no local suite/typecheck/build. + +The existing core recognizes successful streaming Responses without Content-Type. +Keep that parity in the new preflight through an explicit fourth options argument +allowMissingContentType, enabled only by the same core streaming condition; default +combo callers still require text/event-stream. Add missing-header recovery and +non-SSE refusal controls. This avoids a source-PR gap where core selected recovery +but its preflight returned early solely because the header was absent. diff --git a/devlog/_plan/260906_release_244_followups/041_opaque_recovery_implementation.md b/devlog/_plan/260906_release_244_followups/041_opaque_recovery_implementation.md new file mode 100644 index 0000000000..a2dbe6f54c --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/041_opaque_recovery_implementation.md @@ -0,0 +1,20 @@ +# Opaque recovery implementation evidence + +Source 3b8cf8a8f carries PR3535 with a narrowly scoped preflight opt-in. The default +combo event classifier is unchanged; only a matched bare error supplied by the +native decrypt caller is replayable. Headerless streaming is an explicit option +under the existing core condition. Client-reader error evidence is redacted and +bounded before a failed tail is synthesized; real terminals remain authoritative. + +Independent plan audit accepted the scoped seam. Independent source/security +review passed: exact 502 gate, one sanitized rebuild, raw-body object identity, +no replay after visible output, cancellation and current rewrite ordering remain. +The source contributor is credited in the carry commit and PR. + +Regression commits cover native function and agent-message history, repeated +flat/nested errors, both relay shapes, unrelated errors and default combo byte +preservation, output commitment, missing-header and wrong-media-type controls, +and bounded synthesized-message redaction. The headerless fixture uses bytes +and asserts the absence of Content-Type because a string body supplies text/plain. +No local test suite, typecheck, build or live Kiro request was run. Final evidence +comes from hosted CI on the complete PR head and a fresh independent review. diff --git a/devlog/_plan/260906_release_244_followups/050_combo_recovery.md b/devlog/_plan/260906_release_244_followups/050_combo_recovery.md new file mode 100644 index 0000000000..33e4a905c4 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/050_combo_recovery.md @@ -0,0 +1,54 @@ +# Mixed encrypted combo recovery + +Depends on opaque-recovery for tested preflight/terminal composition; class C4. Carry #3706 c311e9598f9c4f3daf8cccdf1e27ba913ba94b30, source base 6dd23d6314c41f1113639e042353aae9e6614e62. Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>. Preserve source commit snapshots, avoid replaying obsolete source branch merge commit 97f453ab. + +## Exact diff map + +- MODIFY src/combos/resolve.ts targetProviderIsUsable and pickComboTarget/pickComboTargetWithWait: canonical OpenAI account/model selector owns quota decisions, provider cached summary cannot veto canonical target; third-party/noncanonical provider quota still filters, including wait eligibility. +- MODIFY src/server/responses/core.ts handleComboResponses: select actually payload-compatible target before deciding recovery; extract bounded recoverUnreadableEncryptedTask and encryptedTaskRecoveryAttempted; if native configured but disabled/cooling/no selectable native, recover once only when a usable routed target exists. Native model/account authorization exhaustion permits one recovered routed dispatch, excluding attempted targets. Preserve lastFailure and no-readable-target failures. +- Preserve clientCancelledResponse mapping at BOTH recovery sites when recovery aborts. The source PR helper returning false must not turn caller cancellation into unreadable-task HTTP 400. +- MODIFY tests/server/agent-task-recovery-combo.test.ts and tests/codex-integration/combos.test.ts; broader existing recovery/security/fallback/combo-preflight fixtures remain authoritative. +- MODIFY all eight existing docs-site/src/content/docs/**/reference/configuration/agents.md pages, describing actual selectable-native vs configured-native behavior. + +Before: a merely configured native target suppresses recovery even when not usable; canonical provider summary may veto before account selection. After: native direct preference stays, usable routed recovery becomes reachable only once with explicit opt-in and no plaintext persistence. + +## Activation / verifier + +Remote tests cover native disabled/cooldown, native 401 exhaustion, canonical summary exhausted with eligible account, noncanonical quota veto, caller eligibility, cooldown waiting, all targets unavailable skips recovery, recovery failure never dispatches plaintext/ciphertext, aborted recovery at both sites returns cancellation, no retry after client output. Preserve 32-inflight and no-persist safeguards where owned by recovery helper. +CodeRabbit HTTPS-only suggestion is assessed against existing http provider policy: do not invent combo-only URL permission changes. Record evidence-backed rebuttal or a narrowly necessary fix during P/security audit. This carry does not change provider URL policy or credentials. Exact-head CI + independent security review required; no live Kiro or local suites. + + +## Current composition and cancellation amendment + +The lower stack PR #3753 is merged as b9f2acc82 from cd6d4d346 (full +CI34020474748 and independent security/final reviews passed). Source #3706 remains c311e9598; its source-only +patch applies cleanly to this foundation. Preserve every opaque preflight and +client-reader repair; only handleComboResponses changes in core. + +At the initial unreadable-task recovery site, a false helper result returns 499 +when the caller signal is aborted, otherwise the existing unreadable-task 400. +At native exhaustion, recheck caller cancellation after routed-target waiting and +recovery, before adopting the last native failure. A successful helper remains +one-shot; normal failed recovery preserves the prior failure and never dispatches +unreadable ciphertext or persists recovered plaintext. Add deterministic abort +fixtures at both recovery sites using the existing fake upstream boundary. + +Canonical forward providers defer account/model quota admission to the existing +native selector; caller eligibility, target cooldowns and attempted exclusions +still apply. Noncanonical hosts and third-party cached quota remain filtered. + +No combo-only HTTPS restriction is added: this routes recovered content through +the same operator-configured provider transport as the already-supported all-routed +recovery case. Recovery credentials still go only to its existing fixed backend, +and explicit opt-in, loopback/caller guards and no-persist policy remain unchanged. +Introducing a new URL policy only for this combo branch would contradict the +existing configured-provider contract without evidence of a distinct boundary. + +Also update the English guides/sub-agent-surface.md paragraph that currently says +combo routing is unchanged and native-only. The configuration pages alone would +leave that guide contradicting the newly reachable opt-in routed recovery path. + +The parent now also preserves native preflight read resets/cancellation and +tee/eager failed terminal accounting, including semantic streamAborted parity. +The combo delta remains unchanged through that cascade; a fresh composition +review confirmed the same patch and the complete child runtime passed CI34020475627. diff --git a/devlog/_plan/260906_release_244_followups/051_combo_recovery_implementation.md b/devlog/_plan/260906_release_244_followups/051_combo_recovery_implementation.md new file mode 100644 index 0000000000..a3ec651598 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/051_combo_recovery_implementation.md @@ -0,0 +1,35 @@ +# Mixed combo recovery implementation + +The carry changes only combo selection in core and provider usability in the +combo resolver. A selectable native target keeps priority. If native candidates +are unavailable or exhausted, an available routed target may be selected after +one explicitly enabled encrypted-task recovery. Existing caller admission, +fixed recovery backend, attempt exclusions and plaintext no-persistence remain. + +Canonical native quota belongs to account/model selection; cached summaries keep +filtering third-party and noncanonical providers. Both initial and late recovery +failures recheck caller cancellation, including cancellation during target waiting, +before returning an unreadable-task or prior native error. + +Original contributor tests cover disabled/cooldown/native-401, failed recovery, +unavailable targets, canonical/noncanonical quota and eligibility. The new paired +abort fixture waits for the recovery fetch to start, then cancels its actual signal; +499/client_cancelled, no routed call and empty cache/continuation stores are asserted. +No local suites/typecheck/build or live Kiro request are used. Hosted exact-head CI +and independent source/security/final reviews supply integration evidence. + +## Verified composition + +- Source fd5e90f1b and regressions cd054d926 passed independent source/security + and final reviews. The initial full hosted run was CI34019564577. +- Parent #3753 required a separate repair cycle for preflight read failures and + tee EOF account outcomes. That repair is merged on dev as b9f2acc82; source + cd6d4d346 passed CI34020474748 and its two review threads are resolved. +- The resulting child e1f5a5b8d passed full CI34020475627. Stable patch ID + 8b62ad9ebb675f63a6dd4933e22663b48e1d95f2 matches the original combo delta, + and a fresh composition review passed. This documentation closeout changes + no runtime or tests. Final PR-head checks remain visible on #3754. +- #3706 remains open until #3754 actually merges. Closure requires a fresh + merged-state and dev-ancestry check; a successful merge command is not assumed. + +No local suite, typecheck, build or live Kiro call was used for these results. diff --git a/devlog/_plan/260906_release_244_followups/052_shutdown_fixture.md b/devlog/_plan/260906_release_244_followups/052_shutdown_fixture.md new file mode 100644 index 0000000000..3242fea9a0 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/052_shutdown_fixture.md @@ -0,0 +1,19 @@ +# Shutdown fallback fixture clock + +CI34021352755 on the documentation-only combo closeout failed one macOS test: +shutdown drain cap expiry enters the synchronous spill fallback. The run had +10,050 passes and one failure. This file and production state.ts were unchanged +from the previously green e1f5a5b8d runtime. + +The fixture freezes ACL and spill clocks but the shutdown reserve uses Date.now. +An 80 ms reserve therefore still races host disk/scheduling latency (the failure +was ETIMEDOUT inside fallbackPendingResponseSpills). Freeze that third clock only +around flush, using the existing spy pattern from the neighboring ordering test. +The real 40 ms drain timer still expires while the async publication gate stays +held; positive synchronous-call, empty-pending and installed-stub assertions remain. +Release the gate, await the publication tail and restore the clock in finally. + +This C1 verifier repair changes one fixture, no production timeout, skip or retry +policy. Budget-exhaustion/watchdog cases remain untouched. Land as a separate +prerequisite PR and cascade the combo branch. Independent fixture review and new +exact parent/child hosted CI are required; no local suite/typecheck/build runs. diff --git a/devlog/_plan/260906_release_244_followups/060_grok_terminal.md b/devlog/_plan/260906_release_244_followups/060_grok_terminal.md new file mode 100644 index 0000000000..e921cb620d --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/060_grok_terminal.md @@ -0,0 +1,57 @@ +# Grok Build sparse terminal snapshot compatibility + +Depends on composed relay stack; C3. Carry #3388 645180ceaf123c954ab5306969cf82da83566648, old base 3c920af5f7b18ecd98f87a589d21d299f5cbe172. Co-authored-by: Maple (zleo-ai). Preserve current dev f121348a9 sparse JSON function-repair fixture when resolving EOF conflict. + +## Exact diff map + +- MODIFY src/server/responses-snapshot-repair.ts: add createGrokResponsesSparseTerminalBlockRewrite and narrow item validators; if file exceeds existing size significantly, extract separate src/server/grok-responses-snapshot-repair.ts for Grok-only tracker while retaining existing exports. Record extraction in P before B. +- MODIFY src/server/responses/core.ts existing rewrite list: enable only logCtx.surface === grok and insert Grok terminal tracker immediately before createResponsesSnapshotBlockRewrite. Preserve current order custom-tool restore -> tool-search restore -> Copilot -> Grok -> provider snapshot -> field backfill -> function repair -> undeclared-tool guard. +- MODIFY tests/responses/responses-snapshot-repair.test.ts and responses-snapshot-repair-server.test.ts; preserve existing sparse JSON function completion inference tests. +- MODIFY structure/04_transports-and-sidecars.md and public adapters reference with client-specific boundary. + +Before: Grok Build renders deltas but sees empty completed.response.output and may retry. After: only marked Grok requests reconstruct empty/missing completed output from raw unique contiguous bounded semantically validated done items. Ordinary clients and default provider responsesSnapshotRepair flag unchanged. Require nonempty call_id on reconstructed function/custom calls; incomplete/failed/contradictory/gapped/duplicate/oversized shapes remain unchanged or fail closed according to current contract. No output fabrication from deltas alone. + +## Activation / verifier + +Remote unit and server fixtures: Grok positive text/function/custom output, missing vs explicit-empty terminal, ordinary-client byte preservation, explicit provider snapshot + Grok coexistence, invalid item shapes/indexes/ids, duplicate/gap/bound checks, failed/incomplete terminal cannot become completed, raw done order retained. CI typecheck/privacy/runtime gates on final head; contributor reported old baseline failures are not accepted without current evidence. This is Grok Build terminal compatibility, not Cursor/Grok semantic no-progress issue #3506. + + +## Current composition and module decision + +Base: verified combo #3754 at 1697a7748. Source #3388 remains 645180cea. +The existing snapshot module is 621 lines; the source adds 327 lines for a +separate client policy. Keep the provider policy stable and put the Grok tracker +in new src/server/grok-responses-snapshot-repair.ts. Extract only the existing +isPlainObject, jsonBlock and RetainedOutputItem into a leaf +src/server/responses-snapshot-codec.ts so both trackers share their wire codec. +Core and Grok tests import the new tracker directly; existing public snapshot +exports stay unchanged and no convenience re-export or circular edge is added. +The tracker imports the existing relay retention limits, SSE block type/parser +and budget type. The codec imports nothing. This local functional dependency +replaces duplication; the stream order is an explicit temporal dependency. + +Keeping everything in the old file would mix two different opt-in contracts and +push it near 950 lines. A broad provider-tracker refactor is also rejected. The +old module remains above the default size limit but shrinks without behavioral +changes; the new tracker stays below 400 lines. Its stateful closure remains one +cohesive retention owner. The source/test carry exceeds 500 lines because its +regression matrix must land with the behavior, not as an untested upper layer. + +Keep the source Grok describe as one top-level block before the existing provider +snapshot describe; do not split the latter. Preserve the current server file's +f121348a9 sparse JSON/function-repair EOF fixture. Add missing/empty/whitespace +call_id negatives for function/custom calls, a valid custom call alongside a +visible message, and a same-provider absent-marker/marker=1 server control. + +x-opencodex-grok: 1 is a client-selected compatibility opt-in, not authenticated +client identity. Do not add authentication or infer privileges from it. Public +adapters documentation must describe that boundary. No live Grok or Kiro probe +is required for this synthetic protocol repair. + +## Asynchronous verification + +The user directed CI to run after implementation asynchronously. Close this +implementation cycle after source audit, attributed PR and exact-head CI queue +verification, then proceed to the next unit. c-grok-terminal remains open until +hosted runtime CI succeeds; release convergence owns that unchanged criterion. +Do not merge or publish an unverified head. No local suites/typecheck/build. diff --git a/devlog/_plan/260906_release_244_followups/070_quota_proxy.md b/devlog/_plan/260906_release_244_followups/070_quota_proxy.md new file mode 100644 index 0000000000..c8d98e1373 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/070_quota_proxy.md @@ -0,0 +1,46 @@ +# Windows quota network-path evidence + +Depends on composed runtime; class C3 investigation and diagnostic documentation. #3644 remains a current-version evidence gap, not a proven entitlement or retry defect. + +## Exact map / before-after + +- READ src/codex/auth-api.ts fetchMainAccountInfoWhileOwned and listCodexAuthAccountsSnapshot: WHAM uses Bun fetch; quotaRefresh result is identity-fenced. READ src/codex/quota-refresh-outcome.ts enum/projector, src/cli/account-api.ts fetchCodexRows, src/config.ts applyProxyEnvWith, src/lib/windows-system-proxy.ts readWindowsSystemProxy, src/server/index.ts applyProxyEnv call. +- MODIFY docs-site/src/content/docs/reference/configuration/server.md and its seven existing translated counterparts: explain explicit proxy:auto/HTTP proxy versus an unset config and service-start environment; show privacy-bounded ocx account list openai --quota --refresh --json fields quotaRefresh.status and optional httpStatus. Do not paste account ids or credentials. Explain that WinINET/PAC/SOCKS and TUN are not equivalent transport evidence. +- MODIFY numbered outcome record only if current docs already fully cover this; NO runtime policy change without a reproduced categorized failure. Existing diagnostic #3693 (71edeec8807d99e8e56a8c093f74da27d163d47a) already carries Ingwannu's implementation, so no redundant reimplementation. +- Existing tests/codex-integration/codex-auth-api.test.ts, tests/cli/cli-account.test.ts, tests/server/proxy-env.test.ts are remote verifier paths; add fixture only for an uncovered documented config contract. + +Before: reporter's 2.43.0 output lacks newly landed quotaRefresh; system proxy mode null quota cannot distinguish direct network failure, HTTP failure or parsing. After: next release exposes already-implemented categories and explicit network setup guidance. A/B same machine/account: TUN on versus TUN off with explicit auto/HTTP configuration; observe status/HTTP code, not raw payload. No Windows environment is fabricated locally. + +## Acceptance / completion + +Fresh source and CI show diagnostic fields travel enum -> main-account cache -> snapshot -> CLI, with malformed/unrecognized extras dropped and null not converted to zero. Document unsupported PAC/SOCKS-only behavior according to actual code. Leave issue open if reporter evidence is still absent and record FIELD_VALIDATION_PENDING, rather than calling the underlying incident fixed. This evidence-limited investigation outcome satisfies this named investigation slice, not a false runtime fix. No outbound credentials or system configuration changes here. + + +## Current-source documentation decision + +The reporter's latest correction still uses 2.43.0; the maintainer explicitly +keeps #3644 open until a build containing #3693 supplies categorized A/B evidence. +Current main-account fetch, identity-fenced snapshot and CLI projector confirm the +diagnostic contract. CLI account.ts declares the existing command used below. +No new runtime fix or field-validation result is available. + +Add an English Codex quota network diagnostics section to server.md and concise +translated sections in ko/ja/fr/ru/tr/zh-cn/zh-tw linking that canonical anchor. +Scope the field to the main Codex account row, not every stored Pool account. +Explain that quotaRefresh (not the quota numbers themselves) is diagnostic only, +that cached/no-attempt output can omit it, and null quota is not zero quota. +Show the real account-list command with a PowerShell projection that outputs only +quotaRefresh, never complete account records. Use the seven fixed status strings +and optional httpStatus only for http_error; no inference of entitlement failure. + +Keep user guidance about the service environment, unset proxy and startup-only +WinINET auto detection; omit internal function/file names from the guide. State +that PAC/WPAD/SOCKS-only and live changes are unsupported by this auto discovery. +Do not claim TUN as a fix. Keep FIELD_VALIDATION_PENDING and the issue's open state +in 071 outcome notes/PR description, rather than hard-coding transient issue status +into all eight evergreen user pages. No local network or account calls. + +Per owner steering, documentation CI runs asynchronously. c-quota-proxy remains +open under release convergence until its scoped checks and unchanged-runtime +source evidence are reconciled. Do not add implementation-mirroring tests for +this documentation-only outcome; existing quota/CLI/proxy tests cover the code. diff --git a/devlog/_plan/260906_release_244_followups/071_quota_proxy_outcome.md b/devlog/_plan/260906_release_244_followups/071_quota_proxy_outcome.md new file mode 100644 index 0000000000..144266fcce --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/071_quota_proxy_outcome.md @@ -0,0 +1,20 @@ +# Windows quota investigation outcome + +FIELD_VALIDATION_PENDING. Issue #3644 remains open. The latest reporter correction +still concerns stable 2.43.0: TUN works, system proxy/rule mode without TUN returns +null plan/quota. The maintainer explicitly requested a categorized comparison from +a build containing #3693. No such current-build result is present. + +The existing #3693 diagnostic is on dev: main-account WHAM fetch outcome, identity- +fenced snapshot, and CLI projector preserve the fixed status vocabulary and optional +HTTP code. Null quota is not rewritten to zero. Current CLI declares +ocx account list openai --quota --refresh --json. Source inspection also confirms +service-start environment handling and static WinINET auto discovery. + +This unit adds canonical user guidance and seven translated links. It changes no +runtime retry, credentials, quota admission, proxy defaults or user configuration. +No live account/network probes or local suites/typecheck/build were run. Existing +quota/CLI/proxy regressions remain; documentation CI is submitted asynchronously. +The incident is not claimed fixed, and no reporter message is needed beyond the +already posted maintainer request. Final release reconciliation retains the open +field-validation status. diff --git a/devlog/_plan/260906_release_244_followups/080_usage_source.md b/devlog/_plan/260906_release_244_followups/080_usage_source.md new file mode 100644 index 0000000000..8c0f22ef89 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/080_usage_source.md @@ -0,0 +1,19 @@ +# Canonical xAI usage-attempt provenance + +Depends on routing/replay changes; class C4 due credential-derived logging. Carry #3642 head 146ed679c9633e5d68726217fcadc8e0b107339b, preserving Co-authored-by: olddonkey . Refresh source head before carry. + +## Exact map / field chain + +- MODIFY src/server/request-log.ts after sealRequestAttemptIdentity: recordAttemptCredentialSource clears stale value and derives only grok-oauth or xai-api-key from resolved canonical xAI transport and authMode. Require https, correct host/path policy and no userinfo/query/custom port; unknown/custom/provider mismatch omits. +- MODIFY src/server/responses/core.ts after initial identity seal and every reseal that can change selected transport. Inspect later seals individually: OAuth retry same physical attempt retains source; new transport clears/rederives it. +- MODIFY src/server/chat-native.ts buildActiveRequest: record from activeProvider at initial build and key-pool rebuild, after resolution. +- MODIFY src/usage/log.ts: UsageCredentialSource union, optional persisted attempt field, normalizeUsageAttempt sanitizer accepts only fixed enum for xai attempts; unknown/historic/non-xai values omitted. +- MODIFY docs-site/src/content/docs/reference/adapters.md and docs-site/src/content/docs/reference/management-api.md, plus directly contradictory translated rows if any. +- MODIFY tests/usage/request-log.test.ts, tests/usage/usage-log.test.ts and tests/server/server-xai-oauth-401-replay.test.ts, retaining original behavioral tests and adding any uncovered reseal case. + +Creation resolved runtime provider -> attempt helper; serialization usage append; deserialization normalizeUsageAttempt; consumers request history/management JSON/CodexBar integration read optional per-attempt value. No top-level combo attribution and no backfill from today's config. No UI enum interpretation added in this PR. + +## Activation / verifier + +Remote tests prove canonical OAuth Responses 401/replay source with sendCount=2, native Chat API-key source, pool rebuild, combo mixed attempts, stale label clearing, unknown enum/custom host/query/userinfo/port/non-xai/historic omissions, privacy canary excluded. All schema fields existing default behavior retained. Run ci.yml runtime+gates and explicit security review; no local tests or xAI live traffic required. Proof records final PR head and source identity, not fork author attestation alone. + diff --git a/devlog/_plan/260906_release_244_followups/082_recovery_doc_alignment.md b/devlog/_plan/260906_release_244_followups/082_recovery_doc_alignment.md new file mode 100644 index 0000000000..faf812d68b --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/082_recovery_doc_alignment.md @@ -0,0 +1,7 @@ +# Recovery wording alignment + +C1 documentation follow-up to public PR3754 threads PRRT_kwDOS-0Gi86fqklW and PRRT_kwDOS-0Gi86fqklX. Parent3762 at63282e49c; runtime3754 at8de126998 passedCI34025899357. No runtime behavior changes. + +Modify guides/sub-agent-surface.md and fr/zh-cn/zh-tw reference/configuration/agents.md only: native absence/exhaustion may activate explicitly enabled recovery toward an eligible routed target; unreadable ciphertext is not sent if recovery cannot provide a usable task. Clarify pre-dispatch unreadable400 versus preservation of a concrete failed native attempt; cancellation499 remains as implemented. Existing recovery auth, quota and no-persistence boundaries stay intact. + +Verifier: source-bound semantic comparison with existing helpers/fixtures, exact-head docsCI submission and outcome; no local test, typecheck or docsbuild under user limits. No external new permission, dependency or release rule changes. Close the public wording finding after published corrected pages; late runtime-status suggestion is rebutted with preserved native-failure contract and passing stored-Pool regression. diff --git a/devlog/_plan/260906_release_244_followups/090_dashboard.md b/devlog/_plan/260906_release_244_followups/090_dashboard.md new file mode 100644 index 0000000000..fcf4f54bb3 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/090_dashboard.md @@ -0,0 +1,31 @@ +# Dashboard alignment carry + +Depends on integrated runtime for final presentation; independent PR, class C2. Carry #3697 head 49a9c79392babd9413831437d6ad71839737b148 (base cededd5ad1b8f8c437813c315c0705ace6c950c3), preserving Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com>. #3689 authless-default change is outside this train. + +## Exact change map + +- MODIFY gui/src/styles-dashboard-workspace.css: shared label/control columns, --dash-controls-width around 26rem, container-based collapse, full-width delegation/sync rows. +- MODIFY gui/src/styles.css: consistent status card alignment and responsive version badge behavior. +- MODIFY gui/src/pages/dashboard-overview-head.tsx and dashboard-overview-sections.tsx: carry original layout classes only; preserve all handlers, state and new controls from current dev. +- MODIFY gui/src/App.tsx: sidebar/mobile version width yields to product name and retains full-value hover. +- MODIFY gui/tests/mobile-topbar-layout.test.ts: version flex-shrink and stable small-layout contract. +- MODIFY docs-site/src/content/docs/guides/web-dashboard.md; ADD original screenshot docs/pr-assets/dashboard-settings-aligned.jpg only as supplied by source PR, mark its source/version clearly. Capture updated screenshot if final rendered content differs. + +Before: uneven columns, two-up tool cards squeeze controls, version text can take product space. After: wide single label/control grid; narrow stacks preserve reading order and 320px selector fit. No visible strings added; any necessary additions require all locale modules. + +## Acceptance / verifier + +Remote GUI lint/stylelint, GUI tests and Vite build from ci.yml; verify rendered wide/narrow state using existing browser tooling with CI-built/static artifact when available (no local suite/build). Inspect original screenshot at exact source SHA and do not claim it proves later changed content. New screenshots must show final UI, with no account info. Regression test alone is not visual proof; independently inspect UI screenshot and CSS breakpoints. + +## Limits + +No authless setting, quota semantics or model management expansion. Preserve current state labels and accessibility. P rechecks any intervening same-file changes before carrying. + + +## Hosted artifact verification + +Main owns the eight-file attributed carry; handlers and visible copy remain unchanged. The existing `ci.yml` gates job uploads a preview after its GUI build when `changes.outputs.gui` is true. `actions/upload-artifact` v7.0.1 is pinned to `043fb46d1a93c77aae656e7c1c64a875d1fc6a0a`, verified against the official release and tag. The artifact contains only `gui/dist`, including generated `build-commit.txt` and `build-gui-tree.txt`, with `retention-days: 7` and `if-no-files-found: error`. Triggers, permissions, secrets, checkout behavior and release eligibility remain unchanged. This workflow surface makes the unit C4 and requires independent security review. + +PR CI may build a merge ref, so compare the artifact's GUI tree with the reviewed head's `gui` tree before using its screenshots as final evidence. Serve the downloaded build with an isolated fixture API and inspect it at 1440, 1024, 768, 390 and 320 CSS pixels, including keyboard operation, focus and overflow. No local build, typecheck or repository test suite is run. Public screenshots contain synthetic data only. The original contributor screenshot is a reference, not final-head evidence. + +Use manual dependent PRs after the owner's native-stack removal. CI and admin integration continue asynchronously without a local rebase. Hosted CI owns lint, typecheck, tests, build and privacy checks. The release goal remains open until publication proof is complete. There is no user-imposed token or cost cap; individual tooling runs are bounded at 30 minutes and waits at 60 seconds. diff --git a/devlog/_plan/260906_release_244_followups/091_dashboard_verification.md b/devlog/_plan/260906_release_244_followups/091_dashboard_verification.md new file mode 100644 index 0000000000..bd3f413b41 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/091_dashboard_verification.md @@ -0,0 +1,30 @@ +# Dashboard visual verification + +Source PR #3764 at 42689e02a60ca230e53dee1f872864af5d6b0872. Hosted CI 34029024036 passed the runtime matrix, gates and installation checks. Gates built artifact 9988003853 from checkout 274d2f8cb7ad8c381db29fa1799ad446c034da39. Its GUI tree 1eff4e4d3485133600e4fdb6e9ba78c36c0bf1e4 equals the reviewed source tree. The artifact SHA256 is c4f507ac7ab269170d6ec82e5351b8a526072524baa95403046e52293ae831de. + +The real built frontend was served on an isolated loopback fixture API. Displayed model selections, request totals, memory figures and the deliberately long preview version come from synthetic fixtures. No live provider or running OpenCodex service was used. No repository local test suite, typecheck or build was run. + +## Observed results + +| Scenario | Evidence | Result | +| --- | --- | --- | +| Desktop 1440 | [capture](screenshots/dashboard-1440.png) | Controls align, long model labels stay inside their buttons | +| Split-screen 1024 | [capture](screenshots/dashboard-1024.png) | Shared control rows stack when the content area narrows | +| Tablet 768 | [capture](screenshots/dashboard-768.png) | No horizontal page or control overflow | +| Mobile 390 | [capture](screenshots/dashboard-390.png) | Radio group, effort pair and version badge fit | +| Narrow 320 | [capture](screenshots/dashboard-320.png) | No horizontal page or control overflow | +| Narrow shadow row | [capture](screenshots/dashboard-320-lower.png) | Full Korean heading is one 21px line; source badge wraps below | +| Keyboard selection | [open](screenshots/dashboard-320-keyboard-open.png), [saved](screenshots/dashboard-320-interaction.png) | Visible keyboard ring; high to xhigh produced one fixture PUT and persisted the value | +| Empty/repeated choice | [capture](screenshots/dashboard-320-empty-repeat.png) | Selecting no limit twice keeps the null state and readable placeholder | +| Dark/reduced motion | [capture](screenshots/dashboard-1440-dark.png) | Readable control labels and boundaries; reduced-motion media active | +| Two-times pinch zoom | [capture](screenshots/dashboard-1440-zoom2.png) | Zoomed viewport captured; reflow is established by the separate CSS-width matrix | + +Each PNG has its signature, nonzero size and exact requested width by 900px height verified. Main inspected every referenced frame; two independent rubric-bound reviewers passed the final set. DOM measurements show page scrollWidth equals viewport width and every select label remains within its button. The shadow heading height equals its 21px line-height at all five widths. Fresh browser console capture contained no output; loaded assets and fixture calls used the loopback origin. + +The first artifact at eb35039fd reproduced a long delegation label reaching 1425px beyond a button ending 1218px. ec88720e6 added scoped span shrink/ellipsis rules. The first narrow capture then exposed a Korean heading orphan; 42689e02a wraps shadow metadata below the heading on narrow containers. These were observed corrections, not inference from green CI. + +Shared Select post-save focus behavior and unchanged sticky chrome were not represented as repaired. Malformed free-form input is not exposed by these select-only layout controls; HTTP parsing is unchanged. Source compatibility and the 15-line artifact workflow addition received independent reviews. The upload remains contents-read, uses an immutable action pin, uploads only built output and expires after seven days. + +## Teardown + +The fixture process was terminated, the isolated Chrome profile was stopped, and both listening ports were confirmed closed after the captures. Raw captures, exact invocations, DOM measurements and the validated three-scenario QA receipt remain in the local session evidence directory. This publication commit adds documentation and captures only; the GUI tree is unchanged from 42689e02a. diff --git a/devlog/_plan/260906_release_244_followups/100_release.md b/devlog/_plan/260906_release_244_followups/100_release.md new file mode 100644 index 0000000000..c3a53873d2 --- /dev/null +++ b/devlog/_plan/260906_release_244_followups/100_release.md @@ -0,0 +1,24 @@ +# Release verification and publication + +Depends on all preceding delivery criteria. Class C4. No local test/build/typecheck run. + +## Exact actions and file map + +- MODIFY package.json via scripts/bump-dev-version.ts for pre-move: for target 2.44.0 dev must outrank target before publish (normally 2.45.0). Freeze feature RC before pre-move and pin it. +- Use existing scripts/release.ts authority and .github/workflows/release.yml, ci.yml, service-lifecycle.yml. No changes planned unless an evidenced defect blocks this train; add a dedicated phase for such repairs. +- Create bounded promotion branches from verified feature RC independently for preview/main; version-only preparation matches intended preview/stable targets. Never mix unrelated current-main state or overwrite the bound worktree. Reviewable promotion PRs include target exception and verified UI screenshot/link from current delta. +- Push --no-verify; merge --admin --match-head-commit after exact-SHA gates. Prefer merge ancestry on live stacks; squash only terminal branches with cascade accounted for. +- Dispatch ci.yml lane=all on the frozen candidate AND each exact preview/main publish expected-sha (version-only promotion commits are separate heads); require actual successful windows 1/6 through 6/6 plus Linux/macOS, gates/privacy/typecheck and package install jobs where applicable. Dispatch service-lifecycle.yml on exact final promotion heads if not triggered. Do not count skipped windows as passed. Wait for the main promotion head docs build from deploy-docs.yml before stable publication; it is separate from ci.yml, which does not build documentation. +- Dispatch release.yml using verified inputs version, tag, expected-sha, dry-run=false. Preview precedes stable; npm target @bitkyc08/opencodex. No helper rehearsal assumed nonmutating. +- Verify npm version metadata, dist-tags, tarball sha512, gitHead, signed provenance, git tag target and GitHub release for each channel. If publish succeeded but smoke failed, inspect before retry; never republish blindly. +- Close fully resolved issues and superseded source PRs with original-author credit and actual landing references. Keep #3644 open if network root cause remains unproved and clearly communicate its tested diagnosis outcome. Document Kiro live test absence. +- MODIFY this unit's numbered evidence/closeout; archive to devlog/_fin only after outcome is public. Complete goal only after E8 criteria and every D closure succeeds. + +## Failure activation / proof + +A failed exact-SHA run triggers log-based RCA and repair; newer dev invalidates ancestor assumptions and is fetched before merge. A missing service run is dispatched, not skipped. Registry already-published check prevents duplicate publication. Final source head, artifact head and tag head must match documented promotion topology. Rollback means redeploy prior known package/version; immutable npm version is not deleted or overwritten. + +## Resources and security + +GitHub Actions/OIDC and existing registry read access, no static npm secret introduced. Existing main/preview protection retained; per-user admin merge authorization applies to this train. Commands bounded at 30 minutes, polls <=60s, continue across CI windows with persistent evidence. Source runtime and artifact validation use remote CI only. + diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1024.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1024.png new file mode 100644 index 0000000000..4bbd3ec147 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1024.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-dark.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-dark.png new file mode 100644 index 0000000000..06655de300 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-dark.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-lower.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-lower.png new file mode 100644 index 0000000000..8fee2e6757 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-lower.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-zoom2.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-zoom2.png new file mode 100644 index 0000000000..8464793fa0 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-zoom2.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440.png new file mode 100644 index 0000000000..097a22f043 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-empty-repeat.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-empty-repeat.png new file mode 100644 index 0000000000..b3fe763d4c Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-empty-repeat.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-interaction.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-interaction.png new file mode 100644 index 0000000000..f9ecfe445c Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-interaction.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-keyboard-open.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-keyboard-open.png new file mode 100644 index 0000000000..60e2c8fbf6 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-keyboard-open.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-lower.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-lower.png new file mode 100644 index 0000000000..b26cf9acbb Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-lower.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320.png new file mode 100644 index 0000000000..b0faf7667d Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-390.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-390.png new file mode 100644 index 0000000000..7e513265b1 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-390.png differ diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-768.png b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-768.png new file mode 100644 index 0000000000..7b96c13217 Binary files /dev/null and b/devlog/_plan/260906_release_244_followups/screenshots/dashboard-768.png differ diff --git a/devlog/_plan/260906_release_244_publish/000_plan.md b/devlog/_plan/260906_release_244_publish/000_plan.md new file mode 100644 index 0000000000..02192a924f --- /dev/null +++ b/devlog/_plan/260906_release_244_publish/000_plan.md @@ -0,0 +1,16 @@ +# 2.44.0 release train + +Loop: satisfy-spec / C4. Trigger: maintainer explicitly requested main + preview merges and deployment after PR3771 CI passes, with cxc-loop. Goal: verified npm preview and stable2.44.0 plus release docs. Non-goals: local suites/typecheck/build, liveKiro, unrelated features, native stacks, rebase, direct protected-ref pushes, credential/settings changes. Verifier: GitHub exact-SHA jobs and actual Test steps, release workflow immutable SHA guards, npm metadata/digests and source-bound docs deploy. Stop: all five units done with receipts; never count pending/skipped as pass. Memory artifact: this numbered unit + .tmp/release-244 + bound goalplan. Outcomes: DONE only with published channel proof; failed gates remain unresolved; ambiguous publish requires registry inspection. Escalation: missing actual account authority or outstanding maintainer objection, unplanned security defect, or exhausted evidence-driven attempts. Existing GitHub/npm OIDC access only; user supplied no numeric token/time budget. Operational review checkpoint: six hours or five failed evidence-driven attempts per release surface; do not call that success. Leaves have read-only audit scope; main reclaims failed dispatches. + +## Dependency order +1. Roadmap docs only (010). +2. Exact regression candidate integration and freeze (020). +3. dev pre-move2.45.0 (030). +4. Independent preview promotion, dry run and publication (040). +5. Independent stable promotion, dry run, publication and docs proof (050). + +Fresh baseline: main06ec553630fa2ee51a96b5cbf694089021249194/latest2.43.0; preview53c784c2a635b061799e4f7542432a921f548bf9/2.43.0-preview.20260906; devbd1cda99c162e3b4b41b14f6ad5ca2cf6f1a1f03. Candidate69f9e07c4fa7b80bcda9e4ba28e3c64f42187828 includes that dev. Service34034184142 all3pass; manualCI34034178072 completed with one Windows3/6 cold-restart apply deadlinefailure; originalWindows25 and macOScontrol20404/0 nowpass. #3763 deferred documentation remains user-withdrawn; #3644 field report remains unresolved, with no runtime-fix claim. This train does not silently reinstate either task. + +Authority: MAINTAINERS.md and scripts/release.ts / .github/workflows/release.yml. Local release helper is not invoked because it runs local suites and can push. Existing workflows perform build/audit/pack/publication on hosted runners. No new runtime field/enum or enforcement is added. GitHub required checks/immutable workflow guards are enforcement; manual admin integration remains bypassable owner authority and is documented accurately. SoT sync: no architecture/CLI contract changes; public release notes generated by the existing changelog builder. Existing dashboard evidence from prior verification is reused with exact source provenance, never claimed as a new render. + +Latest user steering: no heartbeat automation; track all CI and release workflows by direct bounded polling in this task. Automation3771-ci-dev is absent (delete returnednot_found). No replacement automation is authorized. diff --git a/devlog/_plan/260906_release_244_publish/010_roadmap.md b/devlog/_plan/260906_release_244_publish/010_roadmap.md new file mode 100644 index 0000000000..4b176d7ba3 --- /dev/null +++ b/devlog/_plan/260906_release_244_publish/010_roadmap.md @@ -0,0 +1,6 @@ +# Roadmap lock +Dependencies: none; consumes previous verification-only conclusion, whose no-release limit is superseded by the new explicit maintainer request. +NEW local numbered000/010/020/030/040/050 documents and goalplan. No product delta or remote publication in this cycle. Before: no release-authorized active plan. After: dependency-ordered audited plans with all final-head/registry criteria. Review complete docs and live workflow inputs; source audit commands are read-only. Check `git diff --check`, required doc existence and nonempty criteria; these verify documents, not product runtime. Commit these records on codex/release-244-publish-07c0 only. Keep this commit out of PR3771's already-running head. D records independent audit and next020. Existing no-local-suite constraint still applies. + +## Locked CI event contract after independent audit +Windows1/6 through6/6 and macOScontrol are RC/#3771 validation gates. For version-only independentpreview/main promotions, require each finalSHA's successful push-event Cross-platformCI and sameSHAServiceLifecycle; do not launch laneall on a releasebranch while its pushrun is active, because branch-ref cancel-in-progress can cancel the requiredpushrun. If any runtime/source drift from frozenRC appears, stoppromotion and returnto RCvalidation. An extra manualrun, if actuallyneeded, starts only afterpushCI completion. This is the predeclared gate mapping, not a waiver of a failedtest. diff --git a/devlog/_plan/260906_release_244_publish/020_integrate.md b/devlog/_plan/260906_release_244_publish/020_integrate.md new file mode 100644 index 0000000000..4fdff56abd --- /dev/null +++ b/devlog/_plan/260906_release_244_publish/020_integrate.md @@ -0,0 +1,43 @@ +# Regression integration and immutable RC +Dependencies: roadmap. Initial pre-diagnostic scope: no new production code was planned; the later test-only SPAWN_BUDGET_MS change for apply.child.exited is recorded below. PR3771 already contains tests/preload.ts, tests/ci-workflows/test-runner.test.ts, src/adapters/cursor/native-exec.ts and tests/providers/cursor/cursor-blob.test.ts. Before: open draft69f9e07c4. After: reviewed green PR squashed into dev and recorded integrated RC with package2.44.0. +Read `gh pr view 3771 --json headRefOid,baseRefName,state,statusCheckRollup,reviews` and GraphQL reviewThreads plus live maintainer permission. Read manualCI34034178072 and lifecycle34034184142, asserting head equality and actual Windows1..6 Test/macOScontrol execution. Original25 Windows cases must pass; Cursor4096/4097 original bound and added expiry/accounting tests must pass. Trigger scenarios are original failures and missingcapability/expired-pin cases already encoded in regression tests. If red, inspect failing job logs and only repair the scoped cause in an amended unit; no retry-as-fix. +When all required exacthead checks/reviews pass, record maintainer integration evidence in PR body, mark ready and `gh pr merge 3771 --admin --squash --match-head-commit `. Validate dev base immediately before merge. Fetchdev, verify merge commit ancestry, and record RC as the integrateddev commit. Any later devcommit is not silently added to RC. No directdevpush. Host CI verifier APIs already ran successfully (exit0) and read this exact head, while final conclusions remain pending. + +## Fresh release blocker discovered during roadmap audit +At69f9e07c4 manualCI34034178072 Windows3/6 job101489123778 fails tests/claude-integration/claude-desktop-remote-hub.test.ts stored-profile=true on its30second RemoteDesktop apply deadline; falsecase passes in61.7seconds total. macOScontrol is nowgreen. Extend this unit to diagnose/fix the newly observed Windows release-validation failure using explicit hypotheses and hosted baseline/candidate evidence, preserving original behavior assertions and isolation. Candidate file scope is that fixture and its direct CLI apply dependency only if diagnosis establishes runtimecause. No change justified by merely increasingbudget/retrying. Update this plan with exactdiff and independentreview before implementation. #3771 cannotmerge until allgates pass. + +## Diagnostic diff before repair +PreviousD locked releaseorder and gateeventcontract. H1 loopbackdownloadfails5secondbound; H2 Windowschildnativework (PowerShell/icacls) occupies30seconddeadline; H3 commandfails to reachprocess.exit. Falsifiers: fetchstart/endtiming, nativeprocesscategory+duration, processexitmarker and alive-at-deadline. Per-process timers afterprocess.exit cannotexplainfailure. +FirstB diagnostic only: temporarycontents:read Windowsworkflow on separatecodex/diagnose-desktop-apply-07c0 rooted at69f9; pinnedcheckout/setup, existinginstall/build. MODIFY tests/fixtures/claude-desktop-network-guard.ts onrunner to wrapBun.spawnSync/fetch/process.exit with fixedevent/category/millisecond receipts to a fixtureownedfile; preserveoriginalnetworkpermit. MODIFY failingtest onrunner only to print atmost512fixedrecords in a finallyblock aroundunchanged30000msdeadline. No rawargs/URLs/tokens/stdout/stderr or productionpatch. Exact instrumentation and workflow are .tmp/release-244/ci-repair/instrument-desktop-apply.py anddesktop-apply-ci.yml. BaselineWindowstrue/falsepassed82.1/79.5seconds total; candidate69.3failure/61.7pass doesnotisolateapplycost. Reviewdiagnosticthenexecute; use findings to amend exactrepairdiff and re-audit before production/testpatch. + +Diagnostic audit synthesis: reviewerconfirmedpermissions/pins/networkguard/30sdeadline andprivacyscope. Added asynchronousBun.spawn completiontraces (not justspawnSync) tocover asyncACL. Removedredundantglobal--timeout60000 fromisolateddiagnostic (eachcasealready240000 andcleanup90000). JSONLsplit findingrebuffed withgeneratedTS static inspection: Pythonwrites oneescaped\\n intoTSstring, whose runtimevalueisnewline; replacingitwithphysicalnewlinewouldbreakTS. No repositorytests/typecheck/buildran; onlyfilegenerationintoscratchwasinspected. + +## Measured cause and proposed one-file harness repair +Diagnostic34035944642 at9358fad1: apply25,929ms exit0; nativeknownfolderPowerShell22,811ms, SID197ms, hubdownload10ms, ACLsteps16-19ms each, registry24/16ms, process.exit0. H1downloadstall and H3exit/lingeringhandle rejected in this trace. H2 refined to actualWindowsknownfolder lookup cost, notACL. src/codex/user-identity.ts explicitly gives this lookup30seconds; an end-to-end30secondapply test budget can expire before a validnear-budget lookup plus requiredCLIwork finishes. Producttimeout remains30seconds. +Proposed MODIFY tests/claude-integration/claude-desktop-remote-hub.test.ts ONLY: importexistingSPAWN_BUDGET_MS from ../helpers/test-budget; replace apply.exited30_000 withSPAWN_BUDGET_MS(45_000). Explain measuredlookupcost in comment. Preserveoveralltest240s, cleanup90s, networkguard and allmodel/profile/restartassertions. No productguard/ACL/identity changes. Beforepermanentedit, hostedcontrolledslowlookup: nativePowerShell command pads its realexecution to28s inside the unchangedproduct30sbound, only in diagnosticclient. Original30sbudget mustfaildeadline;45s candidate mustpassoriginalassertions. Then removeinjectedslowlookup and mutate productionapplysnapshotchosenalias20260211->validbutwrong20260911; the45s test mustfailonmodelassertion (notdeadline), satisfyingtests/helpers/test-budget.ts requiredablation. Followwith uninstrumentedexactheadall-laneCI andlifecycle. No retry-as-fix and no change to4096/4097or original25cases. + +Independent Hilbert review: VERDICT PASS. The30s wholeCLI deadline is narrower than the product's30s lookup plus measuredrequiredwork; existing45sSPAWN_BUDGET remainsbelow240stest. Acceptance remainsconditional on controlledvalid28slookup red/green and wrongalias modelassertion ablation. Diagnostic34036362222 at29fa76f07 isrunning these onhostedWindows with unchangedproductguards. No permanenttestbudgetedit yet. Primaryoperator explicitly requested directpoll; noheartbeat exists. + +## Hosted proof and permanent delta +34036362222 (29fa76f07): controllednativeknownfolderlookup28,226ms completedwithinproduct30s; oldwholeCLIdeadlinefailed30,004ms withchildstillalive. Canonical45s case exited0 at31,381ms andall22originalassertionspassed. Itsablationstep didnotexecute because diagnosticPythondefaultCP1252 couldnotreadKoreanUTF8source; notaproductfailure. +34036626846 (247651739): UTF8-correct standaloneablationran with45sbudget; validbutwrong20260911aliasmadeoriginalwritten.inferenceModels.toEqual(snapshot.models) fail in57,052ms testcase, withoutapplydeadlinefailure. This proves thelongerbudgetdoesnotmaskthemodelidentityregression. Permanentdelta nowonlyimportsSPAWN_BUDGET_MS andusesitforapply.child.exited; allproducttimeouts/guards/assertions unchanged. Fulluninstrumentedlatest-headCI/lifecyclewillrunagain beforeintegration. Diagnosticworkflows andfaultinjections remainoffPR. + +## Replan after remaining product refusal +C at9d624987c: Windows3/6 run34036848646 job101496387805 returnedclient_lifecycle_lock_failed in storedprofiletrue (72.4scase), whilefalsepassed102.8s. Thisisnot45sdeadline; nofurtherbudgetraise. Cdidnotpass, resettoPwithsameunfinishedunit. H1 identitylookuprefusal/timeout (knownfoldertrace22.8s); H2 ACL/filesystempreparation failure; H3 SQLite/namespace safetyrefusal. Classify error.cause codes usingfixedallowlist andidentity-timeout/namespacebooleans inthrowawaydiagnostic, plusnativeexitcode/timedOutflags. Threefreshsamplesmeasurefailurecondition ratherthanretryingforgreen; noobservedfailuremeansunresolved, notsuccess. Scriptsinstrument-lifecycle-cause.py/lifecycle-cause-ci.yml stay.tmp anddiagnosticrefonly, basedon921docsheadwhichpreserves9druntime. FutureFFIlookupoptionsare read-onlyresearch untilcauseconfirmed andsecurityreviewed. Priorinternalattemptcountisareviewcheckpoint, notuser-requestedterminationbudget; do not abandonthereleasegoalorclaimcompletion. + +Three-sample diagnostic34038264294 didnotreproduceclientrefusal (allpass; apply25.8-26.3s, nofailureflags). ItdoesnotclearfullCI. Nextboundedprobe keepsfullWindows3/6context alongsideisolatedcase, addingonlyfixedPowerShellphase timings aroundAdd-Type andSHGetKnownFolderPath. This separatescompiler/startupcost fromnativeAPIlatency beforeconsideringanyin-processFFIcall; a slowOSAPIwouldmake synchronousFFIanunsafeperformancefix. Exactscriptinstrument-known-folder-stages.py andworkflowknown-folder-stages-ci.yml arediagnosticonly. No budgetincrease orproductionlookupedit authorizedbyemptyflags. + +Newfixture hypothesis fromworkingcomparison: tests/codex-integration/codex-user-identity.test.ts givesrealchildprocessesownedTEMP/TMP andexistingLOCALAPPDATA within10schild/20stestbounds; Desktopfixtureallowlist omitsTEMP/TMP andpointsAPPDATA/LOCALAPPDATA atuncreateddirectories. Isolate missing/temp-only/profile-only/both withunchangedproductionlookupandallassertions. This canexplaincompilerlatencywithoutaproductrewrite; fixture-onlyrepairpreferredifmeasured. Diagnosticfixture-environment-ci.yml usesfourfixedmodes, freshWindowsjobs andprivateownedfolders. No nativeAPIcodechange. + +Fixtureenv experiment34039649747 rejectsTEMP/AppData-onlyrootcause: missing/temp/profile/both allretain22-27scompilationcost; no failureflags. Separatestageprobe establishesAdd-Type17.8s versusnativeAPI16ms, andfullshardclientcasespassed with16.7scompiler (hygiene failuresareexpectedbecauseitsdiagnosticCIyamlreplacesnormalworkflow). Thisdoesnotidentifytheoldgenericrefusal, butitproves a removablecompilerhotspot threatening the existing30schildbudget. + +Proposed experimentalruntime delta, notyetappliedtoproduct: replaceonlythefixedknown-folderAdd-Typebinding with public.NETFramework Reflection.Emit metadata in the same trustedPowerShellchild. Keep30stimeout, outputUTF16/base64, successfulcache, SIDquery, GUID, DEFAULT_PATH0x400/null-currenttoken, canonicalization andACLchecks. Noin-processFFI/unboundednativecall. DefinePInvokeMethod signatureGuid&,UInt32,IntPtr,IntPtr& ->Int32, Winapi,Unicode; PreserveSig required; outparameter metadata; FreeCoTaskMemfinallyevenfailure. DLLpathcomesfrom.NETSystemDirectory, notenv. Hostedprototype comparesunchangedlegacyC#referencepath, envshadowpath, negativeGUIDHRESULT, andfocusedidentity/Desktoptests beforepermanentpatch. Exactemit-known-folder.py andknown-folder-conformance.ts in.tmp. PrimaryMicrosoftDefinePInvokeMethod/AppDomain/PreserveSigdocs were opened; generatedpublicAPIexampleconfirms thismetadataapproach. Nativegenericfailure remainsunclassifieduntilfullgate; donotclaimitfixedfromprototypealone. + +Prototype security review (Locke): PASS forprototype-onlydispatch; originalreference savedbeforeedit, 9-argentrypoint/PreserveSig/finally-free,30sproductionbound,45slegacyoracleonly, no rawpathlogging. Run34040992290 atad982feb53 uses thisprobe andexistingfocusedtests. No productionlookupedit yet. + +Windows1 whole-job bound: cancelledrun completed2736passingtests versus2981in priorcompletedrun. Matchedtests took1244s vs996s (25%slower); remaining245tests took49.4s previously, about61.7s atobservedratio. Therewere no reportedtestfailuresbeforejobcancellation. Proposedci.yml platform-windows timeout25->30minutes gives finitebatch/cleanupmargin whilepreserving allsixshards, everytestcommand, per-testdeadlines and crash-onlyretry. Updateexistingci-workflows.test.ts budgetexpectation25->30. This isnot a code-failurewaiver and must stillcompletealltests; securityreviewmustconfirmno trigger/permission/runner/pin changes. + +Prototype34040992290 passedsamepath/shadow/HRESULTconformance (legacy3579ms, emitted402ms, shadow382ms, productionbound30000), and9focusedtests. Howeverminimal-environmentDesktopcasesstilltook82.7/76.9s. Thereforedo notlandtheproductionrewrite: itdoesnotremovefixture-pathlatency. Nextdiagnostic34041357794 isolatesPSModulePath, executablePATH/PATHEXT, andWindowsinfrastructureenv asfourstaticmodeswithoriginalproductionlookup. Theseareonlyrunner-localdiagnosticchanges; fullPATHinheritanceisnotauthorizedasapermanentfixturefixbecauseitwouldwidenexecutablevisibility. + +Confirmedfixturecause: keybisect34041973010 andsingle-variable34042207026 isolatePSModuleAnalysisCachePath. Inheritedpreparedcache: apply3731ms/AddType215ms; missing27309/22840ms, NUL23473/20008ms, emptyowned25964/22720ms. Thusfreshcacheanalysis—notjustC#binding—is thecost. Owned-copy34042446427 preservesoriginalcacheisolation andpassesoriginalscenario in12.9s withapply4632ms/AddType190ms. No productionlookuprewrite willland. +Permanentfixturechange: Windows-only ownedmodule-analysis-cache path seededbycopy from anabsolute, regular, non-symlinkparentcachewhenavailable; no blanketenv/PATHinheritance. Missing/staleseedorchangedsizefallsbacktocoldownedcache; otherIOfailuresremainfailureswithoutloggingprivatepaths. Alloriginalmodel/credential/restartassertionsandguardsremain. KeplerindependentreviewPASS forownedseedingandracehandling; Windows30minwholejobbound separatelyPASS. Fulllatest-headCIstillrequired. Allinterpreter/FFIexperimentsremainonlyondiagnosticrefs. diff --git a/devlog/_plan/260906_release_244_publish/030_dev_bump.md b/devlog/_plan/260906_release_244_publish/030_dev_bump.md new file mode 100644 index 0000000000..275e8961c7 --- /dev/null +++ b/devlog/_plan/260906_release_244_publish/030_dev_bump.md @@ -0,0 +1,5 @@ +# Development version pre-move +Dependencies: integrated frozen RC. MODIFY package.json only in a PR based on currentdev: version2.44.0 ->2.45.0, or NOOP only when freshdev already strictly outranks intendedstable2.44.0. No runtime/code/lock dependency change. Keep frozenRC at2.44.0. +Inspect the defaultmain version of dev-version-bump.yml. If workflow_dispatch exists, dispatch frommain with intended-version=2.44.0 mode=pre-move and use generatedPR. If older main has onlyworkflow_call, create the established one-file versionPR via scripts/bump-dev-version.ts (--help/CLI inspected first) or exact JSON version rewrite. No local build/tests. Push scopedbranch --no-verify; require hosted exacthead CI and reviewed version-onlydiff, then authorizedadminPR merge todev. Verify origin/dev:package.json and ancestry fresh. Releaseworkflow assert-ahead independently enforces pre-move. A version collision is a blocker, not an automatic unreviewed versionchoice. Capture sourceSHA/version/PR/run proof in this unit. + +Current defaultmain workflow_dispatch was fetched and confirmed (exit0) on2026-09-06; the manual workflow path is selected. diff --git a/devlog/_plan/260906_release_244_publish/040_preview.md b/devlog/_plan/260906_release_244_publish/040_preview.md new file mode 100644 index 0000000000..1f45fb3ee5 --- /dev/null +++ b/devlog/_plan/260906_release_244_publish/040_preview.md @@ -0,0 +1,4 @@ +# Preview promotion and publication +Dependencies: frozenRC plus verified dev-ahead. NEW ordinary promotionbranch from freshpreview; MERGE immutableRC with normalmerge (no rebase/force); resolve only reviewed branch/version conflicts. MODIFY package.json fromRC2.44.0 to2.44.0-preview.YYYYMMDD (execution-day date in the maintainer timezone (Asia/Seoul), choose next suffix only if existing version requires it after explicit freshregistry inspection). All other source tree entries must equalRC; assert `git diff --exit-code RC HEAD -- . ':(exclude)package.json'` after resolving history. No2.45.0dev pre-move in releasecandidate. +Open template-complete preview promotionPR; include actualprior dashboard screenshot and exact source evidence because release delta includes dashboard changes. Target exemption is release promotion, not a featurePR. Inspect required checks and maintainer objections; user explicitly authorizes this promotion. Merge using allowedPR method/admin as authorized with exacthead guard; neverdirectpreviewpush. Fetch finalpreviewSHA, proveRCancestor and packageversion. +For finalpreviewSHA require successful ci.yml eventpush and service-lifecycle.yml sameSHA (manual lifecycle if auto absent); Windows6 and macOScontrol are already required on the unchangedRC; do not claim push'sskippedWindows tested. Never dispatch manualCI on preview/main while the requiredpushrun is active (sharedref concurrency cancels it). Runtime drift returns toRCvalidation beforepromotion. RC all-lane evidence is separate from final push gate; source-only-equivalent versiondelta is documented. Dry-run release.yml frompreview with version, tag=preview, expected-sha full40 and dry-run=true. Waitsuccess, inspect dry-run buildpack, then sameSHA dry-run=false. Neveroverlap publish workflows; existing release concurrencyserializes. Read back npm exactversion metadata+dist-tag+gitHead+dist.integrity+attestations, verify tarball digest andprovenance against releaseSHA; verify vversiontag and prerelease. No localinstall/suite/build. If publish response ambiguous inspectregistry/tagfirst, never duplicatepublish. Oldpublishedversion remainsrollback installtarget; changing dist-tag/rollback is onlydone if actuallyneeded andauthorized, not as a test. diff --git a/devlog/_plan/260906_release_244_publish/050_stable.md b/devlog/_plan/260906_release_244_publish/050_stable.md new file mode 100644 index 0000000000..2d1e2fc68e --- /dev/null +++ b/devlog/_plan/260906_release_244_publish/050_stable.md @@ -0,0 +1,4 @@ +# Stable promotion, publication, and closure +Dependencies: previewpublished proof, immutableRC and devahead. NEW ordinary main promotionbranch from freshmain; merge the SAME RC independently, preservingmainhistory. Finaltree equalsRC and package2.44.0; do not merge previewversion ordev2.45.0. Resolve explicit conflicts and verify RC ancestry and tree identity. PromotionPR uses full template, release exemption, previous dashboard screenshot/evidence and author attribution preserved by originalhistory. Honor outstandingmaintainer objections and exacthead checks; allowedadminmerge remains explicitowner action, not independentapproval. +After mainPRmerge fetchfinalmainSHA and require sameSHA successful push-event CI plus a successful `service-lifecycle.yml` run for the same finalmainSHA, covering all three jobs. Run hosted release.yml dry-run=true then false frommain, version=2.44.0 tag=latest `expected-sha=`; serialize afterpreviewpublication. Verify npm latest/version/gitHead/integrity/provenance, tagv2.44.0 and GitHub release target. Read deploy-docs.yml triggers and default branchsource; wait successful Pages deployment for finalmainSHA or dispatch the existingworkflow if required bypathfilters, then inspect site response/sourceproof. If workflowredafter acceptednpm publish, inspectactualregistry/tag/release state before recovery; create only missingmetadata using existing validatedchangelog, never republishsameversion. +Finalcheck rereads dev/main/preview refs and versions, verifiesboth tags/artifactdigests and publishedinstall-smoke evidence from releaseCI. Record GUI/runtime field limitations accurately (Kiroquota absent, Windowsquota fieldissue3644 not newlyvalidated). Update boundgoalplan/ledger and verify no heartbeat automation remains and complete directpoll tracking after allcriteria met. Keep cleanup scoped: no worktreedeletion, no massbranchcleanup, no localdaemon/installmutation. D reports both publishedversions and proofURLs. Any incompletegate remainsopen. diff --git a/devlog/_plan/260906_stateful_task_guidance/000_plan.md b/devlog/_plan/260906_stateful_task_guidance/000_plan.md new file mode 100644 index 0000000000..b7fd48e7b9 --- /dev/null +++ b/devlog/_plan/260906_stateful_task_guidance/000_plan.md @@ -0,0 +1,30 @@ +# Stateful external-task guidance consistency + +Parent PR #3743 recognizes a complete external task-input envelope as a user turn +and starts the parsed continuation boundary there. Its review identified the +remaining raw insertion predicate in collaboration.ts, which still recognizes +only ordinary user/assistant messages and agent_message. In a stateful delta, +generated guidance can therefore precede the task in parsed messages but follow +it in the stored raw input; reparsing changes the delivered order. + +This C4 protocol/replay follow-up is a separate PABCD work-phase before Kiro +implementation resumes. The Kiro phase remains open with no code changes; the +goalplan gained an additional criterion and an explicit focus cursor, without +marking any unfinished task complete or weakening existing criteria. + +Archetype: spec-satisfaction repair. Goal: the same conversational boundary in +parsed and raw stateful representations. Non-goals: new envelope forms, broader +tool-output repair, stateless insertion changes, auth changes or live Kiro. +Verifier: hosted ci.yml runtime/type/privacy gates and focused regression cases +in tests/codex-integration/multi-agent-compat.test.ts. No local test suite, +typecheck or build. Stop only after exact-head CI and independent review pass, +parent review is resolved and its verified head is ready for the Kiro cascade. + +Resources inherit the authorized release loop: existing repository/GitHub access, +requested xai/grok-4.6 reviewers, no new credentials or purchases, no fixed model +cost cap, bounded processes and status waits. Main owns code/FSM/GitHub actions; +reviewers are read-only. Reclaim failed dispatches; no implicit phase movement. +Design and final source/CI evidence reside in this unit and the bound goalplan. + +The complete implementation map is 010_raw_boundary.md. Apply the verified delta +to parent #3743, then refresh the saved Kiro branch from that parent before B. diff --git a/devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md b/devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md new file mode 100644 index 0000000000..ef68f482f2 --- /dev/null +++ b/devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md @@ -0,0 +1,41 @@ +# Align the stateful raw conversation boundary + +## Exact diff map + +- MODIFY src/server/responses/collaboration.ts: import the existing pure + externalTaskInputContent helper. In isConversationalItem, recognize a complete + external task envelope with helper(item) !== undefined, alongside existing + agent_message and user/assistant message handling. Do not duplicate its shape + validator or alter statefulRawInsertionIndex's replay-prefix/fallback logic. +- MODIFY tests/codex-integration/multi-agent-compat.test.ts near injectDeveloperMessage: + stateful external envelope alone and after a leading ordinary call result must + receive guidance before the external task in both parsed context and raw input. + Reparse the stored raw body and compare role/content order. Add an expanded + replay-prefix case so historical external inputs are not selected as the new + boundary. Keep ordinary stateful protocol, compaction and guidance-dedup tests. +- MODIFY docs-site/src/content/docs/guides/sub-agent-surface.md and + structure/04_transports-and-sidecars.md: distinguish unchanged payload content + from intentional generated-guidance placement; both representations use the + same complete-envelope boundary during stateful injection. + +Before: parsed [developer, user] while raw [external-envelope, developer]. +After: parsed [developer, user], raw [developer, external-envelope], and reparsed +role/content order agrees. Leading protocol results remain before guidance; +historical replay-prefix items remain in place. + +## Activation and boundary proof + +The new predicate executes only when stateful guidance inspects raw input. Tests +set previous_response_id, invoke the real injector and assert raw/parsed/reparsed +arrays. Ordinary tool outputs with call_id remain protocol items because the +shared helper rejects them. Invalid/partial/opaque envelopes retain their current +classification; the complete validator is already covered by parent regressions. + +No persisted schema, configuration or role changes. Existing input shape -> shared +validation -> raw insertion index -> stored raw input -> later parser is the full +data flow. The helper remains pure and adds no optional subsystem dependency. +Review uses the actual diff; all runtime checks execute in GitHub Actions. + +## An audit amendment + +Use the parse-time previous_response_id pattern from multi-agent-compat.test.ts:1075-1089 for envelope-alone and leading-result cases. The raw body must contain that field before parseRequest and retain it during reparse; do not copy the post-hoc parsed.previousResponseId assignment fixture at 1029. For historical-prefix coverage use the 1043-1072 pattern with explicit `_replayPrefixLen` and `_continuationConversationMessageIndex`, and put an old external envelope inside that preserved prefix. Assert parsed boundary before injection as well as raw/parsed/reparsed ordering. This closes the auditor's false-green fixture concern. diff --git a/devlog/_plan/260906_stateful_task_guidance/011_implementation.md b/devlog/_plan/260906_stateful_task_guidance/011_implementation.md new file mode 100644 index 0000000000..1bd823fba8 --- /dev/null +++ b/devlog/_plan/260906_stateful_task_guidance/011_implementation.md @@ -0,0 +1,16 @@ +# Implementation and verification boundary + +The raw conversational-item predicate now reuses externalTaskInputContent, matching +the parsed continuation predicate without another envelope validator. Replay-prefix +skipping and the existing fallback remain unchanged. + +Three new cases parse with previous_response_id already in the raw body, exercise +external input alone or after a real protocol result, preserve a historical external +envelope in the replay prefix, and compare raw/parsed/reparsed role-content order. +They retain the stateful field during reparse and assert the initial parsed boundary, +avoiding a fixture that could accidentally validate stateless behavior. + +Apply this review fix to #3743. Source review and exact-head hosted CI are recorded +on that PR and in the cycle receipt; no local test suite or live Kiro request is run. +After verification, resolve the review and refresh the preserved Kiro branch before +its implementation cycle continues. diff --git a/devlog/_plan/260906_unix_shim_ci_restore/000_plan.md b/devlog/_plan/260906_unix_shim_ci_restore/000_plan.md new file mode 100644 index 0000000000..007d0f9f2f --- /dev/null +++ b/devlog/_plan/260906_unix_shim_ci_restore/000_plan.md @@ -0,0 +1,23 @@ +# 000 — Unix shim CI follow-up roadmap + +This is the next independent cycle of the active dev-CI repair goal. The prior key-login cycle is complete: PR3724 landed at41ab5c2dc, its exact-head PR CI passed and the actual dev macOS1 key-login test passed116.67ms. The dev aggregate still failed in a different test; it is not labeled green. + +Public baseline: dev41ab5c2dcd49ac6bdfaec4cf091324dbc1d41b95, CI34001170755, macOS2 job101400209887. `tests/codex-integration/codex-shim.test.ts:109` expected the fixture install result's installed flag to be true, received false after6004.17ms. The named test at1444 concerns obsolete-shim auto-restore, but setup failed before those assertions. The shard reported10416pass/1fail. Raw evidence is kept under ignored .tmp/lane-b/key-login-repair-qa/dev-macos-2.log. + +## Phase map and next decision + +The earlier cycle established a repeatable DNS-dependent timeout and repaired only its fixture. That finding does not explain this shim failure. Consume `010_repair.md` in the shim-repair cycle; no production edit before a bounded causal trace and independent audit. This P amendment pays the newly discovered multi-cycle roadmap debt before further implementation. + +Current known call chain: withInstalledShim creates an owned temporary PATH/home and an executable echo launcher; installCodexShim performs the real transactional install and launcher probe. The test sets the successful-probe observation interval to20ms. The production probe runs a Bun child with a5s launcher budget and1s cleanup budget; the child launches a detached process group with stderr and descendant-lease pipes. The historical boolean assertion discards the install result message. Its6s duration alone cannot tell which child/cleanup boundary refused the install. + +Existing ownership/context: prior changes51057b611 and2ea9ba7df preserved bounded failure diagnostics and cleanup fail-closed behavior. D does not change shim.ts or this test, has a previous serialized81-test pass, and currently owns a macmini-cf full-suite queue slot. B must respect that slot and not induce competing test load. + +## Bounds + +Treat any executed launcher-validation or cleanup change as C4. Allow read-only GH logs/history and owned remote macOS probes with pinned Bun1.4.0, temporary PATH/home and the shared test-user lock. Write only the owning test, the demonstrated faulty probe boundary if required, and numbered outcome/contract documentation. No global launcher install, service restart, personal account or credential access, release/deploy, integration-branch direct push, or local test/typecheck/build. Existing no-verify/admin and inherited-agent authorization applies. No requested token/cost cap; six-hour checkpoint is a reporting bound. Investigative security details stay in ignored scratch until the fix is public. + +## Baseline discriminator + +The first isolated named-case trace passed. Both the fresh install and obsolete refresh ran the unmodified embedded probe with20ms observation,5s launcher and6s parent ceilings; their child results were status0 with expected group/stderr metadata, in169ms and33ms respectively. This does not resolve the intermittent CI failure. Next bounded probe is the owning file/fixture neighborhood or sequential positive repetitions with every failure retained, to distinguish test-state leakage from starter/process/stream timing. No artificial bootstrap stall will be presented as proof of this historical cause. + +Prior owner A confirms no reproduced6s positive-fixture failure: its51057b611 change only concerns the existing passive cleanup interval after EPERM; the old deliberately negative timeout case and current unexpected setup refusal must remain separate. diff --git a/devlog/_plan/260906_unix_shim_ci_restore/010_repair.md b/devlog/_plan/260906_unix_shim_ci_restore/010_repair.md new file mode 100644 index 0000000000..7427b3e49e --- /dev/null +++ b/devlog/_plan/260906_unix_shim_ci_restore/010_repair.md @@ -0,0 +1,36 @@ +# 010 — Isolate and repair the shim setup failure + +Status: investigation plan; no fix selected or applied. + +## Planned edits and gates + +1. Remote scratch only: preserve exact dev test/source bytes; run the named case with the existing15/other declared test budget and real installer in isolated fixtures. Capture the full bounded install result before its installed assertion, and probe status/signal/error code plus marker/group/stderr-file shape. Capture times at child start, launcher start/exit, pipe-end, probe finish and parent return only if the first result needs them. No credential-bearing or arbitrary launcher output in public diagnostics. +2. Distinguish initial Bun-process startup, launcher/descendant validation, stream completion, and parent cleanup. A fast isolated pass is not proof of resolution; only a controlled reproduction/falsifier selects a correction. Keep stale-file/PID hypotheses separate from actual process identity. +3. Test change owner: `tests/codex-integration/codex-shim.test.ts`, specifically withInstalledShim at89-125 and the obsolete-upgrade case1444 onward. Preserve old-backup/state byte equality, successful revalidation, all existing negative cases and declared budgets. Add bounded install-result diagnostics so a future refusal exposes its category instead of only false. Do not replace real installer success with a stub or add retries/skips. +4. Conditional production owner: `src/codex/shim.ts`, embedded install-probe script44-205, probeUnixShimInstall859-943 and cleanup helper960-984. Amend this document with the observed faulty branch and exact smallest correction before B. No broader rewrite or timer increase. Preserve fail-closed recursion, descendant/group termination, immutable file identity and transaction rollback behavior. Reuse existing seams; do not invent production test modes. +5. Independent reviewer audits the selected delta and counterexamples before implementation. Remote verification includes the named case, complete owning shim file, relevant explicit negative/cross-shell fixtures, strict targeted test typecheck when test types change, root typecheck/privacy for changed inputs and owned-process cleanup. Use a source mutation/fault to demonstrate the regression can fail for the intended reason. +6. Push the reviewed correction with --no-verify, use exact-head hosted CI, admin merge through a PR, prove actual dev ancestry and follow the resulting dev CI. Do not claim the earlier key-login PR's CI certifies this change. A distinct later failure becomes another evidence-backed phase rather than being hidden as success. + +DONE requires causal evidence, unchanged safety predicates or independently reviewed corrections, passing relevant checks and real delivery. BLOCKED needs an external condition with no remaining authorized progress; queue waiting alone is not completion. Current missing information is the actual failure category from the discarded install result and probe boundary trace. + +## First B step: preserve the failure category in hosted evidence + +The original named case and ten bounded sequential copies passed (20probes,32-176ms). Owning-file verification is being collected. Current dev advanced to014061a7e through D3720; shim.ts and its owning test are unchanged. No source/runtime fix is selected. + +Apply only this diagnostic delta to withInstalledShim: capture `const installed = installCodexShim();` and assert `expect(installed.installed, installed.message).toBe(true)`. The repository already uses Bun's custom assertion-message argument in tests/cli/cli-registry.test.ts59-60. This preserves the exact predicate and adds the installer's existing bounded refusal category to a future hosted failure. Validate the targeted test type and unchanged owning tests remotely, then publish a draft diagnostic PR for the current dev tree. Do not merge or describe this as the root-cause fix; the next diagnostic result governs any production change. No retries, loops, budget changes or assertions removed from the committed test. + +## Candidate implementation amendment: CI isolation policy alignment + +A second independent observation strengthens the execution-topology hypothesis: D's PR CI34001613444 failed Cursor shell completion acknowledgement after60s, while its same-source remote full suite passed that file in its explicit serial lane. Both Cursor shell and Codex shim belong to `SERIAL_FULL_SUITE_FILES` in scripts/test.ts. The raw macOS shard command bypasses this existing policy. Historical native wait causes remain unknown; this proposal fixes the observable CI execution-policy gap, not a claimed Bun internals defect. + +Precise delta for audit: + +- `.github/workflows/ci.yml`, platform-macos Test step only: load the six canonical paths from the existing `SERIAL_FULL_SUITE_FILES` export; fail if the manifest cannot be loaded/is empty/names absent files. Build quoted basename ignore arguments for the general shard. Refactor the existing two-attempt crash-only loop into a shell function that accepts test arguments; preserve the same crash signatures, per-test60s ceiling and exact failure code. Run the general shard with all six exclusions, then assign serial file index modulo2 to a single owning shard and run each assigned file with `--parallel=1` in its own Bun process. Any assertion failure returns immediately, and any repeated crash fails. No new job, runner, action, trigger, permissions, dependency or security credential. The explicit unsharded macos-control remains unchanged. +- `tests/ci-workflows/ci-workflows.test.ts`: update the existing platform-macos command-owner checks to the argument-taking retry function and manifest-driven invocation. Preserve matrix[1,2],20min job bound, shared change gate, crash fingerprint and no-unbounded-retry checks. Do not weaken the whole-pool control assertions. +- New `tests/ci-workflows/macos-serial-lanes.test.ts`: execute the actual platform-macos run block in a sandbox with a fake Bun command that records invocation argv/PID and returns controlled outcomes. Cover both shards; every fixture serial file executes exactly once across the pair, all are excluded from the general command, fresh process IDs and--parallel=1 are observed. Verify main/serial assertion failures are not retried, runtime crashes retry once only, repeated crashes fail, and manifest errors cannot silently remove coverage. A regression run against the old workflow must fail its execution-ownership oracle. No actual repository suite inside this harness. +- Register that test in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` using existing layout contracts. +- `tests/codex-integration/codex-shim.test.ts`: keep the two-line diagnostic improvement described above, with the original positive expectation and installer unchanged. + +All source process-validation, recursion, descendant, rollback and5s/1s/6s probe contracts remain untouched. Verification includes remote harness RED(oldworkflow)/GREEN(newworkflow), owning81shim tests and Cursor shell owning tests under the new serial dispatch, existing workflow/test-runner/layout guards and relevant typechecks/privacy, independent workflow security review, full hosted exact-head CI and actual dev integration. Root native failure diagnosis remains explicitly limited; CI isolation is assessed on its own observable policy contract and actual execution, not a lucky repeated green. + +Implementation detail accepted for exclusion integrity: retain the repository's established `**/` Bun ignore syntax, but require each manifest basename to match exactly its one declared file under tests before use (`find tests -type f -name ` equals that canonical path). Fail duplicate manifest entries, invalid relative paths, missing files, basename collisions, an empty manifest or nonzero producer status. The fake-Bun harness includes collision and duplicate/empty/failed-manifest cases. Use standard find/Bash3 constructs only; no new dependency or assumption about an unverified full-path glob grammar. diff --git a/devlog/_plan/260907_track2_protocol/000_plan.md b/devlog/_plan/260907_track2_protocol/000_plan.md new file mode 100644 index 0000000000..42ded4b4ff --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/000_plan.md @@ -0,0 +1,28 @@ +# Track 2 protocol delivery + +- Archetype: satisfy-spec repair with an evidence-backed defer outcome. +- Trigger: maintainer assigns track 2 and authorizes ordinary PR chains, no-verify pushes, final remote CI first, and admin integration. +- Goal: preserve Chat JSON/SSE semantics (#3770/#3779), refusal (#3767), supported custom efforts (#3775), hosted-search execution (#3761), and opt-in Claude compatibility (#3730), or document a concrete unresolved blocker. +- Non-goals: native GitHub stacks, local tests/typechecks/builds/install, other tracks, service/config changes outside repository, releases/deployments. Do not weaken CI definitions or treat skipped tests as passing. +- Baseline: dev 7d8523eed75a67f7a4a15b533744fcd0e6059aa8, including #3771. +- Verifier: existing workflow_dispatch ci.yml lane=all at the final integration head; lower diagnostic CI only if final fails. Commands are NOT RUN locally by explicit instruction. Read workflow definitions to establish target coverage. Independent source review precedes remote execution. +- Stop: feasible reviewed changes land through dev PRs with verification evidence; other items receive explicit evidence-backed dispositions. No completion claims for deferred issues. +- Artifact: this numbered unit; private security analysis and raw tool results only in /tmp/cf54-*. +- Expected outcomes: landed, already implemented, deferred with concrete blocker, or blocked by external CI/service state. +- Escalation: parent reclaims failed worker slices; never widen auth/routing trust or retry provider work to make a test pass. No user budget was set. + +## Roadmap + +1. Docs-only roadmap and independent audit. +2. 010: JSON Responses to streaming Chat semantics; carry #3779 with attribution. +3. 020: refusal across live SSE, final snapshots, JSON, collection, and JSON-to-SSE. +4. 030: custom effort provenance/capability repair after independent Codex source check. +5. 040: hosted-search path feasibility, then scoped execution/continuation repair or defer. +6. 050: opt-in Claude compatibility gate after independent official-contract/security audit or defer. +7. 060: final source audit, remote CI, ordinary PR-chain integration and exact dev ancestry proof. + +The semantic stack is JSON fallback -> refusal. Catalog and Claude slices have disjoint implementation owners and join the integration tip. Source refs are ordinary branches, not registered native stacks. Do not run a separate lower-level CI before the final integration failure. + +## Process availability + +Installed cxc skills resolved to 0.2.20 because the named 0.2.19 directory is absent. No SessionStart binding was injected into this task; SESSION-IDENTITY-01 forbids borrowing a prior/transcript id. Therefore no FSM activation is claimed. Durable P/A/B/C/D artifacts and the native active goal still track authorized work; tests remain pending until remote evidence exists. diff --git a/devlog/_plan/260907_track2_protocol/010_chat_json_sse.md b/devlog/_plan/260907_track2_protocol/010_chat_json_sse.md new file mode 100644 index 0000000000..437133fce5 --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/010_chat_json_sse.md @@ -0,0 +1,22 @@ +# JSON Responses to streaming Chat + +Depends on roadmap only. Class C3; PR #3779 is the public implementation source. + +## File delta + +- MODIFY src/server/chat-completions.ts:455: keep responsesJsonToChatCompletion as semantic authority. Replace text-only extraction and constant stop with converted choice.message content/reasoning_content/refusal; project tool_calls with stable array-order indices; preserve converted finish_reason. One role event, at most one combined delta, one terminal and one DONE. No extra upstream inference. +- NEW tests/responses/chat-json-sse-fallback.test.ts from the source PR: actual loopback Responses upstream -> handleChatCompletions. Include one/two tools, reasoning+text, incomplete length, empty completion, cancellation and translator-budget release. +- MODIFY scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json: register new test under responses. +- MODIFY docs-site/src/content/docs/reference/proxy-formats.md and structure/04_transports-and-sidecars.md: buffered fallback delivery, semantic parity and no additional request. + +## Activation and oracle + +Streaming Chat request + JSON Responses upstream is the trigger. Native Chat and real SSE bypass this path. Hardcoded official Chat fixtures require indexed function calls, nullable finish for intermediate chunks and original terminal finish. First-choice scope follows the existing Responses single-result contract. Official openai-node ChatCompletionChunk/ChatCompletionMessage are the independent shape oracle; source PR tests are evidence candidates, not a passing result. + +## Check and delivery + +No local execution. Final remote CI must execute tests/responses/chat-json-sse-fallback.test.ts and existing chat-completions-endpoint coverage, typecheck and test-layout guards. Preserve upstream author credit in carried commit and final PR body. Lower refs are published with --no-verify; no native stack registration. + +## Build checkpoint + +Carried #3779 and applied independent-audit corrections: shared native serializer, indexed tools, typed unknown incompletes, correct length/content_filter precedence, and explicit converted/serialized byte ownership. New tests preserve the source PR cases and add official-contract boundary/accounting cases. Local suites/typecheck/build NOT RUN by instruction; git diff --check is a whitespace check only. Remote verification remains pending. diff --git a/devlog/_plan/260907_track2_protocol/011_chat_audit_amendment.md b/devlog/_plan/260907_track2_protocol/011_chat_audit_amendment.md new file mode 100644 index 0000000000..4634664830 --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/011_chat_audit_amendment.md @@ -0,0 +1,15 @@ +# Chat plan audit resolution + +Independent reviewer Fermat returned GO-WITH-FIXES, five blockers. No local command was executed. + +1. Accepted: source oracle differs from current converter. Incomplete max_output_tokens -> length and content_filter -> content_filter take precedence over tools. Incomplete missing/max_messages/steered/other reason becomes typed upstream truncation rather than fabricated token exhaustion, matching existing live-SSE handling of unknown incompletes. Both JSON and SSE public handlers map typed error, without success DONE. +2. Accepted with scope: use existing budget owner; add optional budget to pure converter so runtime caller charges retained copied content/reasoning/tools. Reuse existing native jsonCompletionSse owner for both JSON-to-SSE routes after it gains optional budget-aware serialization and proper final tool indexes. Charge serialized strings and output buffer while simultaneously live; release temporaries on transfer, retain response bytes until consumption/cancel finalization. No general translator-budget refactor. Positive charge and small configured-budget overflow tests required; no local runs. +3. Accepted: refusal parts indexed by original output_index/content_index; item.id/item_id are optional correlation constraints and a present mismatch fails. Preserve original array positions. Buffer refusal parts until terminal and emit in output/content order, avoiding interleaved-part reordering. Deltas append; equal snapshots deduplicate; extending snapshots fill suffix; shorter-prefix/empty snapshot preserves known data (sparse compatible provider); absent field is no new evidence; explicit non-string/contradictory non-prefix snapshot fails. Budget text plus per-entry metadata/key bytes, release on terminal/cancel/fail. Zero-length parts cannot bypass map accounting. Final JSON and collector use nullable refusal field. +4. Accepted: native JSON-to-SSE uses same shared helper and preserves refusal. Matrix covers native/translated upstream JSON/SSE with client JSON/SSE. Native streaming passthrough remains opaque. +5. Accepted: collectChatCompletion catch cancels reader before releasing lock, invoking upstream translator cancel; tests prove cancellation under processing overflow and no successful partial JSON. Existing outer budget finalizer remains final response owner. + +These replace conflicting portions of 010/020. Re-audit before source edits. Official SDK source field definitions retained in /tmp/cf54-openai-responses-types.ts and /tmp/cf54-openai-chat-types.ts. Aside page-open reached its host deadline without content; no browser-source proof claimed. + +## Re-audit resolution + +Fermat re-audit accepted the five resolutions and found one remaining terminal-order blocker. Accepted: stage all final role/tool/refusal/finish/DONE frames as one bounded terminal batch; serialize and reserve every frame before enqueueing any success frame. On reservation failure, release the staged reservations/refusal state and emit only the bounded typed overflow error (no success finish or DONE). Commit terminated success only after batch admission. Merely moving the terminated assignment is insufficient. Add a small-budget fixture that fails at final batch admission and asserts absence of success finish/DONE plus typed error and cancellation. diff --git a/devlog/_plan/260907_track2_protocol/020_refusal.md b/devlog/_plan/260907_track2_protocol/020_refusal.md new file mode 100644 index 0000000000..8bacb10a5d --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/020_refusal.md @@ -0,0 +1,17 @@ +# Preserve refusal across Chat projections + +Depends on 010 for JSON-to-SSE field delivery. Class C3 public wire contract. + +## File delta + +- MODIFY src/chat/outbound.ts: responsesJsonToChatCompletion accumulates content parts with type refusal and their refusal string into message.refusal, alongside existing content/reasoning/tool fields. +- MODIFY same file live translator: map response.refusal.delta to delta.refusal. Track each output/content part separately with the existing translator budget. Final response.refusal.done, content_part.done, output_item.done and completed/incomplete snapshots may add only an unseen matching suffix. Repeated final representations must not duplicate text. Conflicting snapshots cannot be represented as append-only deltas and must terminate as a typed translation failure, not false success. Map storage and release follow existing turn-budget lifecycle. +- MODIFY collectChatCompletion: collect delta.refusal with retained_collectors budget and serialize message.refusal. Never coerce refusal into ordinary assistant answer text. +- NEW tests/responses/chat-refusal.test.ts (register both test-layout inventories): cover direct JSON, split live deltas plus all snapshot representations, done-only, terminal-only, multiple parts, mixed text/refusal, stream collection, contradictory snapshot failure, cancellation/overflow, and JSON-to-SSE handler path inherited from 010. +- MODIFY proxy-formats.md and structure/04_transports-and-sidecars.md: document refusal field and parity across delivery shapes without claiming a new policy decision. + +## Independent oracle and acceptance + +OpenAI Responses docs define refusal.delta.delta and refusal.done.refusal, indexed by output_index/content_index; official Chat SDK defines delta.refusal and message.refusal. Local official Codex corpus is read only for consumer behavior; it is not automatically the sole Chat API schema authority. + +Trigger known refusal events/parts, expect exactly one concatenated refusal, unchanged normal content/tool semantics, one terminal+DONE on valid completion, typed failure without success DONE on invalid final snapshot or overflow. Tests use inert fixture messages rather than provoking live model refusals. No local suites run; remote final CI owns executable proof. diff --git a/devlog/_plan/260907_track2_protocol/030_custom_efforts.md b/devlog/_plan/260907_track2_protocol/030_custom_efforts.md new file mode 100644 index 0000000000..0a8856a0bb --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/030_custom_efforts.md @@ -0,0 +1,18 @@ +# Proven custom native capability projection + +Depends on roadmap; independent from Chat semantics. Class C3. Issue #3775 remains partial because an arbitrary gateway model name does not prove native capability, and current official source is not a binary proof for Desktop 0.153.4. + +## File delta + +- MODIFY src/codex/catalog/provider-fetch.ts: in current custom-row producer, retain existing canonical openai forward destination and capability-backed model ID proof. AFTER custom/inherited metadata merge, intersect explicit reasoningEfforts with nativeReasoningEfforts for that proved model. If explicit [] preserve [] and remove default; if nonempty declared list has no supported entry, use proved native default as singleton. Otherwise choose declared default only if present, then proved default if present, then first surviving effort. Do not clamp arbitrary provider/model names, destination overrides, or ordinary routed custom models. +- MODIFY src/codex/catalog/sync.ts: in retained sync merge, current invocation's live custom rows must not have max re-added. Use current config/producer provenance rather than a disk marker. Keep ordinary provider/combo/Reserve rules. +- MODIFY existing catalog-custom-models, sync-hardening, convergence and Claude model-discovery tests after reading actual filenames: canonical Astra with none/minimal + valid ladder; explicit []; all-invalid nonempty; valid default preserved; same-name noncanonical gateway unchanged; destination override unchanged; second sync no max resurrection; both gather entry points and /models client-version projection. +- MODIFY relevant English catalog/reasoning reference plus structure/03_catalog-and-subagents.md to state the narrow capability proof and explicit empty-list behavior. Translations must not claim broader gateway/client repair. + +## Official evidence and deferrals + +Local official source corpus 121_openai-codex: protocol/src/openai_models.rs allows nonempty custom effort strings; models-manager/src/manager.rs qualified lookup is consumer lookup, not gateway provenance; multi_agents_common.rs validates chosen-row membership. Codex source supports ultra and translates it on wire. API model docs lacking ultra do not justify deleting Codex ultra. + +Field chain is existing custom config -> fetch custom row -> deriveEntry/retained merge -> catalog file/direct /models consumers. No new schema field or request-time effort override. Existing threads and version-specific stale runtime state are outside this repair. + +Source/code inspection is completed; proposed regression commands are NOT RUN locally. Final remote CI must exercise modified tests and typecheck. #3775 must remain open for gateway destination and exact Desktop/version evidence; this fix addresses only proven canonical rows. diff --git a/devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md b/devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md new file mode 100644 index 0000000000..44e3c7714e --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md @@ -0,0 +1,13 @@ +# Hosted search passthrough disposition + +Outcome: DEFER #3761; no production diff in this track. + +## Source findings + +src/web-search/loop.ts mutates normalized messages, while src/adapters/openai-responses.ts serializes passthrough _rawBody. Existing loop parsing is compaction-oriented and does not preserve the native tool/reasoning conversation needed for search-result continuation. src/server/sse-payload-rewrite.ts is synchronous rewriting rather than an asynchronous execution loop. Mixed ordinary tools/search and replay need a separate raw conversation contract; continuation storage alone does not supply it. + +Official Ollama local middleware supports hosted Responses search, including cloud model execution, but that does not establish the direct ollama.com/v1 endpoint contract. References opened during investigation: https://github.com/ollama/ollama/pull/17686 and https://docs.ollama.com/integrations/codex . Local official client source: corpus 121_openai-codex. No direct-cloud authenticated run was performed. + +## Resume criteria + +Define destination/backend execution policy; preserve raw Responses tools/reasoning when inserting search results; exercise mixed tools, cancellation, bounded iteration, SSE/JSON/WS, compact and replay remotely with a confirmed destination contract. A guard-only change is rejected because it cannot deliver these results. The issue remains open and will receive this disposition; no claimed fix, workflow or client change. diff --git a/devlog/_plan/260907_track2_protocol/050_claude_compatibility.md b/devlog/_plan/260907_track2_protocol/050_claude_compatibility.md new file mode 100644 index 0000000000..7cf73f0b0b --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/050_claude_compatibility.md @@ -0,0 +1,23 @@ +# Claude compatibility carry + +Depends on roadmap and an independent source/security audit. Class C3 with C4 review of admission and persisted diagnostics. Public source is PR #3730 at 18d64748ade8001e05327726b2ae4b22e8393418. + +## Published scope and file map + +- NEW src/claude/compatibility.ts: opt-in analysis for translated Messages; no Lab imports, source envelope, credential access or adapter execution. +- MODIFY src/server/claude-messages.ts: gate translated requests after real native passthrough returns and before inference. Preserve existing auth/origin and logging ownership. +- MODIFY src/types/config.ts: compatibility mode property. +- MODIFY src/server/request-log.ts and src/usage/log.ts: bounded optional metadata, persisted-row normalization and hydration. +- NEW tests/claude-integration/claude-compatibility.test.ts and MODIFY existing endpoint/usage tests; register new filename in both test-layout inventories. +- MODIFY server configuration reference and Claude guide only where current scope needs clarification. + +The source PR needs substantial classifier corrections before adoption. Detailed security-sensitive findings, official feature matrix, exact corrections and review evidence are held in task scratch space. The parent accepts a uniform conservative translated-path contract; no per-adapter exemption based on preliminary routing. Existing unset/native behavior remains unchanged. Shadow is observational and enforce is endpoint compatibility admission, not a global security boundary. + +## Field chain and acceptance + +Mode: typed config -> persisted JSON -> existing loader -> translated Messages gate. Present invalid mode must produce a fixed visible configuration failure rather than silently disable checking. No global config fallback change. +Evidence: gate -> request context -> ring -> usage row -> normalized disk row -> hydration. Only closed protocol codes may be stored; no body/header/credential/signature material. Old rows remain valid. + +Test unset/shadow/enforce/invalid modes, real native bypass versus translated Anthropic, zero-inference rejected requests, normal tools versus hosted feature declarations, nested supported content positions, large headers, persistence and reload. Exact behavior follows the private reviewed feature matrix. No local test/typecheck/build/install; final remote CI and explicit independent security review required. + +Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> diff --git a/devlog/_plan/260907_track2_protocol/060_remote_delivery.md b/devlog/_plan/260907_track2_protocol/060_remote_delivery.md new file mode 100644 index 0000000000..612f9342fd --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/060_remote_delivery.md @@ -0,0 +1,15 @@ +# Remote validation and delivery + +Depends on all accepted implementation slices. Class C3 integration; any admission changes require independent security review. + +## Delta + +- MODIFY only this unit's outcome/evidence record after source review. +- Publish ordinary branches using git push --no-verify. Native stack registration and repo-wide workflow edits are excluded. +- Final integration branch contains all accepted layers and current dev. Dispatch existing .github/workflows/ci.yml lane=all at its exact SHA; inspect every expected Linux/macOS/Windows shard and supporting gate. No local tests, typecheck, builds or install. +- If final CI fails, inspect log/artifact and then use lower-head or bounded remote cases to isolate it. Never claim skipped/cancelled checks passed or silently weaken assertions. Existing automatic PR jobs may run; no fabricated status. +- Open template-complete ordinary parent/child PRs; preserve contributor trailers. Record local NOT RUN, final integration proof, independently verified issue scope and explicit maintainer integration. +- Merge accepted work through dev PRs with admin authority. Refresh actual base/head and maintainer objections before each write, retain parent refs while children target them, and prove each final merge is an ancestor of fetched dev. If dev changes in another track, review integration delta and refresh final proof where needed. +- Close only fully resolved issues and superseded source PRs with attribution and replacement links; partial/deferred issues remain open with precise status. + +No source test is executed merely to verify that its command exists. CI workflow and package scripts are the source-inspection evidence of coverage. Report what each remote job actually did. diff --git a/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md b/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md new file mode 100644 index 0000000000..9006019834 --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md @@ -0,0 +1,31 @@ +# Windows composed-fixture cache ownership + +## Trigger and plan amendment + +Final integrated run 34049728209 failed only Windows shard 2/6's composed-toggle B case with a request timeout. Product changes did not run in that management-only case. Three-head diagnostic run 34051777446 kept all deadlines/assertions, comparing base 24c761a, prior d60a0716 and current 84c94f3; all samples passed, but OFF consistently consumed 20-26 seconds of a 30-second request budget. + +## Controlled evidence + +Same-VM run https://github.com/lidge-jun/opencodex/actions/runs/34053472964 changed only fixture child PowerShell module-cache policy, on fixed source 84c94f3. The parent cache was read into a private job-owned regular file; no original cache path was passed to experiment subprocesses. All test assertions and deadlines remained unchanged. + +| Condition | Startup | OFF request | Result | +| --- | --- | --- | --- | +| Original A1 | 35.26 s | abort at 30.00 s | timeout; held-sync 45 s abort also recorded | +| Original A2 | 30.11 s | 25.50 s | pass | +| Owned empty, two samples | 26.71-27.41 s | 26.07-26.96 s | pass | +| Owned prepared copy, two samples | 6.12-6.29 s | 3.36-3.37 s | pass | +| Restored original | 27.50 s | 26.15 s | pass | + +The copied-cache whole composed file also passed (7 pass, 1 pre-existing skip, 0 fail). A1 has mixed abort observations, so the latency comparison uses the complete A2 and restored-control samples. A mere empty destination did not remove the delay. In copied samples the stale sync completed after the provider release; original controls exhausted discovery before release. No guard-ablation claim is made. + +## Scoped delta and acceptance + +MODIFY tests/codex-integration/codex-composed-acceptance.test.ts only: seed a Windows-only constructor-owned module cache using the existing Desktop fixture policy, outside the Codex manifest root, and pass only that owned destination to children. Preserve HOME/USERPROFILE variants, real SID/service evidence, coordinator paths, provider hold/release, cleanup, all assertions and all deadlines. No product, workflow, or identity-policy changes. + +The isolated diagnostic workflows are not delivery changes. Independent source/security review and a fresh full cross-platform run of the combined latest-dev integration head remain required. Local suites/typecheck/build are NOT RUN per instruction. This support layer is separate from the four product PRs. + +## Follow-up source review + +External review of #3808 identified a construction-failure cleanup gap: the fixture is registered only after its constructor returns. The cache-setup block now removes its own temporary root with bounded retries before rethrowing. Parent cache sources and shared coordinator paths remain outside that cleanup. Independent source review passed; final remote verification will include the delta. + +Run 34054412656 passed Windows composed shard 2, including B at 8.511 seconds. It failed a different competing-OFF fixture in shard 6 before the flip started; this does not negate the cache-control result and is tracked separately. diff --git a/devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md b/devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md new file mode 100644 index 0000000000..cd8c79bcc3 --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md @@ -0,0 +1,18 @@ +# Windows competing-OFF preparation allocation + +## Failure and bounded plan + +Final run 34054412656 passed every individual job except Windows shard 6. The competing-OFF test refused to begin its real second-process flip: 32,262 ms remained of the 85,000 ms child budget after 52,738 ms of preparation. The unchanged flip plus reap requires 45,000 ms. This was a pre-flip budget rejection, not an outer timeout or a product assertion failure. Unlike the composed fixture, this test already inherits the ambient child environment; no cache-causation claim is made. + +MODIFY only the test's named preparation allocation: on Windows reserve two existing boot budgets for import, identity and admission preparation, then retain the original single flip and reap budgets. Windows CI child/test limits become 125/130 seconds; other platforms retain 85/90. No product deadline, assertion, coordinator, identity or service evidence changes. + +## Remote control + +Diagnostic run https://github.com/lidge-jun/opencodex/actions/runs/34056824267 checked out fixed source 0f8936b1f692d72ff1d2c1dd6218183dd0e9b882 and used a 52-second TOTAL preparation floor before the original remaining-budget guard. + +- Unchanged control passed and completed its real flip. +- Old allocation rejected the floor before flip, without timeout. +- New allocation completed the real flip and passed the original skip and unchanged-config assertions. +- With the new allocation, a one-site diagnostic mutation stored ON for the OFF request. The real flip process completed successfully, but the original result assertion failed: applied instead of skipped/desired_disabled. No timeout or budget refusal contaminated that failure. + +The isolated job restored both files byte-for-byte. Its synthetic floor, traces, workflow and production mutation are excluded from delivery. This proves the preparation allocation and test sensitivity to broken OFF persistence, not every downstream guard. Independent diagnostic security review passed. Full integration CI remains the delivery gate; no local suites, typecheck, install or build were run. diff --git a/devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md b/devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md new file mode 100644 index 0000000000..7d85053bbd --- /dev/null +++ b/devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md @@ -0,0 +1,14 @@ +# Windows unreadable-config shim fixture deadline + +Final run 34057173038 passed 23 individual jobs and failed only Windows shard 2 (plus aggregate). The original shim unreadable-config test had a 10-second outer timeout and no owned child deadline. Bun killed the dangling child at 10.24 seconds, yielding status null. Fixture and inspected runtime source were unchanged since 8615f1a. The specific slow runtime stage is unproven; this is not an environmental or cache-causation claim. + +MODIFY only that subprocess fixture: use the existing 45-second spawn budget on Windows and a named 5-second outer cleanup allowance. Preserve POSIX's 10-second limit, all temporary paths, fake launcher, real install/diagnosis/advisory path and original exit/output assertions. Error/signal diagnostics are fixed and omit captured output. Product behavior is unchanged. + +Remote diagnostic https://github.com/lidge-jun/opencodex/actions/runs/34058337624 pinned c7a96b14f and demonstrated: + +- Unchanged control: real CLI exit 0 and original assertions pass. +- With a 12-second preload delay, old limit: child is signalled and test times out. +- Same delayed CLI with owned Windows deadline: exit 0 and original assertions pass. +- Same new limit with the readiness collector's advisory catch changed to rethrow: real CLI exits 1 without timeout/signal; the original exit-0 assertion fails. + +The diagnostic restored source bytes and is excluded from delivery. Its initial run 34058187661 failed before tests because Python selected a Windows legacy codec; explicit UTF-8 corrected that diagnostic-only error. No passing rerun was used to erase a product failure. Source/security review of the diagnostic and independent source review of the candidate passed. No local suites, installs, typecheck or builds were run. Final cross-platform CI remains required for the published combined head. diff --git a/docs-site/public/pr-screenshots/3659-provider-model-removal.png b/docs-site/public/pr-screenshots/3659-provider-model-removal.png new file mode 100644 index 0000000000..1c5cb0573a Binary files /dev/null and b/docs-site/public/pr-screenshots/3659-provider-model-removal.png differ diff --git a/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png b/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png new file mode 100644 index 0000000000..3d63f634b7 Binary files /dev/null and b/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png differ diff --git a/docs-site/public/pr-screenshots/t4-log-polling-81a8.png b/docs-site/public/pr-screenshots/t4-log-polling-81a8.png new file mode 100644 index 0000000000..4dd53042e9 Binary files /dev/null and b/docs-site/public/pr-screenshots/t4-log-polling-81a8.png differ diff --git a/docs-site/public/pr-screenshots/t4-model-picker-81a8.png b/docs-site/public/pr-screenshots/t4-model-picker-81a8.png new file mode 100644 index 0000000000..0754aefc3c Binary files /dev/null and b/docs-site/public/pr-screenshots/t4-model-picker-81a8.png differ diff --git a/docs-site/public/screenshots/aside-profiles.jpg b/docs-site/public/screenshots/aside-profiles.jpg new file mode 100644 index 0000000000..7e78846321 Binary files /dev/null and b/docs-site/public/screenshots/aside-profiles.jpg differ diff --git a/docs-site/public/screenshots/logs-filters-desktop-en.png b/docs-site/public/screenshots/logs-filters-desktop-en.png new file mode 100644 index 0000000000..004d94d2e1 Binary files /dev/null and b/docs-site/public/screenshots/logs-filters-desktop-en.png differ diff --git a/docs-site/public/screenshots/logs-filters-mobile-ko.png b/docs-site/public/screenshots/logs-filters-mobile-ko.png new file mode 100644 index 0000000000..ba72049f56 Binary files /dev/null and b/docs-site/public/screenshots/logs-filters-mobile-ko.png differ diff --git a/docs-site/public/screenshots/logs-filters-proxy-clock.png b/docs-site/public/screenshots/logs-filters-proxy-clock.png new file mode 100644 index 0000000000..a82351915b Binary files /dev/null and b/docs-site/public/screenshots/logs-filters-proxy-clock.png differ diff --git a/docs-site/public/screenshots/manual-openai-model-toggle.png b/docs-site/public/screenshots/manual-openai-model-toggle.png new file mode 100644 index 0000000000..d8a0dab0de Binary files /dev/null and b/docs-site/public/screenshots/manual-openai-model-toggle.png differ diff --git a/docs-site/public/screenshots/models-client-refresh-warning.jpg b/docs-site/public/screenshots/models-client-refresh-warning.jpg new file mode 100644 index 0000000000..2b5a79a8b1 Binary files /dev/null and b/docs-site/public/screenshots/models-client-refresh-warning.jpg differ diff --git a/docs-site/public/screenshots/openai-context-cap-off.png b/docs-site/public/screenshots/openai-context-cap-off.png new file mode 100644 index 0000000000..092f7377f9 Binary files /dev/null and b/docs-site/public/screenshots/openai-context-cap-off.png differ diff --git a/docs-site/public/screenshots/openai-context-cap-on.png b/docs-site/public/screenshots/openai-context-cap-on.png new file mode 100644 index 0000000000..204cbd24a7 Binary files /dev/null and b/docs-site/public/screenshots/openai-context-cap-on.png differ diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 827b98ea95..861063667a 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -155,6 +155,12 @@ The current maintainers, their responsibilities, and the review and merge policy [`MAINTAINERS.md`](https://github.com/lidge-jun/opencodex/blob/main/MAINTAINERS.md). GitHub review ownership for the repository and security-sensitive paths is declared in `.github/CODEOWNERS`. +Contributor pull requests normally need a maintainer's approval. A current maintainer with +GitHub `maintain` or `admin` access may explicitly integrate a PR into `dev`, including their +own, without a second maintainer approval. The decision and exact-head verification must be +recorded; CI, security review and outstanding maintainer objections still apply. This exception +does not change `main`/`preview` review rules or allow direct pushes, force-pushes or deletion. + ## Conventions - **ES Modules only** (`import`/`export`), TypeScript, `strict` mode. Keep `bun x tsc --noEmit` clean. diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 06e6e09133..08e77f2992 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -134,9 +134,12 @@ est temporairement indisponible, la première route disponible de la famille est Vous pouvez également gérer le même profil depuis la ligne de commande : +Les instructions de modification ci-dessous concernent le profil local. L'application via un hub connecté est décrite séparément plus bas. + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] +ocx claude desktop status [--json] ocx claude desktop move [--default] ocx claude desktop default ocx claude desktop export @@ -204,6 +207,67 @@ l'en-tête d'admission dédié du proxy est valide. Par conséquent, l'avertisse Désactivez ce comportement avec `claudeCode.nativePassthrough: false` ; définissez une autre destination avec `claudeCode.anthropicBaseUrl`. +## Claude Desktop connecté à un hub distant + +Sur une machine connectée, `ocx claude desktop apply` ou `ocx claude desktop` récupère +l'instantané Desktop du hub et écrit son origine ainsi que ses identifiants exacts dans la +configuration Desktop locale, sans créer d'alias locaux. Les modes static/hybrid copient les +entrées ; discovery-only utilise l'origine du hub sans intégrer la liste. + +Le hub gère le profil, les familles et les valeurs par défaut. Modifiez-les sur le hub, puis +réappliquez côté client et sélectionnez à nouveau le modèle dans Desktop. Les anciens alias +créés uniquement sur le client nécessitent aussi cette opération. `show`, les modifications +locales et import/export restent locaux. En connexion distante, +`ocx claude desktop import --apply` est refusé avant l'enregistrement ; sans `--apply`, +l'importation reste locale. + +La lecture utilise l'identifiant d'accès aux données de la connexion existante, sans jeton +administrateur ni envoi de profil. Un ancien hub incompatible, une réponse invalide ou une liste +Desktop vide fait échouer l'application, sans catalogue local ni adresse de bouclage de secours. +Mettez à jour ou configurez le hub, puis réappliquez. + +Ce changement d'alias ne résout pas la demande distincte de [#3719](https://github.com/lidge-jun/opencodex/issues/3719) concernant la relecture de +`thinking` / `redacted_thinking` et le cache de prompts. L'accès au proxy seul n'active pas le +passthrough Anthropic natif ; les routes Anthropic traduites peuvent néanmoins utiliser le cache. +La fidélité de relecture et la comparaison des accès au cache restent à traiter séparément. + +### Rotation des clés, récupération et déconnexion + +La rotation et la récupération mettent à jour la clé du profil Desktop géré par la connexion +avec celle de la connexion locale, sans réapplication manuelle pour migrer la clé. Les ID de +modèles, familles, valeurs par défaut et la sélection courante sont conservés ; la rotation ne +resélectionne pas le profil géré et ne réactive pas une intégration désactivée. Dans le JSON CLI, +`rotation: "committed"` signifie que la nouvelle clé est active ; `rotation: "rolled_back"` signifie +que l'ancienne a été conservée ou restaurée, sans prétendre qu'elle a été révoquée. Une récupération +incertaine ou incomplète n'est pas annoncée comme une rotation réussie. + +La première application connectée conserve les paramètres gérés et la sélection antérieurs pour +les restaurer. Réapplication et rotation ne remplacent pas cette référence initiale. +`ocx disconnect` restaure les paramètres appartenant à la connexion en préservant les champs +ajoutés par l'utilisateur et les autres profils. La sélection antérieure n'est restaurée que si +le profil géré reste sélectionné ; un autre profil valide choisi depuis reste sélectionné. +Un profil créé puis enrichi par l'utilisateur est conservé en mode standard lisible. +`--keep-catalog` conserve le catalogue, pas la clé Desktop de la connexion. + +Un ancien profil géré sans historique peut être migré s'il appartient sans ambiguïté au hub +courant et à une clé de connexion reconnue. Apply, rotation/récupération ou déconnexion directe +le prennent en charge sans nouveau drapeau ni réapplication préalable. Un avertissement précise +que la déconnexion utilisera le mode standard faute de paramètres antérieurs enregistrés. +Seuls les paramètres de passerelle appartenant à la connexion sont retirés ; les champs utilisateur +et une sélection distincte valide restent intacts. Ce résultat est un repli standard, pas une +restauration de l'original. + +Les conflits de paramètres gérés, identifiants inconnus ou données de restauration endommagées +sont conservés et signalés. Un nettoyage interrompu reprend uniquement pour la même connexion, +sans effacer une nouvelle connexion ni annoncer une restauration incomplète comme terminée. +Terminez la récupération de rotation avant la déconnexion et gardez le même choix de conservation +du catalogue lors d'une nouvelle tentative. + +Quittez complètement puis rouvrez Claude Desktop après application, rotation/récupération ou +restauration : le processus en cours peut garder l'ancienne clé. Aucun redémarrage automatique +n'est effectué. La déconnexion locale ne révoque pas automatiquement la clé du hub et n'efface +pas les copies externes ; révoquez-la séparément sur le hub si nécessaire. + ## Le sélecteur /model (« Depuis la passerelle ») Claude Code 2.1.129+ découvre les modèles de passerelle via `GET /v1/models?limit=1000` et les répertorie dans @@ -248,6 +312,16 @@ utilisent l'alias haché. Les identifiants de modèle peuvent contenir `--` (la **Ordre de résolution du modèle :** retrait du marqueur `[1m]` → décodage de l'alias lisible → décodage de l'alias haché de Claude Desktop → correspondance exacte dans `modelMap` → correspondance sans date (suffixe `-20250514` retiré) → transfert direct. + + +Un ID Desktop de forme datée non résolu peut aussi être un véritable modèle natif absent de +la découverte. Messages et count-tokens renvoient HTTP 503 avec l’erreur fixe `desktop_model_mapping_unavailable` lorsque les informations disponibles ne permettent pas de résoudre cet ID ; cela ne +prouve pas que le modèle est invalide. Les anciens alias de type hash inconnus restent rejetés +avec HTTP 400. Aucun des deux cas ne retire la date ni ne choisit une autre route. Les ID connus, +les correspondances enregistrées et les entrées exactes de `modelMap`, dont les véritables ID +natifs reconnus, conservent leur traitement. Actualisez la découverte ou réappliquez le profil du +hub connecté avant de réessayer ; une simple nouvelle tentative ne garantit pas la résolution. + Chaque entrée porte un nom d'affichage tel que `gemini-3-pro (gemini)`, ainsi que toutes les fonctionnalités du modèle (échelle d'effort de raisonnement et types de réflexion) dans la structure officielle `ModelInfo`. Les véritables modèles Anthropic conservent leurs identifiants canoniques sur les deux interfaces. @@ -371,6 +445,8 @@ l'élision). Le contenu de remplacement préserve l'association entre l'appel d' Ordre de recherche : alias de découverte → identifiant exact → identifiant sans le suffixe de date (`-20250514`) → transfert direct. +Voir la [résolution des alias Desktop](#desktop-alias-resolution) pour les règles de rejet. + ## Matrice des services auxiliaires : recherche web et compréhension des images Les modèles routés ne disposent pas tous des mêmes outils hébergés ou de la même prise en charge des images. opencodex comble ces lacunes diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 091a63a787..02ffa8a211 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -311,8 +311,15 @@ S'il manque un modèle dans Codex, ou si l'ordre ou la visibilité du catalogue d'autorisation n'atteint jamais le catalogue. 2. **`disabledModels`** au niveau supérieur — masque les modèles dans le catalogue comme dans `/v1/models`, et fait passer les identifiants GPT natifs non qualifiés à `visibility: "hide"`. -3. **`liveModels: false` avec `models` vide** — lorsque la découverte en direct est désactivée et que `models` - est vide ou absent, opencodex n'expose aucun modèle routé pour ce fournisseur. +3. **`liveModels: false`** — Avec `liveModels: false`, si `models` est vide ou absent, la liste initiale commence par le + `defaultModel` configuré, puis les identifiants de `retainModels`. Les doublons sont supprimés + en conservant leur première occurrence. Une liste `models` explicite non vide est au contraire + suivie de `retainModels`, sans ajout implicite d’un autre `defaultModel`. Ce dernier peut toujours + être inscrit explicitement dans `models` ou `retainModels`. Si aucun de ces champs ne fournit + d’identifiant, la liste initiale est vide. Cet ordre ne garantit pas l’ordre final du sélecteur. + `selectedModels`, `disabledModels` et la désactivation du fournisseur restent applicables. + `authMode: "forward"` conserve sa branche distincte et n’utilise pas cette liste statique routée. + Ces règles ne changent pas le repli en cas d’échec de la découverte en direct. 4. **Cursor `GetUsableModels`** — l'adaptateur Cursor découvre les modèles par son appel RPC protobuf `GetUsableModels`, et non par `/models` ; une modification côté Cursor peut donc changer les identifiants visibles indépendamment des autres fournisseurs. diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index d54d941a2f..c65531a4d3 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -114,6 +114,11 @@ l'application s'arrête et le signale au lieu d'écrire une valeur modifiée en réussi. Le fichier concerné est indiqué et rien n'est déplacé sur le disque. Vous pouvez toujours modifier ce fichier manuellement ; seule la réécriture automatique est refusée. +Les dates et heures TOML empêchent également la réécriture automatique : la fusion +les convertirait en chaînes entre guillemets, y compris dans les tableaux et les +tables en ligne. Les dates déjà écrites entre guillemets restent prises en charge. +Pour conserver une date typée sans guillemets, modifiez manuellement la configuration. + **Pi, Kimi Code, Gajae Code, MiniMax Code et l'intégration DSH gérée fonctionnent uniquement avec une adresse de bouclage.** Les quatre premiers n'ont aucun champ de configuration pour l'en-tête `x-opencodex-api-key` qu'exige une liaison hors bouclage. DSH possède une table d'en-têtes générique, mais rc.6 ne documente pas diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md index cac2b0667c..b22bf9b827 100644 --- a/docs-site/src/content/docs/fr/guides/model-ordering.md +++ b/docs-site/src/content/docs/fr/guides/model-ordering.md @@ -23,7 +23,7 @@ priorités `i * N + j`, où `j` est la position du sélecteur en base zéro ; un sont déplacées hors de ces groupes de sélecteurs. Codex continue de n’annoncer que les cinq premières lignes visibles dans le sélecteur. -Les priorités sans sélecteur pertinentes sont : +Sans ordre global du sélecteur, les priorités sans sélecteur pertinentes sont : | Entrée du catalogue | Priorité | Source | | --- | --- : | --- | @@ -113,7 +113,7 @@ Utilisez `subagentModels` pour choisir et ordonner les premiers modèles que Cod `spawn_agent`. La page **Sous-agents** du tableau de bord peut réorganiser les identifiants natifs non qualifiés et les identifiants routés. Utilisez `ocx agent subagents set` ou modifiez la configuration OpenCodex pour définir des choix exacts de la forme `/` ; le tableau de bord -ne les répertorie pas et les omet s’il enregistre la liste. Configurez au maximum cinq identifiants. Avec +conserve ces identifiants déjà enregistrés, même indisponibles. Configurez au maximum cinq identifiants. Avec des sélecteurs de compte, un choix natif non qualifié peut se décliner en plusieurs lignes de catalogue qualifiées par sélecteur ; les choix configurés et les lignes annoncées ne correspondent donc pas nécessairement un à un. @@ -134,11 +134,55 @@ au-delà de ce bloc mis en avant : Les lignes routées indiquées apparaissent dans l’ordre configuré. Une ligne absente du tableau conserve sa priorité normale et reste donc devant la bande d’affichage de `modelPickerOrder` ; indiquez toutes les lignes routées dont vous souhaitez contrôler l’ordre relatif. Une ligne également présente dans -`subagentModels` conserve sa priorité de mise en avant. `modelPickerOrder` ne réorganise ni les lignes -natives non qualifiées ni celles qualifiées par un compte ; utilisez `subagentModels` pour celles-ci. +`subagentModels` conserve sa priorité de mise en avant. Une liste contenant uniquement des identifiants +routés conserve la position normale des lignes natives. -`modelPickerOrder` ne modifie jamais l’ensemble des candidats de `spawn_agent`. Il change uniquement la -priorité visible par Codex dans le sélecteur, tandis qu’OpenCodex conserve la priorité naturelle de chaque -ligne déplacée pour la sélection des sous-agents. `disabledModels` et `selectedModels` de chaque fournisseur +Pour ordonner tout le sélecteur, incluez un identifiant natif non qualifié : + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Les lignes indiquées apparaissent d’abord dans l’ordre du tableau, puis les lignes absentes +selon leur priorité naturelle. La correspondance est exacte : `gpt-5.6-sol` et +`openai/gpt-5.6-sol` désignent deux lignes distinctes. Pour une ligne qualifiée par un compte, +indiquez son identifiant complet, sélecteur inclus. Les formes brute et encodée du même +identifiant routé sont acceptées, avec priorité aux correspondances exactes. Les entrées +vides sont ignorées. + +### Migration : identifiants natifs dans les listes existantes + +Auparavant, les identifiants natifs dans `modelPickerOrder` étaient ignorés. Une liste +existante contenant un identifiant natif non qualifié ordonne désormais tout le sélecteur, +y compris les lignes mises en avant. Supprimez ces identifiants pour conserver l’ancien +comportement limité aux lignes routées. Les listes absentes, vides ou uniquement routées +conservent leur comportement ; le calcul des candidats pour les consignes d’OpenCodex selon les priorités naturelles reste inchangé. + +`modelPickerOrder` préserve le calcul d’OpenCodex qui retient jusqu’à cinq candidats préférés +pour les consignes aux sous-agents, selon leur priorité naturelle. Chaque ligne déplacée conserve +cette priorité séparément de son `priority` natif ; changer uniquement l’ordre du sélecteur ne doit +pas modifier ce calcul. Cela ne restreint pas l’admissibilité d’un modèle désigné par son nom exact : +la liste annoncée n’est pas une liste d’autorisation. Les contraintes d’authentification, de modèle, +d’effort et de backend restent applicables. + +Codex natif utilise le `priority` natif pour annoncer les cinq premiers modèles admissibles et +visibles dans le sélecteur via `spawn_agent`, en V1 et en V2 lorsque les substitutions de modèle +sont exposées. Ces cinq modèles peuvent donc changer avec l’ordre du sélecteur, même si les +candidats préférés d’OpenCodex restent identiques. La V1 ne reçoit aucune injection de liste +préférée d’OpenCodex. La V2 peut recevoir en plus des consignes fondées sur les priorités naturelles +si l’état du catalogue client le permet ; ces consignes ne réordonnent pas la liste annoncée par +l’outil natif. + +`disabledModels` et `selectedModels` de chaque fournisseur restent des champs de visibilité, pas des contrôles d’ordre. Il n’existe aucun paramètre distinct `modelOrder`, `providerOrder` ou de carte de priorité. + +## Préréglages du sélecteur + +Dans **Models**, choisissez Par défaut, A–Z par modèle, Par fournisseur ou Instantané des usages, puis appliquez l’ordre. Les identifiants routés actuellement disponibles et `modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`) sont enregistrés. Les usages conservés sont lus une seule fois lors de l’application ; un rechargement ou un changement de modèles ne recalcule rien. Un ordre personnalisé ou natif complet reste intact jusqu’à une application explicite. Par défaut efface les deux champs même sans modèle disponible. + +`GET/PUT /api/subagent-models` conserve les choix enregistrés désactivés ou absents dans `chosen` et `available` ; `pickerAvailable` contient les identifiants routés admissibles. Models envoie `pickerOrder` et `pickerOrderMode`, jamais `models`. Une sauvegarde du roster seul conserve l’ordre. Entrée invalide ou échec de sauvegarde préserve l’état précédent. + +Les plages prioritaires et natives restent en place. Les préréglages affectent le catalogue Codex et les groupes routés de la découverte Claude, sans modifier le préfixe natif Claude ni les profils Desktop explicites ou la propriété des alias. Les rangs de guidage OpenCodex et les réglages de repli sont conservés ; les cinq choix annoncés nativement par Codex et le défaut recommandé peuvent changer. Aucun client n’est redémarré ; une actualisation peut rester en attente et nécessiter de rouvrir le client. diff --git a/docs-site/src/content/docs/fr/guides/model-routing.md b/docs-site/src/content/docs/fr/guides/model-routing.md index 927d2ef863..273e2d1901 100644 --- a/docs-site/src/content/docs/fr/guides/model-routing.md +++ b/docs-site/src/content/docs/fr/guides/model-routing.md @@ -90,13 +90,17 @@ Le routage et la visibilité dans le catalogue sont deux mécanismes distincts : catalogue et `/v1/models` sont restreintes. - `provider.disabled: true` retire ce fournisseur de la découverte du catalogue. Les requêtes explicites `provider/model` échouent, et les recherches dans `defaultModel` et `models[]` l'ignorent. -- `providerContextCaps` applique des plafonds de contexte visibles par Codex, fournisseur par fournisseur. - `contextCapValue` est la valeur par défaut du tableau de bord (350 000 par défaut), mais n'a aucun effet à - lui seul tant qu'un fournisseur ne figure pas dans `providerContextCaps`. La modification de la valeur dans - le tableau de bord réaffecte tous les fournisseurs activés uniquement lorsque l'option « appliquer à tous les - fournisseurs routés » est activée ; sinon, chaque fournisseur conserve son propre plafond. Un plafond peut - seulement réduire une fenêtre de contexte connue : il ne peut ni l'augmenter ni modifier la limite réelle du - modèle en amont. +- `providerContextCaps` définit les plafonds de contexte visibles par Codex pour chaque fournisseur. + `contextCapValue` est la valeur par défaut du tableau de bord (350 000) ; elle n’applique aucun + plafond tant que le fournisseur ne figure pas dans `providerContextCaps`. Modifier cette valeur + ne met à jour les plafonds actifs que si « appliquer à tous les fournisseurs routés » est activé ; + sinon, chaque fournisseur conserve son plafond. Les fenêtres ordinaires connues ne peuvent + qu’être réduites ; les modèles natifs prenant en charge une fenêtre longue peuvent être étendus + jusqu’à leur propre plafond pris en charge, sans modifier la limite réelle du modèle en amont. + Désactiver un plafond conserve sa sélection dans `providerContextCapValues`, même après + rechargement ; le réactiver restaure cette sélection. Une sélection mémorisée n’impose aucune + limite tant que le plafond est désactivé. `{ "setAll": true }` sans `value` active tous les + fournisseurs configurés à la valeur globale actuelle et remplace leurs sélections mémorisées. ```json { diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 92aa565e52..525d69afde 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -124,6 +124,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | +Les vérifications de quota Google Antigravity utilisent des points de terminaison Google fixes, y compris le repli vers la liste des modèles. Elles prennent en charge le DNS Fake-IP transparent pour ces destinations en conservant la vérification TLS, le refus des redirections et les contrôles des adresses privées. Une URL de base personnalisée ne modifie que les requêtes de modèles ; `NO_PROXY` conserve la politique de connexion directe. + + Après un échec définitif d'actualisation de Nous, exécutez `ocx login nous` pour vous réauthentifier. Pour les préréglages canoniques du forfait Kimi Coding (`kimi` pour la connexion au compte et `kimi-code` diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index fd0f11c904..15d5392c18 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -60,13 +60,32 @@ La rotation garde les deux clés valides sous le même `apiKeyId` pendant dix mi ## Docker, retour arrière et dépannage -Il n’existe pas d’image Docker officielle, mais le dépôt fournit un `Dockerfile` et un `compose.yaml` maintenus pour construire localement une image Bun épinglée par digest. Au premier démarrage normal, le conteneur crée un certificat TLS auto-signé dans `/home/bun/.opencodex/container-tls/cert.pem` et sa clé privée dans `/home/bun/.opencodex/container-tls/key.pem`. La clé reste accessible au seul propriétaire dans le volume `ocx-state`, et le point de terminaison de données utilise HTTPS dès ce démarrage. +Lors d'un retour arrière, conservez les deux volumes et leurs points de montage. Les droits des volumes existants ne sont pas corrigés automatiquement. Consultez le [guide canonique](/guides/remote-hub/#docker-compose) pour les montages nommés hors Compose et les chemins d'état personnalisés. -Avant ce premier démarrage, initialisez une seule fois le jeton de données via stdin. L’outil d’amorçage accepte au plus une ligne de 512 octets, ne l’affiche jamais, refuse de remplacer un jeton existant et l’enregistre dans le fichier privé canonique `service-api-token`. +Deux volumes distincts conservent l'état : `ocx-state` pour +`OPENCODEX_HOME=/home/bun/.opencodex` et `codex-state` pour +`CODEX_HOME=/home/bun/.codex`. Leurs fichiers `auth.json` ont des formats incompatibles : +ne fusionnez pas ces répertoires. Ils restent accessibles en écriture malgré la racine en lecture seule. -Installez Git et Bun sur l’hôte. Avant chaque construction, générez le manifeste canonique depuis les sources et fichiers de définition du conteneur suivis par Git, sans les modifier entre la génération et la construction. Le JSON généré reste non suivi ; `.git` est exclu du contexte Docker. Le port hôte est lié à `127.0.0.1` par défaut. Pour un accès distant, utilisez explicitement `OPENCODEX_BIND_ADDRESS= docker compose up -d` ; `0.0.0.0` expose toutes les interfaces. Protégez cet accès par un pare-feu et un frontal TLS/tailnet authentifié. +Le catalogue n'est pas généré automatiquement. Avant de tester `/v1/catalog` avec authentification, +créez ou importez un fichier valide dans `/home/bun/.codex/opencodex-catalog.json`. +Un répertoire vide renvoie normalement 404 `catalog_not_found`. Une mise à jour conserve +`ocx-state` et ajoute `codex-state`, sans déplacer les fichiers. Sauvegardez tout catalogue +précédemment placé dans `.opencodex`, puis transférez seulement ce catalogue avec des permissions +réservées au propriétaire ; ne remplacez pas un `auth.json` par celui de l'autre produit. +Si vous redéfinissez `CODEX_HOME`, montez ce répertoire exact en écriture et placez le catalogue +par défaut dans `${CODEX_HOME}/opencodex-catalog.json`. Si `model_catalog_json` désigne un autre +fichier, son chemin résolu doit aussi être persistant. Conservez les variables et montages +personnalisés jusqu'à la fin d'une migration explicite. +`docker compose down` conserve les deux volumes ; `docker compose down --volumes` supprime +`ocx-state` et `codex-state`, avec les identifiants, l'historique d'utilisation, la clé de données, +l'état et le catalogue Codex. Ce n'est pas une commande de mise à jour ou de redémarrage. -La construction authentifie par manifeste `Dockerfile`, `compose.yaml`, `.dockerignore`, chaque fichier suivi faisant autorité sous `docker/`, les sources sous `src/` et les fichiers de paquet obligatoires, dont `package.json`, `bun.lock` et `scripts/model-metadata.source.json`. Elle compare chaque SHA-256 au contexte puis à l’image ; les fichiers manquants ou divergents, tout fichier source ou fichier Docker faisant autorité supplémentaire et les liens symboliques sont refusés. +Il n’existe pas d’image Docker officielle, mais le dépôt fournit un `Dockerfile` et un `compose.yaml` maintenus pour construire localement une image Bun épinglée par digest. Initialisez une seule fois la clé de données via stdin ; elle est enregistrée avec des permissions réservées au propriétaire dans le volume `ocx-state` et n’est jamais affichée. + +Installez Git et Bun sur l’hôte. Avant chaque construction, générez le manifeste canonique depuis les sources suivies par Git, sans modifier les sources entre la génération et la construction. Le JSON généré reste non suivi ; `.git` est exclu du contexte Docker. Le port hôte est lié à `127.0.0.1` par défaut. Pour un accès distant, utilisez explicitement `OPENCODEX_BIND_ADDRESS= docker compose up -d` ; `0.0.0.0` expose toutes les interfaces. Protégez cet accès par un pare-feu et un frontal TLS/tailnet authentifié. + +La construction rejette les manifestes périmés en comparant chaque SHA-256 aux fichiers du contexte puis de l’image. Les fichiers manquants ou divergents, les sources supplémentaires et les liens symboliques sont refusés. `package.json`, `bun.lock` et le seul fichier autorisé de `scripts/`, `scripts/model-metadata.source.json`, sont obligatoires. ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -77,40 +96,6 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -Copiez uniquement le certificat public pour vérifier le point de terminaison HTTPS local : - -```bash -mkdir -p .tmp -docker compose cp hub:/home/bun/.opencodex/container-tls/cert.pem .tmp/opencodex-container-ca.pem -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/healthz -``` - -`OPENCODEX_PORT` règle à la fois le port publié sur l’hôte et le `tls.publicOrigin` géré par Compose ; le listener interne reste sur `10100` : - -```bash -OPENCODEX_PORT=10190 docker compose up -d -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10190/healthz -``` - -Un volume conservé datant d’avant TLS est migré automatiquement au démarrage : l’identité par volume est créée et l’origine HTTPS reprend le port publié. Les chemins de certificat gérés par l’opérateur sont préservés. Le certificat généré ne couvre que `localhost` et `127.0.0.1`. Pour publier directement sous un nom distant, installez un certificat et une clé pour ce nom, puis fournissez son origine HTTPS exacte avec `OPENCODEX_PUBLIC_ORIGIN` et l’adresse de publication : - -```bash -OPENCODEX_PUBLIC_ORIGIN=https://hub-name.tailnet-name.ts.net \ -OPENCODEX_BIND_ADDRESS=100.64.0.10 \ -docker compose up -d -``` - -Les sondes internes de santé et de disponibilité peuvent désactiver la vérification du certificat uniquement vers l’adresse fixe de loopback du conteneur, `https://127.0.0.1:10100`. Cette exception ne vaut jamais comme validation externe : l’acceptation du déploiement doit vérifier le nom d’hôte exact avec le certificat public copié ou la chaîne de confiance du système. - -Conservez ces variables lors des invocations Compose suivantes. Pour revenir à une ancienne image HTTP, retirez le réglage TLS pendant que l’image actuelle est encore disponible, puis démarrez l’ancienne image. Les fichiers d’identité peuvent rester dans le volume : - -```bash -docker compose down -docker compose run --rm hub bun run src/cli/index.ts config unset tls -# sélectionner ou construire l’ancienne image, puis recréer le hub -docker compose up -d -``` - Le conteneur s’exécute avec l’utilisateur non-root `bun`, un système de fichiers racine en lecture seule et uniquement le port `10100` publié. Ne publiez jamais `10101` et ne placez aucun secret dans `ARG`, `ENV`, `COPY`, Compose, l’historique d’image ou argv. Après le healthcheck, vérifiez séparément `/readyz`, le catalogue authentifié et une réponse réelle. `docker compose down` conserve le volume ; `docker compose down --volumes` supprime aussi la configuration, les identifiants et la clé. - Hub indisponible : `ocx disconnect` restaure localement, mais la révocation reste à faire. diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md index b437c28b3d..18575f21c8 100644 --- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md @@ -37,6 +37,14 @@ résultat du plafond. ## Simuler un profil enregistré +Les capacités des candidats utilisent la configuration effective du fournisseur, +après application du registre. Les exigences de localité (`localOnly` et +`remoteAllowed`) utilisent donc l’adresse amont effective. Si elle ne peut pas être +classée, `unknownEvidence.capability` détermine l’admissibilité du candidat. +Une configuration de fournisseur invalide qui ne peut pas être résolue est toujours +exclue avec `route-unavailable`, même si les capacités inconnues sont autorisées. +Les fournisseurs absents ou désactivés sont également exclus avec `route-unavailable` avant le calcul des scores. + Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. Les modifications non enregistrées ne sont pas prises en compte par la simulation. Enregistrez d’abord le profil afin que la révision et l’évaluation affichées correspondent à la même configuration. diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 077437d5c2..7bd4ee9016 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -57,6 +57,14 @@ gestionnaire de mots de passe. | **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. | | **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. | +### Filtrer les requêtes + +Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. + +Les périodes de 15 minutes, une heure et un jour évoluent toutes les 30 secondes dans l’onglet Logs, même sans actualisation automatique. La vitesse mesure les jetons de sortie par seconde sur toute la durée : moins de 15, de 15 à moins de 50, ou au moins 50 ; les valeurs indisponibles sont exclues quand ce filtre est actif. Réussite : 2xx ; erreur : 4xx/5xx. + +Le compteur compare les résultats au total chargé ; la réinitialisation restaure toutes les lignes. Aucun résultat diffère d’un journal vide. Flèches et Home/End pilotent le sélecteur d’interface. Aucun historique au-delà du journal chargé n’est interrogé. + ### Liens directs vers une section Il n'existe qu'une seule mise en page adaptative, donc aucun commutateur de disposition n'est à configurer. @@ -84,6 +92,27 @@ uniquement si la liste d'autorisation de son fournisseur l'inclut — ou si aucu s'il n'est pas désactivé. Activer un modèle réconcilie atomiquement les deux filtres ; **Tout activer** efface la liste d'autorisation du fournisseur afin que les modèles découverts ultérieurement soient eux aussi actifs. +### Gérer les modèles dans l’espace fournisseur + +Dans l’onglet **Modèles** d’un fournisseur, **Supprimer** retire la définition personnalisée +stockée. Le modèle natif ou découvert sous-jacent peut alors réapparaître ; le nombre de modèles +peut donc rester identique. **Masquer** change uniquement la visibilité dans le catalogue, sans +supprimer la définition ni modifier la politique de routage direct. **Gérer la visibilité dans +Modèles** ouvre la page **Modèles** pour rétablir la visibilité, même si l’onglet du fournisseur +ne contient plus aucune ligne. + +**Ajouter** enregistre une définition personnalisée sans effacer un masquage existant ni les +règles de sélection du fournisseur. Un modèle enregistré peut donc rester masqué. Si le modèle +est déjà connu, gérez sa visibilité dans **Modèles**. Un enregistrement confirmé reste valable +même si l’actualisation du catalogue échoue : suivez le message d’actualisation au lieu d’ajouter +le modèle à nouveau. Si la modification n’est pas confirmée, actualisez l’état des modèles avant +de réessayer. + +Le compteur du fournisseur indique le nombre d’entrées uniques non désactivées dans l’inventaire +courant renvoyé par le serveur, avant recherche ou limitation de l’affichage. Il ne mesure ni la +liste d’autorisation ni les résultats de découverte en direct et ne prouve pas l’origine d’une +entrée. Les badges de sélection et les informations de découverte restent distincts. + ## Sélecteur de délégation et routage des créations de sous-agents Le sélecteur **Délégation de sous-agent** du tableau de bord enregistre `injectionModel` et, facultativement, @@ -184,7 +213,7 @@ L'interface graphique est un client léger de l'API JSON de gestion du proxy. Pa | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Sélectionner le compte de la prochaine requête et configurer le routage du pool. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Lire le compte effectif — notamment `pinned` et le compte désigné par `pinnedAccountId` — et définir l'ordre de sélection d'un compte. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Ajouter un compte au groupe au moyen d’une connexion dans le navigateur. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Lire les métadonnées des requêtes récentes avec des filtres facultatifs de fin de journal, de fournisseur et d'état exact ou par classe. Avec `limit`/`offset`, la pagination remonte depuis la ligne la plus récente (`offset=0` renvoie la dernière page). Forme de la réponse : `{ timeZone, total, logs }`, où `total` est le nombre de lignes filtrées avant pagination. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Lire les métadonnées des requêtes récentes avec des filtres facultatifs de fin de journal, de fournisseur et d'état exact ou par classe. Avec `limit`/`offset`, la pagination remonte depuis la ligne la plus récente (`offset=0` renvoie la dernière page). Forme de la réponse : `{ timeZone, generatedAt, total, logs }`, où `total` est le nombre de lignes filtrées avant pagination. | | `GET` / `PUT /api/subagent-models` | Lire ou définir les cinq modèles de remplacement `spawn_agent` mis en avant. | | `POST /api/stop` | Arrêter le proxy et le service, restaurer Codex natif et quitter. Refusé avec `respawnable_service` sur le backend Planificateur de tâches Windows, et avec `service_state_unknown` lorsque cet état ne peut pas être lu ; rien n'est modifié dans les deux cas. | diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index ac152db316..59652bbaef 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -152,6 +152,12 @@ Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit rec Exécute opencodex comme service d’arrière-plan géré à l’ouverture de session — **launchd** sous macOS, **unité utilisateur systemd** sous Linux et **Task Scheduler** sous Windows — qui démarre automatiquement à la connexion et redémarre après un plantage. Les services définissent `OCX_SERVICE=1` afin qu’un redémarrage ne réécrive pas inutilement la configuration Codex. +Les installations via le Planificateur de tâches Windows utilisent une priorité de processus normale (`Priority=4`). +L’ancienne priorité d’arrière-plan (`7`, également la valeur par défaut si le paramètre est omis) peut retarder les réponses +aux contrôles de santé en cas de contention CPU : la zone de notification affiche alors Offline même si le processus fonctionne. +Après la mise à jour, exécutez `ocx service repair` pour migrer cette priorité enregistrée et redémarrer le service. +Une confirmation UAC peut être nécessaire. Une priorité déjà normale ou haute ne déclenche pas, à elle seule, de réenregistrement. + | Sous-commande | Action | | --- | --- | | aucune | Installe et démarre le service s’il est absent ; sinon, actualise et redémarre le service existant. Une définition Task Scheduler Windows saine est réutilisée ; une définition obsolète peut être réenregistrée et nécessiter une élévation. | diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index af85f764aa..107e908aca 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -203,12 +203,11 @@ renvoient 1 ; une sonde de quota en amont qui échoue ou expire produit plutôt ### `ocx account auto-switch > [--json]` -Contrôle uniquement le groupe de comptes Codex `openai`. `on` règle 80 %, `off` règle 0 %, `status` lit la -valeur actuelle et `threshold ` accepte un entier de 0 à 100. Les autres fournisseurs et les valeurs -invalides entraînent le code de sortie 1. `--json` renvoie : +Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Les seuils génériques sont actuellement inactifs : leur sauvegarde ne change ni le basculement par seuil, ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique que le seuil ne s’applique pas ; une capacité inconnue ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/fr/reference/configuration/agents.md b/docs-site/src/content/docs/fr/reference/configuration/agents.md index ceeb971237..b0346c029f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/fr/reference/configuration/agents.md @@ -58,7 +58,7 @@ Pour un tour enfant créé, l’ordre de repli est le suivant : Les chaînes de repli propres à un rôle doivent résider dans la configuration d’opencodex. L’ajout de `model_fallback` dans `$CODEX_HOME/agents/*.toml` amène Codex 0.146+ à rejeter le fichier de rôle entier à cause de ce champ inconnu, puis à ignorer le rôle (#1190). Une ancienne ligne `model_fallback` dans le fichier TOML reste lue par souci de rétrocompatibilité, mais `ocx doctor` la signale. -opencodex ignore les candidats désactivés, non routables, en mauvais état, en période de temporisation ou ayant atteint le seuil de quota. L’instantané de disponibilité est mis en cache pendant `subagentModelFallbackPollMs`. Les tâches enfants chiffrées limitent la chaîne aux cibles ChatGPT natives canoniques et aux routes Responses directes avec authentification par clé explicitement approuvées via `allowEncryptedV2AgentTasks: true` ; si aucune ne peut consommer la charge chiffrée, la requête échoue au lieu d’envoyer un texte chiffré illisible à une autre destination. Les combos restent limités aux cibles natives canoniques. +opencodex ignore les candidats désactivés, non routables, en mauvais état, en période de temporisation ou ayant atteint le seuil de quota. L’instantané de disponibilité est mis en cache pendant `subagentModelFallbackPollMs`. Les tâches enfants chiffrées limitent la chaîne aux cibles ChatGPT natives canoniques et aux routes Responses directes avec authentification par clé explicitement approuvées via `allowEncryptedV2AgentTasks: true` ; si aucune ne peut consommer la charge chiffrée et que la récupération facultative ne permet pas un envoi routé, la requête échoue sans transmettre de texte chiffré illisible. Un combo essaie d’abord une cible native canonique disponible ; si aucune n’est sélectionnable ou si les tentatives natives sont épuisées, et que `agentTaskRecovery` est activé, un `NEW_TASK` chiffré est récupéré une fois avant l’envoi routé du combo. ```json { @@ -111,7 +111,7 @@ Ce mécanisme ne protège pas contre un autre processus exécuté sous le même N’activez cette option que si la requête authentifiée supplémentaire, la consommation de quota, la présence de texte en clair dans le processus et la dépendance à un service privé sont acceptables. Dans le cas contraire, privilégiez un enfant ChatGPT natif ou une délégation hétérogène v1. -Ce mécanisme de récupération s’applique aux enfants routés directement. Au maximum 32 requêtes de récupération peuvent être actives simultanément ; toute absence supplémentaire dans le cache échoue de manière sûre. Pour les tâches chiffrées, le routage par combinaison conserve son filtre existant limité aux cibles natives et n’utilise pas la récupération. +Ce mécanisme de récupération s’applique aux enfants routés directement et aux `NEW_TASK` chiffrés d’un combo. Au maximum 32 requêtes de récupération peuvent être actives simultanément ; toute absence supplémentaire dans le cache échoue de manière sûre. Un combo disposant d’une cible native canonique disponible continue d’envoyer directement le texte chiffré ; la récupération peut s’exécuter si aucune cible native n’est sélectionnable ou si les tentatives natives sont épuisées. Si la récupération est désactivée ou échoue, ou si aucune cible routée n’est disponible, le texte chiffré illisible n’est pas transmis à un fournisseur routé. ## Plafonds d’effort diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index a2efac94c9..aefefa414b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -34,8 +34,9 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `providers` | `Record` | — | Mappage du nom du fournisseur avec la configuration du fournisseur. | | `openaiProviderTierVersion?` | `2` | défini par la migration | Marque la projection OpenAI prenant en compte les options uniques comme terminée. | | `disabledModels?` | `string[]` | — | Modèles masqués du catalogue de Codex et de `/v1/models`, mais non bloqués des appels proxy directs. Un identifiant acheminé est supprimé des listes. Un identifiant natif qualifié de compte masque uniquement cette ligne de sélecteur ; un identifiant GPT natif nu masque la ligne nue et chaque ligne de sélecteur de compte pour ce modèle. La page Modèles du tableau de bord expose uniquement les lignes natives routées et nues ; utilisez ce champ de configuration directement pour masquer une ligne qualifiée par le sélecteur. | -| `providerContextCaps?` | `Record` | `{}` | Limites de contexte Codex-visibles par fournisseur. Un plafond abaisse uniquement une fenêtre de contexte connue. | -| `contextCapValue?` | `number` | `350000` | Valeur par défaut utilisée par les contrôles de plafond de contexte du tableau de bord. La modifier applique la valeur à chaque fournisseur routé — y compris ceux qui ne possèdent aucune entrée `providerContextCaps` — uniquement lorsque l'option « appliquer à chaque fournisseur routé » est activée ; sinon, chaque fournisseur conserve son propre plafond. | +| `providerContextCaps?` | `Record` | `{}` | Limites de contexte actives par fournisseur. Les fenêtres ordinaires sont réduites ; les modèles natifs prenant en charge une fenêtre longue peuvent être étendus uniquement jusqu’à leur propre plafond pris en charge. | +| `providerContextCapValues?` | `Record` | `{}` | Dernières limites sélectionnées par fournisseur, conservées après désactivation. Ces valeurs n’activent aucun plafond. Une valeur active est prioritaire sur une valeur mémorisée. | +| `contextCapValue?` | `number` | `350000` | Valeur par défaut lors de la première activation. Les activations suivantes restaurent la sélection du fournisseur. Modifier la valeur globale avec `setAll: true` ne modifie que les plafonds actifs ; `setAll: true` sans valeur active tous les fournisseurs configurés à la valeur globale actuelle. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Métadonnées du compte pool ChatGPT/Codex gérées par Codex Auth. Les secrets vivent séparément dans `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Comptes exclus de la sélection du pool jusqu'à la reprise, y compris le compte principal `__main__` lorsqu'il est mis en pause. | | `codexAccountNamespaces?` | `Record` | — | Mappage facultatif d’un sélecteur de modèle public arbitraire vers une cible de compte Codex stockée. Lorsque les lignes du sélecteur qualifié par compte sont activées, chaque sélecteur dont la cible est présente ajoute des lignes `/` distinctes au sélecteur Codex ; chaque ligne utilise uniquement ce compte. Dès qu'un sélecteur est actif, les lignes natives non qualifiées sont masquées dans le sélecteur, mais leurs identifiants restent routables et figurent toujours dans la réponse brute de `/v1/models`, sauf désactivation explicite. | @@ -100,7 +101,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Style de l'en-tête de clé Anthropic. La valeur par défaut est l'en-tête natif `x-api-key` ; ce champ n'est valable que pour les fournisseurs `anthropic` authentifiés par clé. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Pool multi-clés. `apiKey` reflète l'entrée active ; chaque élément a `id`, `key`, `label` facultatif et `addedAt` numérique facultatif. | | `defaultModel?` | `string` | Modèle utilisé lorsque ce fournisseur est sélectionné sans modèle explicite. | -| `models?` | `string[]` | Liste initiale ou de repli des modèles. Avec `liveModels: false`, ce sont les seuls modèles découverts. | +| `models?` | `string[]` | Liste initiale ou de repli. Avec `liveModels: false`, une liste `models` non vide est suivie de `retainModels` ; si `models` est vide ou absent, la liste commence par `defaultModel` (si configuré), puis `retainModels`, en conservant la première occurrence de chaque identifiant. | | `liveModels?` | `boolean` | Récupère le catalogue actif au démarrage et lors de la synchronisation (true par défaut). Les fournisseurs personnalisés utilisent `${baseUrl}/models` ; les fournisseurs intégrés peuvent employer une URL de registre et un filtre. | | `selectedModels?` | `string[]` | Liste autorisée du catalogue après la découverte. Non vide expose uniquement ces identifiants ; vide ou omis expose tous les modèles découverts. | | `contextWindow?` | `number` | Repli contextuel à l’échelle du fournisseur lorsque les métadonnées en amont sont absentes ; sinon, un plafond qui conserve des métadonnées en direct plus petites. Le tableau de bord Modèles expose cela séparément de `providerContextCaps`. | @@ -444,8 +445,17 @@ modèle. Le même mappage s'applique à un sélecteur natif `vercel/` ## Listes autorisées de modèles statiques -Réglez `liveModels: false` pour exposer uniquement `models`. Si `models` est vide ou omis, le fournisseur n'expose -aucun modèle routé. La découverte dynamique rejette plus de 4 Mio ou 2 000 lignes de modèle brutes avant leur mise en cache ; +Avec `liveModels: false`, si `models` est vide ou absent, la liste initiale commence par le +`defaultModel` configuré, puis les identifiants de `retainModels`. Les doublons sont supprimés +en conservant leur première occurrence. Une liste `models` explicite non vide est au contraire +suivie de `retainModels`, sans ajout implicite d’un autre `defaultModel`. Ce dernier peut toujours +être inscrit explicitement dans `models` ou `retainModels`. Si aucun de ces champs ne fournit +d’identifiant, la liste initiale est vide. Cet ordre ne garantit pas l’ordre final du sélecteur. +`selectedModels`, `disabledModels` et la désactivation du fournisseur restent applicables. +`authMode: "forward"` conserve sa branche distincte et n’utilise pas cette liste statique routée. +Ces règles ne changent pas le repli en cas d’échec de la découverte en direct. + +La découverte dynamique rejette plus de 4 Mio ou 2 000 lignes de modèle brutes avant leur mise en cache ; les préréglages intégrés peuvent appliquer des limites inférieures et filtrer les lignes admissibles à la conversation. Les résultats trop volumineux ou mal formés utilisent le catalogue obsolète ou configuré comme solution de repli. Un résultat valide ne contenant aucun modèle admissible fait autorité et n'est pas silencieusement remplacé ou tronqué. diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 2b2fb51fc7..efb131baa0 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -279,3 +279,7 @@ compte et la charge de travail prévus. `runtimeRole` vaut `standalone` par défaut. Un hub utilise `hub.managementPublicOrigin`, `hub.managementIngress` limité au loopback (`enabled:false` si absent) et les identités exactes de `remoteGui.allowedTailscaleUsers` (liste vide si absente). La clé client reste dans `service-api-token`, jamais dans `config.json`; `service-api-token.prev` peut exister pendant une rotation. Les usages ne sont pas répliqués. `remoteGui.allowInsecureHttp` est un ancien no-op déprécié, conservé uniquement pour que les anciens fichiers passent encore le schéma strict. Supprimez-le de la configuration : les grants de pairing ne sont acceptés que sur loopback ou via HTTPS authentifié, et `true` ne réactive pas le pairing HTTP en clair. + +## Diagnostic réseau des quotas Codex + +Le champ `quotaRefresh` de la ligne du compte Codex principal décrit la récupération du quota, pas le quota restant ni les droits d’accès au modèle. Il peut être absent lorsque les données sont en cache ou qu’aucune récupération n’a eu lieu. La requête utilise l’environnement du service proxy en cours d’exécution, pas celui du terminal interactif. Sans `proxy`, l’environnement existant est conservé ; `"auto"` lit uniquement le proxy statique Windows au démarrage. PAC/WPAD, les paramètres SOCKS seuls et les changements à chaud ne sont pas pris en compte automatiquement. Un succès avec TUN ne valide pas à lui seul le chemin du proxy HTTP. Consultez [les commandes et les états en anglais](/reference/configuration/server/#codex-quota-network-diagnostics). diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 45d9855307..aff119dcd8 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -172,12 +172,15 @@ d’abord et soumettez le résumé renvoyé. Préférez la quarantaine lorsqu’ | `GET /api/models` | Renvoyer les lignes de modèles destinées au tableau de bord et à l'interface en ligne de commande | `catalog_busy` lorsque la collecte est saturée | | `GET /api/client-config?client=...` | Créez une configuration client en lecture seule pour toute intégration de fichiers prise en charge | 400 client non pris en charge ; 503 catalogue indisponible | | `PUT /api/disabled-models` | Remplacer la liste partagée des modèles désactivés | 400 invalide JSON | -| `PUT /api/model-visibility` | Modifier atomiquement la visibilité au niveau du fournisseur ou du modèle | 400 fournisseur, portée, cible ou corps non valide | +| `PUT /api/model-visibility` | Modifier atomiquement la visibilité au niveau du fournisseur ou du modèle | 400 fournisseur, portée, cible ou corps non valide; 409 `initial_model_selection_pending` (Actualisez la liste des modèles, puis réessayez.) | | `GET, POST /api/custom-models` | Répertoriez les modèles personnalisés ou ajoutez-en un | 400 champs invalides ; 404 fournisseur manquant ; 409 dupliquer le modèle | | `PUT, DELETE /api/custom-models/{id}` | Modifier ou supprimer un modèle personnalisé | 400 invalide id/fields ; 404 introuvable ; 409 modèle en double | | `GET, PUT /api/selected-models` | Lire les listes autorisées et la disponibilité des fournisseurs, ou remplacer une liste autorisée | 400 fournisseur ou corps manquant ; 404 fournisseur inconnu; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Lire les préréglages ou choisir le mode preset/all/custom | 400 mode invalide ou préréglage indisponible; 404 fournisseur inconnu; PUT 409 `initial_model_selection_pending` | +Un modèle manuel remplace la ligne du tableau de bord Models ayant le même fournisseur et identifiant de modèle. Pour OpenAI, la ligne manuelle conserve `openai/` et ses contrôles de visibilité. Sa suppression restaure la ligne native sans qualificatif de compte. Les lignes natives qualifiées par compte restent distinctes. Les routes natives et les droits du compte ne changent pas. Une cible de visibilité OpenAI non native doit correspondre à un modèle manuel configuré. + + Tant qu’une liste initiale fiable n’est pas disponible, les requêtes PUT valides vers `/api/selected-models` et `/api/model-presets` renvoient HTTP 409 avec le code `initial_model_selection_pending`. Actualisez la découverte des modèles (par exemple, `GET /api/models`), puis réessayez après sa réussite. ### Comptes OAuth, clés de fournisseur et clés du plan de données @@ -217,6 +220,18 @@ fournisseurs ne sont pas renvoyés aux clients du tableau de bord. | `GET, PUT /api/provider-context-caps` | Lire ou mettre à jour les plafonds de contexte globaux, communs à tous les fournisseurs ou propres à un fournisseur | 400 requête invalide ; 404 fournisseur inconnu | | `GET /api/provider-presets` | Renvoyer les préréglages de fournisseur de l'interface graphique dérivés du registre d'exécution | — | +La réponse des plafonds de contexte comprend `caps` (limites actives) et `values` (dernières +sélections, conservées après désactivation). Activer un fournisseur sans `value` restaure sa +sélection, ou utilise la valeur globale `contextCapValue` lors de la première activation. +Cela vaut aussi pour OpenAI : le commutateur ne sélectionne pas un mode spécial à 922k. +Un plafond actif borne chaque fenêtre native ; les modèles prenant en charge un contexte long +peuvent être étendus uniquement jusqu’à leur propre plafond pris en charge. +`{ "value": 600000, "setAll": true }` modifie la valeur globale et uniquement les plafonds actifs ; +les fournisseurs dont le plafond est désactivé conservent leur sélection pour une réactivation ultérieure. +`{ "setAll": true }` sans `value` active tous les fournisseurs configurés à la valeur globale +actuelle et remplace leurs sélections mémorisées. La désactivation conserve la sélection, +même après rechargement, sans l’appliquer comme limite. + `provider_has_dependent_combos` est une barrière de sécurité : supprimez ou modifiez les combinaisons dépendantes avant de supprimer leur fournisseur. diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index e2340ee58d..b319274602 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -28,7 +28,7 @@ doit choisir parmi plusieurs cibles. | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE se terminant par `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Comptage des jetons Anthropic | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Sans objet | -| Découverte de modèles | `GET /v1/models` | L'un des trois contrats du catalogue | Sans objet | +| Découverte de modèles | `GET /v1/models` | Catalogue ou instantané Desktop explicite | Sans objet | | Voix et temps réel | `POST /v1/live`, `POST /v1/realtime/calls` | Réponse de création d'appel relayée | Une bande latérale séparée WebSocket relaie les trames dans les deux sens | | Compactage des réponses | `POST /v1/responses/compact` | Historique de remplacement JSON | Sans objet | @@ -231,10 +231,17 @@ estimation documentée du contenu du système, des messages et des outils et ret { "input_tokens": 123 } ``` +Un ID Desktop de forme datée non résolu peut aussi être un véritable modèle natif absent de +la découverte. Messages et count-tokens renvoient HTTP 503 avec l’erreur fixe `desktop_model_mapping_unavailable` lorsque les informations disponibles ne permettent pas de résoudre cet ID ; cela ne +prouve pas que le modèle est invalide. Les anciens alias de type hash inconnus restent rejetés +avec HTTP 400. Aucun des deux cas ne retire la date ni ne choisit une autre route. Les ID connus, +les correspondances enregistrées et les entrées exactes de `modelMap`, dont les véritables ID +natifs reconnus, conservent leur traitement. Actualisez la découverte ou réappliquez le profil du +hub connecté avant de réessayer ; une simple nouvelle tentative ne garantit pas la résolution. + ## `GET /v1/models` -Le même itinéraire dessert trois clients qui attendent des enveloppes de catalogue incompatibles. -La variante Anthropic est prioritaire, sauf si `client_version` est également présent. +Sans `format=desktop-config`, les contrats de catalogue ordinaires sont les suivants : | Contrat | Déclencheur | Forme de niveau supérieur | Comportement de l’identifiant du modèle | | --- | --- | --- | --- | @@ -242,6 +249,31 @@ La variante Anthropic est prioritaire, sauf si `client_version` est également p | Codex catalogue | `client_version` paramètre de requête | `{ "models": [...] }` | Les entrées natives et routées contiennent les champs de catalogue Codex les plus riches, la visibilité, l'effort, WebSocket et les métadonnées multi-agents | | Liste simple OpenAI | Ni l'un ni l'autre déclencheur | `{ "object": "list", "data": [...] }` | Les identifiants natifs visibles sont nus ; les identifiants routés sont des alias ou `provider/model` | +### Instantané de configuration Desktop + +`GET /v1/models?ids=desktop&format=desktop-config` sélectionne explicitement le snapshot +Desktop, indépendamment du user-agent. La réponse est `{ "version": 1, "models": [...] }` +avec `Cache-Control: no-store`. Le client envoie `Accept: application/json`, +`anthropic-version: 2023-06-01` et ses identifiants existants d'accès aux données, sans jeton +administrateur ni envoi de profil. Les entrées sont les modèles de configuration Desktop émis +par le hub, pas les lignes du catalogue Codex. + +Avec `ids=cli` ou un paramètre `client_version`, ce format renvoie HTTP 400. Sans le sélecteur +de format, les contrats ordinaires ci-dessus restent inchangés. Si Claude est désactivé, +`{ "version": 1, "models": [] }` indique l'indisponibilité à Desktop apply, qui n'écrit aucun +profil de remplacement. Un ancien hub renvoyant un catalogue ordinaire au lieu de la version 1 +n'est pas pris en charge ; aucun identifiant local de secours n'est généré. + +Le snapshot reste une lecture de modèles, pas une API de rotation ou d'envoi de profil. +Migration des clés Desktop, récupération et déconnexion utilisent le cycle de vie client existant. +La rotation conserve modèles et sélection ; le champ CLI `rotation` distingue `committed` et +`rolled_back`. La déconnexion restaure les paramètres gérés ou signale un repli standard pour un +ancien profil reconnu, en préservant champs utilisateur et choix valides ultérieurs. Conflits et +récupération incomplète empêchent de déclarer l'opération terminée. Redémarrez Desktop pour lire +les changements ; la déconnexion ne révoque pas automatiquement la clé du hub. +Voir [le guide Desktop](/fr/guides/claude-code/). Relecture thinking et cache restent dans +[#3719](https://github.com/lidge-jun/opencodex/issues/3719). + ## `POST /v1/live` et bande latérale en temps réel `POST /v1/live` accepte la surface de création d'appel ChatGPT/Codex App sans cadre. diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md index 62241df747..b02c39812a 100644 --- a/docs-site/src/content/docs/getting-started/for-agents.md +++ b/docs-site/src/content/docs/getting-started/for-agents.md @@ -34,8 +34,10 @@ second terminal: ocx init ``` -The wizard writes `$OPENCODEX_HOME/config.json` (normally -`~/.opencodex/config.json`). It can also inject the proxy address into Codex's `config.toml` and +The wizard creates `$OPENCODEX_HOME/config.json` (normally +`~/.opencodex/config.json`) only if it is missing. Rerunning init keeps an existing config; it has +no force/overwrite flag. Invalid or concurrently created config is preserved and setup stops. +It can also inject the proxy address into Codex's `config.toml` and install the optional Codex autostart shim. `ocx init` never starts the proxy. For a fully non-interactive setup, configure providers with `ocx provider add` as shown below instead of driving the wizard. diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 57f6c0ac9e..1d4b6b1b26 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -25,6 +25,17 @@ ocx init The result is saved to `$OPENCODEX_HOME/config.json` (default `~/.opencodex/config.json`). +`ocx init` creates a config only when none exists. An existing valid config is kept and setup +exits; use `ocx config` or the dashboard to update it. Invalid, unreadable, or symlinked config +entries are preserved and reported as errors. If another process creates the config during the +wizard, its file wins and setup stops before backup housekeeping or integration prompts. + +EOF or Ctrl+C before creation cancels setup. Cancellation after creation keeps the saved config. +Initial publication requires hard-link support and permission on the config filesystem; failures +stop setup without falling back to an overwrite. If publication or temporary-file cleanup cannot +finish, inspect the config directory before retrying: a complete config or private temporary file +may remain. + :::note[GPT-5.6 rollout entries] The current stable release seeds GPT-5.6 Sol/Terra/Luna for ChatGPT passthrough, OpenAI API-key, OpenRouter, and diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 2d2ccf3ee9..5ed946c72a 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -3,27 +3,19 @@ title: Claude Code description: Use any routed model from Claude Code — opencodex serves the Anthropic Messages API and gateway model discovery on the same port. --- -opencodex serves `POST /v1/messages` and `POST /v1/messages/count_tokens` alongside `/v1/responses`, so Claude +opencodex serves `POST /v1/messages` (plus `count_tokens`) alongside `/v1/responses`, so Claude Code can use every routed provider — OAuth logins, account pools, key failover and sidecars included — with zero extra auth work. -Generated OpenCodex roster definitions carry signed route and (when configured) effort directives. -The proxy verifies those directives before dispatching a provider request: an invalid or altered -signed directive fails closed with `400 invalid_request_error`. For compatibility with older, -unsigned definitions, a directive is honored only when it exactly matches an active -OpenCodex-owned roster entry; arbitrary unsigned text is ignored. See [Roster agents](#roster-agents-injectagents) -for operational details. - ## Claude OAuth account pool (experimental) You can log in multiple Claude accounts via the Providers dashboard (`ocx login anthropic` / add-account). By default every request uses the **active** account only. An **experimental, opt-in** Claude account pool (`anthropicAccountPool.enabled`) adds sticky -session affinity and usage-aware new-session selection across those OAuth accounts. When the -setting is omitted, 429 failover is enabled by the presence of two or more usable accounts, so a -rate-limited request can move to another account. Setting `anthropicAccountPool.enabled` explicitly -to `false` disables that reactive failover as well as the pool. For **new** +session affinity and usage-aware new-session selection across those OAuth accounts. It does +**not** gate 429 failover: with two or more usable accounts stored, a rate-limited request moves +to another account whether the pool is on or off, and that cannot be switched off. For **new** sessions, `anthropicAccountPool.strategy` selects among eligible accounts: `quota` (default) picks the lowest known usage in the window set by `quotaWindow` (`five-hour` by default, or `weekly` / @@ -33,7 +25,7 @@ reauthentication, or threshold, then advances. It is **off by default**, shows a and is not battle-tested — Anthropic may restrict accounts that look like automated rotation; rotation does not protect against provider enforcement. -Operational contract when failover is active: +Operational contract when enabled: - Upstream **429** cools that account using `Retry-After` when present (else a default backoff), clears its affinities, and may rotate to another eligible account within the same request @@ -147,6 +139,8 @@ is temporarily unavailable, the first available route in that family is used unt You can also manage the same profile from the command line: +The profile-editing instructions below describe the local profile. Connected remote apply is described separately below. + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] @@ -225,6 +219,66 @@ dedicated proxy admission header is valid. This also means the Disable with `claudeCode.nativePassthrough: false`; point elsewhere with `claudeCode.anthropicBaseUrl`. +## Claude Desktop on a connected remote hub + +When this machine is connected to a hub, `ocx claude desktop apply` (or `ocx claude desktop`) +uses the hub's Desktop model snapshot. It writes the connected hub origin and the hub-issued +model IDs into the local Desktop configuration without generating replacement aliases locally. +Static and hybrid modes copy the snapshot entries; discovery-only mode uses the hub origin +without embedding the model list. + +The hub owns the Desktop profile, family assignments and defaults. Change those on the hub, +then apply again on the connected client and reselect the model in Desktop. Old aliases created +only on the client require reapply/reselection; they are not automatically migrated. Local `show`, +profile edits, and import/export remain local views and operations, not hub-profile management. +While connected, `ocx claude desktop import --apply` is unsupported and refuses the import +before saving. Import without `--apply` remains local. + +Apply reads the snapshot using the existing connection's data credential. It needs no admin token +and uploads no profile. If the hub is too old to support the snapshot, the response is invalid, +or no Desktop models are available, apply fails without substituting a local catalog or loopback +origin. Upgrade/configure the hub and apply again. + +This alias change does not fix the separate `thinking` / `redacted_thinking` replay and prompt-cache +request in [#3719](https://github.com/lidge-jun/opencodex/issues/3719). Proxy admission alone does not enable native Anthropic passthrough; translated +Anthropic routes can still use prompt caching. Replay fidelity and cache-hit comparisons remain +separate work. + +### Key rotation, recovery and disconnect + +Key rotation and recovery update the credential stored in the connection-owned Desktop profile +alongside the local connection credential. No manual Desktop reapply is required just to migrate +the key. Existing model IDs, family/default choices and the user's current profile selection are +preserved; rotation does not select the managed profile again or re-enable a disabled integration. +CLI JSON `rotation: "committed"` means the new key is active. `rotation: "rolled_back"` means the +previous key was retained or restored, not that a new key was committed or the previous key revoked. +Uncertain or incomplete recovery is reported as such, rather than as successful rotation. + +The first connected apply records the prior managed settings and selection for restoration. +Repeated apply and key rotation retain that original baseline. `ocx disconnect` restores the +connection-owned settings while preserving current user-added fields and unrelated profiles. +The previous selection is restored only if the managed profile is still selected; a later valid +user selection stays selected. A newly created profile with user additions is retained in readable +standard mode instead of deleting those additions. `--keep-catalog` keeps the catalog, not the +Desktop connection credential. + +For an older managed profile without an original record, OpenCodex can migrate it when it +unambiguously belongs to the current hub and a recognized connection key. Apply, rotation/recovery +or direct disconnect can handle this case without a new flag or prerequisite reapply. A warning +explains that disconnect will use standard mode because the previous settings were not recorded. +That fallback removes only the connection-owned gateway settings, preserves user fields and a +separate valid selection, and is reported as standard fallback, not original restoration. + +Conflicting managed fields, unrecognized credentials or damaged restoration records are preserved +and reported for resolution. Interrupted cleanup can resume for the same connection; it does not +clear a newer connection or claim completion while restoration remains incomplete. Finish pending +rotation recovery before starting disconnect, and retain the same catalog choice when retrying it. + +Fully quit and reopen Claude Desktop after apply, rotation/recovery or restoration: changing files +does not replace a credential already held by the running app. OpenCodex does not kill/restart the +app automatically. Disconnect works locally without automatically revoking the hub key or erasing +arbitrary external copies; revoke separately on the hub if desired. + ## The /model picker ("From gateway") Claude Code 2.1.129+ discovers gateway models via `GET /v1/models?limit=1000` and lists them in @@ -269,6 +323,16 @@ express fall back to the hashed alias. Model ids MAY contain `--` (resolution sp **Model resolution order:** `[1m]` marker stripped → readable alias decoded → Desktop hashed alias decoded → `modelMap` exact match → date-stripped match (`-20250514` removed) → passthrough. + + +An unresolved date-shaped Desktop ID can also be a genuine native model missing from discovery. +Messages and count-tokens return HTTP 503 with the fixed `desktop_model_mapping_unavailable` error when the available +evidence cannot resolve that ID; this does not establish that the model is invalid. Unknown legacy +hash aliases still return HTTP 400. Neither case strips the date or falls back to another route. +Known IDs, registered mappings and exact `modelMap` matches keep their existing behavior, including +recognized real native IDs. Refresh model discovery or reapply the connected hub profile before +trying again; retrying alone does not guarantee resolution. + Each entry carries a display name like `gemini-3-pro (gemini)`, plus full model capabilities (reasoning-effort ladder, thinking types) in the official `ModelInfo` shape. Real Anthropic models keep their canonical ids on both surfaces. @@ -332,32 +396,6 @@ Proxy startup/ensure, `ocx claude`, and relevant dashboard saves sync your featu Dispatch: `subagent_type: "ocx-gpt-5-6-sol"`. 1M-capable targets carry `[1m]` automatically. -**Directive trust:** the generated definitions are signed. Each `ocx-*` agent body carries -`` (plus `` when an effort is configured) together -with a matching `` signature that OpenCodex creates with a local signing key -it manages automatically. `ocx doctor` reports that key's presence and permissions without ever -printing the key itself. The proxy verifies the signature on every request **before any provider -dispatch**: when a signature is present but malformed, altered, corrupted, or otherwise invalid, the -request rejects with a `400 invalid_request_error` — it is never routed to another provider. A -definition with no signature may use the compatibility path, but only when its directive exactly -matches an active OpenCodex-owned roster entry; arbitrary unsigned text is ignored. A failed -signature check never falls back to that roster path. - -## Unauthenticated loopback listener (opt-in) - -When the proxy listens on a non-loopback address that requires a credential, a local client that -never receives that credential would be refused. For that exact case OpenCodex can open a second -listener bound to `127.0.0.1`: set `unauthenticatedLoopbackListener` in the configuration -(**off by default**; the port is required, must differ from the proxy port, and is never -OS-assigned). See -[Local clients that cannot receive the token](/reference/configuration/#local-clients-that-cannot-receive-the-token). - -When enabled, the listener for Claude Code admits exactly two routes: `POST /v1/messages` and -`POST /v1/messages/count_tokens` (both POST-only). Every other method and path — including -`/api/*` and the dashboard — returns `404`. Public-listener authentication is unchanged: -enabling this listener never relaxes the main listener. Codex-specific routes on this listener -are described in the configuration reference. - ## Bundled-skill elision (blockedSkills) Claude Code's bundled `claude-api` skill injects ~840KB (~136k tokens) of Anthropic documentation @@ -393,44 +431,7 @@ entirely). The stub keeps tool call/result pairing intact. Lookup order: discovery alias → exact id → id with date suffix stripped (`-20250514`) → passthrough. -## Compatibility mode - -Routed Claude requests are evaluated for feature compatibility before the proxy contacts any upstream. The analyzer is a small pure function with no network or routing side effects and no Lab dependency. - -```json -{ - "claudeCode": { - "compatibility": "enforce" - } -} -``` - -| Mode | Value | Behavior | -| --- | --- | --- | -| `enforce` | default | Pre-network check: requests carrying incompatible features are rejected with `400 invalid_request_error` and a reason naming the feature codes. Compatible requests pass through unchanged. | -| `shadow` | opt-in escape | Records ordinary incompatibilities without rejecting. Safety invariants such as genuine Anthropic signed-thinking ownership still reject before upstream activity. | - -Invalid values are ignored on load and therefore use the `enforce` default. - -Feature codes (stable, also visible in the bounded debug ring): - -| Code | Meaning | Enforce result | -| --- | --- | --- | -| `cache_control` | Positional Anthropic prompt-cache marker | informational on translated targets; preserved and validated on Anthropic targets | -| `context_management` | Top-level `context_management` field | the exact Claude Code `clear_thinking_20251015` + `keep: "all"` no-op is allowed; mutating forms reject | -| `thinking_block` | `thinking` param or `thinking`/`redacted_thinking` content blocks | allowed (informational) | -| `signed_thinking` | Genuine Anthropic signature or redacted-thinking payload | reject on every non-Anthropic target, including in `shadow`; preserve on Anthropic targets | -| `tool_search` | Claude tool-search declaration, call, or result | translate through Responses tool search | -| `web_search_tool` | Claude web-search declaration, call, or result | translate through the existing web-search path | -| `deferred_tools` | Tools with `defer_loading: true` or a top-level deferred flag | translate only on the native Responses adapter; reject elsewhere | -| `input_examples` | Anthropic-native tool input examples | preserve on Anthropic targets; reject on translated targets | -| `documents`, `code_execution`, `computer_use`, `mcp_tool`, `server_tool` | Anthropic-native content or server tools without a lossless Responses lowering | reject on translated targets; preserve on Anthropic targets | -| `container`, `inference_geo`, `user_profile`, `unknown_body_field`, `unknown_content_block` | Anthropic-only or unrecognized semantic request fields | reject on translated targets; preserve on Anthropic targets | -| `structured_output` | `output_config.format` `json_schema` | translate | -| `service_tier` | Anthropic service tier | translate through provider capability sanitation | -| `beta_*` | Each `anthropic-beta` token, sanitized to `beta_` (sorted, de-duplicated) | allowed (informational) | - -Diagnostics: the inbound debug ring (`GET /api/claude/inbound-debug`) carries `featureCodes`, `adapter`, and `decision` (`allow`/`reject`/`shadow`) per entry when capture is enabled. Check `featureCodes` there before changing the mode. Native passthrough is unchanged and never gated by this mode. +See [Desktop alias resolution](#desktop-alias-resolution) for the rejection policy. ## Sidecar matrix: web search and image understanding @@ -512,7 +513,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | Assistant text | `output_text` | | Assistant `tool_use` | `function_call` (`input` → JSON-stringified `arguments`) | | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | -| `thinking` / `redacted_thinking` replay | Ordered Responses reasoning items using the `ocxr1` continuity envelope | +| `thinking` / `redacted_thinking` replay | Dropped | | Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -529,14 +530,13 @@ name. | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | Text deltas | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | -| Reasoning summary/text | `thinking` block with a verified Anthropic signature when ownership matches, otherwise an OpenCodex `ocxr1` continuity signature | +| Reasoning summary/text | `thinking` block with synthetic signature | | Function-call frames | `tool_use` block with `input_json_delta` | | Terminal event | `message_delta` → `message_stop` | | EOF before terminal | 502-style `api_error` | **Stop reason mapping:** `completed` → `tool_use` (if any tool call) or `end_turn`; -`incomplete/max_output_tokens` or retained `model_context_window_exceeded` → `max_tokens`; -`incomplete/content_filter` → `refusal`; retained `pause_turn` → `pause_turn`. +`incomplete/max_output_tokens` → `max_tokens`; `incomplete/content_filter` → `refusal`. **Error taxonomy:** 400 `invalid_request_error`, 401 `authentication_error`, 402 `billing_error`, 403 `permission_error`, 404 `not_found_error`, 409 `conflict_error`, @@ -545,15 +545,13 @@ other 5xx `api_error`. `Retry-After` is preserved. ## Prompt caching and token usage -**Anthropic-routed requests:** explicit client breakpoints are preserved in Anthropic wire order -(`tools` → `system` → `messages`) and validated before the request is sent: at most four markers, -with 1-hour markers before 5-minute/default markers. Requests translated to another protocol do not -promise equivalent positional caching; their `cache_control` markers are diagnostic only. +**Anthropic-routed requests:** the adapter manages cache breakpoints for tools, system content, +and the penultimate user message, plus top-level automatic `cache_control`. Stable turns normally +produce about a 99.9% cache hit rate. -**Native OpenAI/ChatGPT routing:** derives a session-scoped `prompt_cache_key` from -`x-claude-code-session-id` or `metadata.user_id`, and emits a `session_id` header only for that real client session. -A system-content cohort hash remains a shared prompt-cache hint, never a continuation identity or session header. -The cache key includes model and full tool schemas. +**Native OpenAI/ChatGPT routing:** derives a session-scoped `prompt_cache_key` (from +`metadata.user_id` when present, falling back to a system-content hash) and `session_id` header +for cache affinity. The cache key includes model and full tool schemas. **Token math:** Anthropic output subtracts `cached_tokens` and `cache_write_tokens` from `input_tokens`, exposing them as `cache_read_input_tokens` and `cache_creation_input_tokens`. @@ -628,45 +626,3 @@ it by default (`blockedSkills: ["claude-api"]`). **Subagent dispatches to wrong model** — Roster agents (`ocx-*`) use `` directives, not the Agent tool's `model` argument. Make sure the directive matches the intended route. Pass `"haiku"` as the model placeholder. - -## Client compatibility diagnostics - -Before `ocx claude` launches, opencodex checks the Claude Code version against the **2.1.201** -compatibility floor. The probe resolves to one of five states, each with actionable guidance: - -| State | Meaning | What to do | -| --- | --- | --- | -| `compatible` | Version is at or above the floor | Nothing | -| `outdated` | Version is below the floor | `npm install -g @anthropic-ai/claude-code` | -| `missing` | Claude Code is not installed | Install it with `npm install -g @anthropic-ai/claude-code` | -| `timed-out` | The version check timed out | Retry; repair or upgrade Claude Code if it persists | -| `unparseable` | The version could not be recognized | Repair or upgrade Claude Code, then retry | - -The probe is advisory: a below-floor, missing, timed-out, or unrecognized client **never blocks -launch** — the warning prints and `ocx claude` proceeds. `ocx doctor` and `ocx status --json` -surface the same client state. This floor is separate from the **2.1.129** native `/model` -gateway-picker capability. - -### Token-count benchmark (opt-in, may incur charges) - -The routed-path token approximation can be measured against real provider counts with -`bun run benchmark:claude-tokens -- --provider --model --confirm-live-provider-charges [--json]`. -The command **is** the consent: without `--confirm-live-provider-charges` it performs argument -validation only and sends nothing. When confirmed, it sends real requests and **can incur -provider charges**. Never automate or unattended-script it — run it deliberately, with an eye on -the account. - -What it does: - -- Targets Anthropic-adapter provider/model pairs only (the provider must be key-authed and list - the model), so the upstream reports authoritative `input_tokens`. -- Sends a deterministic, sanitized fixture set — no customer text is read or embedded. -- Sends fixtures one at a time; failures are typed and never retried, with no concurrency and no - fallback. -- Emits a closed, non-persistent report: fixture ids, digests, states, metrics, and the provider - kind + model id only. No request bodies, credentials, or account identifiers are ever written. -- Applies a per-fixture tolerance of max(32 tokens, 20%) and passes only when the weighted - aggregate absolute error stays within 10%. - -Routed `/v1/messages/count_tokens` behavior itself is unchanged by the benchmark: it stays -local for routed models and passes through to Anthropic only for native `sk-ant-` credentials. diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index da01d969c5..a83719c7fd 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -64,6 +64,23 @@ or grant account entitlement. The separately billed `openai-apikey/daybreak-blue-latest` API row is a different route and its 1,050,000 / 922,000 limits are never copied into the Codex-login row. +For custom Astra and Daybreak rows on that canonical `openai` Codex-forward destination, +explicit `reasoningEfforts` are bounded by the model's pinned Codex capabilities. A custom +`["none", "minimal", "low"]` becomes `["low"]` in the catalog; a nonempty list with no +supported values also falls back to the native default as a single choice. An explicit `[]` +stays empty and has no advertised default. A declared default is retained only if it belongs to +the resulting list; otherwise the native default is used when present, then the first surviving +choice. Stored custom configuration is unchanged, and repeated syncs do not add `max` back to a +narrow custom list. + +This requires the exact provider, destination, and capability-backed model identity. An arbitrary +gateway such as `YYLJ/gpt-6-astra` does not inherit native capabilities from its name. Its explicit +custom ladder continues to override discovered provider metadata under the normal routed rules. +Codex's native Astra `ultra` choice is retained: it is a client delegation mode converted to a +supported wire effort, distinct from the [API model's effort list](https://developers.openai.com/api/docs/models/gpt-6-astra). +Catalog normalization does not rewrite existing thread settings or establish support for a +particular installed Desktop version. + When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If `codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for backward compatibility. Set it to `false` to hide generated qualified rows and restore bare native diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index b2ae7fcb91..64466b61a4 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -324,6 +324,18 @@ encodes that declaration and its history as an upstream function tool, then rest function-call lifecycle to `custom_tool_call` before Codex sees it. Native OpenAI forward routing and the supported `apply_patch` custom tool stay unchanged. +If a routed model sends a complete patch as the entire code-mode `exec` input, opencodex +converts it to the nested `tools.apply_patch` call before the tool-completion events reach +Codex. Native custom calls and converted function calls use the same completion rule; +patch previews are held while their executable form is unresolved. JavaScript that merely +contains patch text and unrelated native custom payloads stay unchanged. + +Ordinary routed Responses function calls also use the original declared parameter schema at +completion: integral floats in integer fields and integral numbers in string-only fields are +normalized, while fractions and numeric unions stay unchanged. An explicitly empty completed +argument string becomes `{}`. Final events and locally stored continuation history agree. +Unambiguous dotted namespace spellings are restored to the declared namespace and tool name. + The selected provider must support function/tool calling. A text-only provider without tool-call support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool mode unchanged. @@ -454,8 +466,14 @@ If a model is missing from Codex, or the catalog order/visibility looks wrong, c catalog. 2. **`disabledModels`** (top level) — hides models from both the catalog and `/v1/models`, and flips bare native GPT slugs to `visibility: "hide"`. -3. **`liveModels: false` with empty `models`** — when live discovery is off and `models` is empty or - omitted, opencodex exposes no routed models for that provider. +3. **`liveModels: false`** — With `liveModels: false`, an empty or omitted `models` list seeds the configured `defaultModel` + first, followed by `retainModels`; duplicate ids are removed while preserving first occurrence. + A nonempty explicit `models` list instead seeds `models` followed by `retainModels`, without + implicitly adding a different `defaultModel`. That default can still be listed explicitly in + `models` or `retainModels`. If none of these fields supplies an id, the static seed is empty. + This is seed order, not a promise of final picker order. `selectedModels`, `disabledModels` and + provider-disabled policy still apply. `authMode: "forward"` keeps its separate branch and does + not use this routed static seed. These rules do not change live-discovery failure fallback. 4. **Cursor `GetUsableModels`** — the Cursor adapter discovers models through its protobuf `GetUsableModels` RPC, not `/models`, so a Cursor-side change can alter which ids are visible independently of other providers. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index da1273ad9a..0c95908206 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -21,6 +21,10 @@ file, and removes it again. Twelve clients work this way, each with a switch: | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | +Generated catalogs include only enabled models from each provider selection. This applies to both +downloads and managed integrations, including Pi and Aside. The management model list still shows +the full roster so you can enable additional models. + The managed OpenCode integration owns two fragments: `provider.opencodex` (opencode V1) and `providers.opencodex` (opencode V2). Only the V2 block carries the per-model reasoning-effort variants, so both are written and kept in sync; they name the same provider and model ids, and @@ -48,12 +52,10 @@ disagree about which file is meant. Its managed block owns only stay untouched. Prime Agent reads `models.json` when a session starts, so start a new session after connecting it. -Aside is per-account: its state lives under `~/.aside/u//` and opencodex -writes the catalog of whichever account Aside's own `accounts.json` names as -current. If that manifest is missing or unreadable the integration refuses rather -than guessing an account, because a guess on a multi-account machine would write -into a different account's catalog. Its managed block owns only -`providers.opencodex`, so your other Aside providers stay untouched. +Aside keeps a separate model catalog for each registered profile, including local profiles. OpenCodex lists +all registered profiles, including local profiles, and can synchronize them together or control +one profile at a time. Switching an integration never changes Aside's active account. A prior +Aside connection enables all profiles by default; individual exclusions survive later syncs. One caveat specific to Aside: the running app rewrites `models.json` itself, so fully quit and reopen Aside after applying, the same way Claude Desktop needs a @@ -159,6 +161,11 @@ changed value and calling it success. You will see the file named and nothing on disk will have moved. Editing that file by hand still works; it is only our automatic rewrite that declines. +TOML dates and times also refuse managed rewrites: the merge step would turn these +typed values into quoted strings. This includes values inside arrays and inline +tables. Quoted date strings remain supported; an unquoted date must be preserved +by editing the configuration manually. + **Pi, Kimi Code, Gajae Code, MiniMax Code, Prime Agent and the managed DSH integration only work against a loopback bind.** The first four have no config field for the `x-opencodex-api-key` header a non-loopback bind requires. DSH has a generic headers map, but rc.6 does not document that dedicated admission @@ -209,9 +216,25 @@ ocx integration client enable --client mcode ocx mcode ``` -Once connected, `ocx sync` also refreshes the owned MCode block with current context -windows and reasoning-effort ladders. It leaves missing, foreign-edited, unsafe, and -never-owned blocks untouched; re-enable explicitly when you intend to reconnect one. +Once connected, `ocx sync` refreshes owned MCode, Pi, and Aside catalogs with the current +model selection, context windows, and reasoning-effort ladders. Changes to model visibility, +provider selection, or presets also refresh connected Pi and Aside catalogs. Foreign-edited +or unsafe blocks stay untouched, as do previously owned blocks you removed manually. +An enabled Aside profile is an exception to the usual owned-only refresh: if its account +directory exists and it has never had an owned block, sync may create its first block when +that slot is empty. A prior Aside connection enables this behavior for all registered +profiles by default. Sync does not create missing account directories or replace manual blocks. +A refused or overlapping refresh is reported separately for each client. Start a new Pi +session or fully quit and reopen Aside to load the updated file. +Aside refresh requires a [compatible running proxy](#aside-profile-controls). + +If Models reports **“Model selection saved”** together with a client-refresh warning, the +selection is already saved; one or more client files could not be updated. The warning names +the affected client and Aside profile, when applicable, and explains the refusal. Open +**Integrations** to inspect that client or profile before starting a new session. Resolve the +reported issue, then retry `ocx sync`; an overlapping operation must finish first. If the +warning includes a backup path or says recovery did not finish, inspect that recovery state +before retrying. A successful selection save alone does not confirm client-file recovery. The separate MiniMax platform CLI (`mmx`) is not a file-toggle integration. Its text commands use MiniMax's Anthropic-compatible endpoint, so OpenCodex provides a @@ -236,3 +259,39 @@ decision to make. Client details were verified against each project's own configuration format; see the research notes in `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` for what was checked and when. + +## Aside profile controls + +Aside profile controls and the Aside refresh performed by `ocx sync` require a running +ocx proxy that supports the Aside profile APIs. Updating the CLI alone does not update an +already-running proxy. If the proxy is unavailable or too old, the Aside operation cannot +complete; the CLI never falls back to writing Aside profile files locally. + +Upgrade the ocx installation used by the proxy, then restart the proxy (or start it if it +is stopped). Retry `ocx sync` or the profile command. After the profile files update +successfully, fully quit and reopen Aside so it loads the new catalogs. + +```bash +ocx integration client status --client aside --json +ocx integration client enable --client aside +ocx integration client disable --client aside --profile 1 +ocx integration client history --client aside --profile 1 +ocx integration client restore --client aside --profile 1 --op +``` + +The profile number is the account ID shown by the status command. Omitting `--profile` on an +Aside toggle applies the desired state to every registered profile. A per-profile change leaves +siblings unchanged. Desired sync settings are saved before file changes; actual state and any +refusal are reported for each profile. A partial bulk result is not an all-applied success and +the CLI exits nonzero. Undo restores the selected profile's synchronization intent as well as +its file, so a later sync does not silently reverse Undo. + +The [profile API](/reference/management-api/#aside-profile-controls) returns HTTP 200 for a +successful bulk operation and HTTP 207 with `ok: false` if any profile refuses. Inspect every +entry in `results`: successful profiles are not rolled back when another fails. Desired +settings remain saved, so retry after addressing the affected profile rather than assuming +the entire change failed. If saving those settings fails, no profile files are changed. + +Each profile has separate ownership and history. Existing user edits, unsafe paths and linked +catalogs are refused; the existing explicit overwrite and drift-confirmation controls remain +available. Fully quit and reopen Aside to load changed model files. diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 696f631a58..2d33b409d8 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -23,7 +23,7 @@ priorities `i * N + j`, where `j` is the selector's zero-based position; a route rows are moved outside those selector groups. Codex still advertises only the first five picker-visible rows. -The relevant no-selector priorities are: +Without complete-picker ordering, the relevant no-selector priorities are: | Catalog entry | Priority | Source | | --- | ---: | --- | @@ -112,8 +112,7 @@ have expanded into selector-qualified groups. Use `subagentModels` to choose and order the leading models that Codex also advertises to `spawn_agent`. The dashboard's **Sub-agents** page can reorder bare native and routed ids. Use `ocx agent subagents set` or edit the opencodex configuration for exact -`/` choices; the dashboard does not list those choices and omits them -if it saves the roster. Use at most five configured ids. With account selectors, one bare native +`/` choices; the dashboard retains those ids once saved, including choices currently unavailable. Use at most five configured ids. With account selectors, one bare native choice can expand into multiple selector-qualified catalog rows, so configured choices and advertised rows are not necessarily one-to-one. @@ -133,10 +132,49 @@ featured block: Listed routed rows appear in the configured order. A routed row omitted from the array keeps its normal priority, so it remains ahead of the `modelPickerOrder` display band; list every routed row whose relative position you want to control. A row also present in `subagentModels` keeps its -featured priority. Bare native and account-qualified native rows are not reordered by -`modelPickerOrder`; use `subagentModels` for those rows. +featured priority. With a routed-only list, native rows keep their normal positions. -`modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the -Codex-visible picker priority while opencodex retains each moved row's natural priority for -sub-agent selection. `disabledModels` and each provider's `selectedModels` remain visibility fields, +To order the complete picker, include a bare native id: + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Listed rows appear first in array order, followed by unlisted rows in natural priority +order. Matching uses exact catalog ids: `gpt-5.6-sol` and `openai/gpt-5.6-sol` are separate +rows. Raw and encoded spellings of the same routed id are also accepted, with exact +matches taking precedence. Empty entries are ignored. Account-qualified rows need +their selector-qualified id in the list. + +### Migration note: native ids in existing orders + +Previously, native ids in `modelPickerOrder` were ignored. An existing list containing +a bare native id now activates complete-picker ordering, including featured rows. +Remove bare native ids to keep the previous routed-only behavior. Unset, empty and +routed-only lists retain their behavior; OpenCodex's natural-priority guidance candidate calculation is unchanged. + +`modelPickerOrder` preserves OpenCodex's natural-priority calculation of up to five preferred +candidates for subagent guidance. Each moved row retains its natural priority separately from +its native `priority`; changing picker order alone must not change that OpenCodex calculation. +It does not restrict eligibility for an exact-name model override: the native advertised list +is not an allowlist, and existing authentication, model/effort and backend constraints still apply. + +Native Codex uses native `priority` to select the first five eligible picker-visible models +advertised by `spawn_agent` on V1 and on V2 when model overrides are exposed. Those advertised +five may therefore change with picker order, even when OpenCodex's preferred candidates remain +unchanged. V1 receives no OpenCodex preferred-roster injection. V2 may additionally receive +OpenCodex's natural-priority guidance when the client catalog state permits; that guidance does +not reorder the native tool's advertised list. + +`disabledModels` and each provider's `selectedModels` remain visibility fields, not ordering controls. There is no separate `modelOrder`, `providerOrder`, or priority-map setting. + +## Dashboard picker presets + +On **Models**, choose **Default**, **A–Z by model**, **Group by provider**, or **Most used snapshot**, then **Apply order**. This saves the currently visible routed ids and `modelPickerOrderMode` (`alphabetical`, `provider`, or `most-used`). Most used reads all retained usage once when applied; reloads restore the snapshot without fetching usage again. New or removed models do not automatically recompute it. A manually saved order, including a complete/native order, remains untouched until you explicitly apply a replacement. Default clears both picker fields, even when no routed models are available. + +The controls use `GET/PUT /api/subagent-models`: `chosen` and `available` retain saved roster choices, including disabled or missing models; `pickerAvailable` contains only eligible routed catalog ids. The Models page sends `pickerOrder` and `pickerOrderMode`, never `models`. Roster-only saves preserve picker settings. Invalid combined updates and failed persistence leave the previous picker/roster state intact. + +Routed-only presets keep the existing featured/native priority bands. They affect the Codex catalog and Claude discovery's routed groups; Claude's native prefix and explicit Desktop profile/alias ownership remain unchanged. OpenCodex guidance ranks and configured fallback settings are preserved, but native Codex's advertised five and recommended default can change with display priority. Saving does not restart clients; a catalog refresh may remain pending, and clients holding an old catalog may need reopening. diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index b554ed364c..b9f1a6b34d 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -93,13 +93,14 @@ Routing and catalog visibility are separate controls: `provider/model` requests fail, and `defaultModel` / `models[]` scans skip it. - `providerContextCaps` applies per-provider Codex-visible context caps. `contextCapValue` is the dashboard default (350,000 by default), but it does nothing by itself until a provider is - present in `providerContextCaps`. Changing the dashboard value re-points every enabled provider + present in `providerContextCaps`. Changing the dashboard value updates every enabled cap only when "apply to every routed provider" is toggled on; otherwise each provider keeps its own - cap. Caps only lower a known context window; they never raise one or change the upstream model's - actual limit. -- Live `/models` metadata is authoritative for a model's window until a cap lowers it. Provider - `contextWindow` and `modelContextWindows` are safety caps and fallbacks for unknown windows; - discovery never auto-fills those fields from live catalog data. + cap. Ordinary known windows can only be lowered; native models that support a longer window + can expand up to their own supported ceiling. Caps never change the upstream model's actual limit. + Switching a cap off retains its selection in `providerContextCapValues`, including after reload; + switching it on restores that selection. A remembered selection never applies a limit while disabled. + Sending `{ "setAll": true }` without `value` enables all configured providers at the current + global value and replaces their remembered selections. ```json { diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7c54d3d754..05f586a816 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -121,24 +121,12 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA chat/adapter requests use SSE (`v1internal:streamGenerateContent?alt=sse`) and buffer that stream for unary callers. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint. The adapter retries its maintained daily/production peer at most once after a first-host transport failure, empty stream, 404, or `UNAVAILABLE`; authentication, geoblock, invalid-request, and exhausted-quota responses do not trigger host failover. See [Claude on Antigravity](#claude-on-antigravity-cloud-code-assist) below. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | -### Antigravity pacing and TLS profile +Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. -The built-in `google-antigravity` provider uses conservative request pacing by default: 30 RPM, -at least 2,000 ms between request starts, and up to 500 ms of positive jitter. Existing explicit -`requestPacing` settings remain authoritative; `jitterMs` may be set from 0 through 60,000 ms and -only delays a start. Model rules can make the provider slower, never faster. - -The dashboard can explicitly enable `tlsProfile: "antigravity-browser"` for this provider. This is -an experimental, unofficial compatibility mechanism, not a compliance feature. -It may make traffic more distinctive, and initialization failures fall back to Bun; requests that -already reached the native transport are not replayed. The profile is limited to canonical Cloud -Code Assist hosts, keeps certificate and hostname verification enabled, and leaves OAuth/token/ -onboarding requests on standard Bun TLS. Users who prioritize account-policy safety should use the -official Gemini API-key, Vertex, or documented Gemini Code Assist routes. After a terminal Nous refresh failure, run `ocx login nous` to reauthenticate. @@ -234,23 +222,6 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools Inline JSON and extra positional arguments are rejected. Keep exported files private and delete or store them securely after import. -### Claude on Antigravity (Cloud Code Assist) - -The `google-antigravity` provider routes Claude models through Google's Cloud Code Assist (Antigravity) -wire rather than Anthropic's native API. opencodex translates requests and responses at the Gemini -format envelope: tool use/result pairing follows Anthropic semantics (including stable `functionCall.id` -/ `functionResponse.id` fields), and Claude thinking blocks keep their `thoughtSignature` values across -turns. - -CCA Claude models reject histories that end with an assistant (model) turn — upstream treats that as -prefill. opencodex strips trailing model turns when safe and appends a `(continue)` user nudge when the -history would otherwise end on model output (for example after context compaction or interrupted-turn -replay). Histories that already end on a user message or tool result are left unchanged. - -Antigravity exposes only SSE transport. Unary (non-streaming) callers still go through the same -`parseStream` path; plain JSON bodies without `data:` framing are rejected as truncated SSE rather -than parsed as a separate JSON response format. - ### OAuth reliability opencodex coordinates token refresh and Codex pool routing so concurrent requests do not race the @@ -339,25 +310,6 @@ from the live CLI store, or when an existing primary CLI database has no recogni Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected. -## Azure OpenAI identity - -Azure OpenAI can use the Azure SDK's default credential chain instead of an API -key. Configure `adapter: "azure-openai"` (or `"azure"`), a real resource -`baseUrl`, and `azureCredential: { "type": "default-azure-credential" }`. -For a user-assigned managed identity, add the non-secret -`managedIdentityClientId`; it selects only that managed-identity leg. Identity -uses the exact scope `https://cognitiveservices.azure.com/.default`, sends one -`Authorization: Bearer` header, and reports credential/import failures only as -`Azure identity credential unavailable` without returning SDK diagnostics or -tokens. - -Set `models` and `liveModels: false` for the supported static catalog; Azure -identity does not use generic `/models` discovery. Do not combine -`azureCredential` with `apiKey`, `apiKeyPool`, or a non-key `authMode`. API-key -mode remains supported separately and uses the adapter's `api-key` header. -See the [Azure OpenAI authentication configuration reference](/reference/configuration/providers/#azure-openai-authentication) -for copyable identity and API-key examples. - ## 3. API-key catalog opencodex ships 79 built-in presets: 67 key-based, eight OAuth, three local, and one default @@ -552,13 +504,6 @@ account bearer; the Provider-API key preset (`commandcode`) uses the active conf key. A user-edited lookalike base URL is never probed. Remaining monthly, purchased, and free credits are shown as a USD window when Command Code also reports period spend. -**Command Code project context.** Optional `projectContext: "on"` on the OAuth `command-code` provider -only (not the API-key `commandcode` preset) fills `/alpha/generate` `memory`, `taste`, and `skills` -from the proxy working directory. Set it on `providers.command-code` via **Providers → Command Code → -Edit JSON**, start the proxy from the trusted Codex project, and restart after saving. Absent or -`"off"` keeps the empty envelope even when `AGENTS.md` or taste files exist. See -[Adapters](/reference/adapters/#command-code) for file paths, caps, and fail-soft behavior. - **SambaNova Cloud discovery.** The preset reads SambaNova Cloud's public `/v1/models` list from the fixed API host, preserves provider-native ids, and caps discovery at 128 KiB and 128 raw rows. Because the catalog is unauthenticated, the CLI login flow reports the key as unverifiable instead of treating @@ -678,6 +623,12 @@ A provider is included when opencodex has a matching wire adapter, **not** based (AI Studio, Vertex, and Antigravity/Cloud Code Assist modes), `azure` / `azure-openai`, `kiro`, and `cursor`. A proprietary API without one of these implementations, such as native Amazon Bedrock, is not supported directly. + +Provider configuration selects the adapter; upstream transport selection is separate. Eligible +Responses traffic can use WSS with [explicit proxy routing](/reference/proxy-formats/#json-and-sse-output). +Invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE, which uses Bun's HTTP +proxy rules rather than the WSS-specific `ALL_PROXY` fallback. + **GitHub Copilot** is an OAuth provider (`ocx login github-copilot`) that exchanges a GitHub device-flow login for a short-lived Copilot API token — not a pasted API key. **GitLab Duo** remains a key/subscription-token gateway on its OpenAI-compatible endpoint. **Cloudflare AI diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index fe9474638a..3fabea8228 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -176,6 +176,45 @@ Before the first normal start, stream a freshly generated data-plane token into The helper accepts at most one 512-byte line, never prints the token, refuses to replace an existing token, and persists it as the canonical owner-only `service-api-token` in the `ocx-state` volume. +The deployment persists two separate homes: `ocx-state` at `/home/bun/.opencodex` for +OpenCodex configuration, provider credentials and usage, and `codex-state` at +`/home/bun/.codex` for Codex state and `opencodex-catalog.json`. The image and Compose +explicitly set `CODEX_HOME=/home/bun/.codex`, so this catalog path remains writable +with `read_only: true` and survives container recreation. The image creates both +directories for the non-root `bun` user with mode `0700`; existing volume +ownership and permissions are not migrated automatically. + +Do not combine `CODEX_HOME` and `OPENCODEX_HOME`: both products use an `auth.json` +filename with different formats. This packaging change adds persistence, not a +catalog generator. Materialize or import a valid catalog into +`/home/bun/.codex/opencodex-catalog.json` before the catalog acceptance check below; +without one, `catalog_not_found` remains the expected response. + +Upgrading preserves the existing `ocx-state` volume and adds `codex-state`; no files +are migrated automatically. If a previous workaround placed a catalog directly +under `/home/bun/.opencodex`, back it up and deliberately copy only the catalog to +the new Codex home, preserving owner-only access. Do not copy either product's +`auth.json` over the other. Deployments with a custom `CODEX_HOME` should retain +their explicit environment and writable volume mapping until migration is complete. +When overriding `CODEX_HOME`, mount that exact directory writable and persist the +default catalog at `${CODEX_HOME}/opencodex-catalog.json`. If `model_catalog_json` +explicitly selects another file, that resolved path must also be persisted. + +Keep the Compose project name stable during upgrades so the same named volumes are reused. +Mounts with existing foreign ownership, read-only mounts, and mounts using `volume-nocopy` +are not repaired by the image's directory setup. Persist separately selected catalog or SQLite +paths separately; an OS credential store is not backed up by these two volumes. + +When running without Compose, explicitly supply both named mounts. Dockerfile `VOLUME` +declarations alone create anonymous volumes that a later `docker run` does not automatically +reuse. These mount options use standalone example names; to reuse Compose data, substitute +its actual project-prefixed volume names: + +```sh +--mount type=volume,src=ocx-state,dst=/home/bun/.opencodex \ +--mount type=volume,src=codex-state,dst=/home/bun/.codex +``` + Install Git and Bun on the host first. Before **every** image build, run the existing canonical generator from this Git checkout. It hashes Git-tracked working-tree sources and container authority (stage any newly added files first), not an arbitrary directory scan. Do not change those files between @@ -262,7 +301,7 @@ docker compose restart hub ``` Do not put a token in `ARG`, `ENV`, `COPY`, Compose YAML, image history, or command arguments. Do not -mount the Docker socket, host home, Codex home, SSH agent, or provider-key files. A management +mount the Docker socket, the host's home or Codex home, SSH agent, or provider-key files. A management ingress bound to `127.0.0.1:10101` inside the container is reachable only by a TLS/tailnet frontend in the same network namespace; never publish `10101` as a shortcut. @@ -284,9 +323,9 @@ system trust plus the exact configured hostname for a CA-signed certificate). Then send one real authenticated routed response with a configured model. If the secret is absent or unreadable, a non-loopback hub must not be accepted as ready. Never treat liveness alone as proof. -`docker compose down` removes the container and network but retains the named volume. Treat +`docker compose down` removes the container and network but retains both named volumes. Treat `docker compose down --volumes` as destructive: it deletes configuration, OAuth credentials, usage -history, and the data-plane token together. +history, the data-plane token, and persisted Codex state together. ## Rollback @@ -300,7 +339,9 @@ ocx config set hub.managementIngress '{"enabled":false}' ocx service repair ``` -For a container rollback, remove or replace the container while retaining the named state volume. +For a container rollback, retain both named state volumes and their mappings. An older image +can still use `CODEX_HOME=/home/bun/.codex` when that directory remains mounted; do not revert +to an older Compose file that drops the Codex mount. Do not merge the homes or rerun token bootstrap. For a service rollback, stop the branch service and repair the prior release against the same `OPENCODEX_HOME`. Disabling management ingress or Serve does not require changing the data listener. diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 5cf5fc6d71..d53e0d3616 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -38,6 +38,14 @@ cap outcome. ## Dry-run a saved profile +Candidate capabilities use the effective provider configuration after registry +overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) +therefore use the effective upstream address. If that address cannot be classified, +the profile's `unknownEvidence.capability` setting decides eligibility. +An invalid provider configuration that cannot be resolved is always excluded with +`route-unavailable`, even when unknown capabilities are allowed. +Missing or disabled providers are also excluded with `route-unavailable` before scoring. + Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. Unsaved edits are not used by dry-run. Save the profile first so the displayed revision and evaluation refer to the same configuration. diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index effc2cb58a..53e9fe2617 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -86,6 +86,20 @@ Start with **base**. Choose **v1** when cross-provider delegation must work pred only when you specifically want its newer session model across every catalog entry. ::: +## External task input + +Codex can deliver a task's initial input or follow-up in a result-shaped envelope +without a `call_id`. On translated routes, OpenCodex recognizes only the complete +`function_call_output` shape with nonblank `id`, `name` and `namespace` and supported +text/image output, then treats it as a user turn. This also starts the new conversation +boundary during continuation and clears pending reasoning from the preceding turn. +Generated developer guidance is placed before the current task in both parsed +messages and saved raw history, preserving the same order when that history is replayed. + +Malformed, empty, opaque or incomplete envelopes still fail validation. Actual tool +results keep their required `call_id`; native passthrough and compaction retain their +existing raw-input handling. See [the adapter contract](/reference/adapters/#external-task-input-on-translated-responses-routes). + ## How it works The selected mode controls the `multi_agent_version` field in every catalog entry Codex reads: @@ -201,8 +215,9 @@ opencodex fails safely instead of forwarding an empty or unreadable task: `error.code = "unreadable_encrypted_agent_task"` and does not echo the ciphertext. An eligible direct key-auth Responses provider that explicitly opts in with `allowEncryptedV2AgentTasks: true` instead receives the opaque ciphertext and bypasses this error. -- A combo considers only canonical native ChatGPT targets for that task, including retries. If none - is available, it returns the same 400 error. +- A combo first considers canonical native ChatGPT targets. If none is available or their attempts + are exhausted, enabled recovery may make the task readable for an available routed target. + Without successful recovery and an eligible target, unreadable ciphertext is never forwarded. - A readable plaintext task keeps the normal route and fallback behavior. Recovery options are to select a native ChatGPT child, explicitly trust a direct key-auth Responses @@ -219,12 +234,28 @@ authentication, another provider credential, or another Codex account. Only `aut `content-type` and `accept` are generated locally, and no other caller headers cross the boundary. It consumes quota, adds latency, briefly retains recovered plaintext in a bounded in-memory cache, and depends on undocumented ChatGPT backend behavior. Because a model returns the recovered text, -byte-for-byte fidelity is not guaranteed. It rejects generic/API-key proxy callers and preserves -`unreadable_encrypted_agent_task` on any failure. See +byte-for-byte fidelity is not guaranteed. It rejects generic/API-key proxy callers. Failed recovery before any native attempt returns +`unreadable_encrypted_agent_task`; after native attempts have failed, their last error is retained. See [Agent configuration: Encrypted v2 task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery) for the full trust boundary and configuration. -Combo routing remains unchanged and continues to consider only canonical native ChatGPT targets for -encrypted tasks. +Combo routing prefers a selectable canonical native ChatGPT target for encrypted tasks. If none +is usable, or native authorization attempts are exhausted, an explicitly enabled recovery may +make the task readable for one available routed target. All recovery trust and no-persistence +guards above still apply; a configured but disabled or cooling native target does not block this +fallback, and cancellation never becomes an unreadable-task error. + +## Rejected encrypted history + +An upstream Responses server can reject encrypted parts in earlier function/custom-tool +output or `agent_message` content with `Encrypted function output content could not be decrypted or decoded.`. Before +any output is committed, opencodex replaces those parts with `[encrypted content omitted]` +and rebuilds the request once. The surrounding readable content stays intact; the +omitted content is not decrypted or recovered by this retry. + +If the rebuilt request receives another bare SSE `error` followed by EOF, both relay +modes preserve the error message in a `response.failed` terminal instead of reporting +`adapter_eof`. Other upstream `response.failed` events remain SSE failures. This history +recovery does not change the encrypted v2 task-delivery restrictions described above. The parent override avoids this recovery path by routing the eligible root before Codex can create encrypted child content. It does not decrypt or rewrite Codex's protocol. Native children remain diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 67224fbcf2..32cd198174 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -68,6 +68,13 @@ missing credential — the proxy did not recognise the request as loopback. Open the address the proxy prints on startup (usually `http://127.0.0.1:`), and prefer that exact host and port over a LAN IP or an alias. +## Dashboard layout + +Overview uses matching status cards and full-width settings rows. On wide screens, labels share +one column and model/effort controls share another. On narrower screens, controls move below their +labels in the same reading order. Long version labels are shortened visually; hover the version +badge or the version value to read the full value. + ## What you can do | Area | What it does | @@ -89,6 +96,35 @@ host and port over a LAN IP or an alias. | **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | +### Account selection + +Account selection is shared with request routing. Selecting an OAuth account takes effect on the +next request even when a pool is enabled. A healthy selection is not replaced merely because +another generic OAuth account has more unused quota. If the account returns 429, automatic +failover can still select another usable account with the pool off. A committed automatic +selection updates the dashboard immediately; account changes do not wait for the quota refresh +timer. Requests already sent upstream retain their original credentials. + +### Filtering request logs + +Logs filters combine surface, intercepted requests, provider, exact model, status, time, +speed, and conversation ID over the currently loaded request ring. Provider and model +choices also include fallback attempts; model matching ignores case and surrounding spaces +but does not match partial names. Choices that disappear from the ring reset to All. + +Time windows cover the last 15 minutes, hour, or day and refresh every 30 seconds while the +Logs tab is active, even with auto-refresh off. Windows use the proxy timestamp from +the logs response and advance with elapsed browser time, so a different browser clock +does not shift the cutoff. Older proxies without that timestamp retain the browser-clock +fallback until a valid sample is available. Speed uses output tokens per second over the +full request duration: below 15, 15 to below 50, or at least 50. Unavailable speed values are +excluded when a speed filter is active. Success means 2xx; errors mean 4xx or 5xx. + +Active filters show the matching count out of the loaded total. Reset filters restores all +rows and returns keyboard focus to the All surface control; “No matching requests” +differs from an empty log ring. Use arrow keys or Home/End in +the surface selector. These controls do not query historical records beyond the loaded ring. + ### Linking to a section There is a single layout, so there is no layout switch to configure. Dashboard sections are @@ -121,6 +157,25 @@ new or that every upstream measurement was refreshed. The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on. +### Managing models in a provider workspace + +In a provider’s **Models** tab, **Delete** removes the stored custom definition. An underlying +native or live-discovered model may then appear again, so the model count can stay the same. +**Hide** changes catalog visibility only: it does not delete the definition or change direct +routing policy. Use **Manage visibility in Models** to open the **Models** page and restore +visibility, even when the provider tab has no rows left. + +**Add** saves a custom definition; it does not clear an existing hide or provider selection rule. +A saved model can therefore remain hidden. If the model is already known, manage its visibility +in **Models**. A confirmed save with a failed catalog refresh is still saved: follow the refresh +message instead of adding it again. If the change cannot be confirmed, refresh the model state +before retrying. + +The provider’s model count is the number of unique, non-disabled entries in the current model +inventory returned by the server, before search or display truncation. It is not the provider +allowlist size, a live-discovery count, or proof that an entry was discovered upstream. Selection +badges and discovery information remain separate from that count. + ## Delegation picker vs spawn routing The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, @@ -270,7 +325,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Read the effective account (including `pinned` and which account is `pinnedAccountId`) and set one account's selection order. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, total, logs }` where `total` is the filtered row count before pagination. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, generatedAt, total, logs }` where `total` is the filtered row count before pagination. | | `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. | | `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. Refused with `respawnable_service` on the Windows Task Scheduler backend, and with `service_state_unknown` when that state cannot be read; nothing is changed either way. | diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 4a229200f5..164c3c23d4 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -94,6 +94,59 @@ hook を削除します。Claude Desktop は独立した profile を使用し、 `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを 指定できます。 +## リモートハブに接続した Claude Desktop + +接続中のマシンで `ocx claude desktop apply` または `ocx claude desktop` を実行すると、 +ハブの Desktop スナップショットを取得し、ハブの origin と発行済みモデル ID をそのまま +ローカル Desktop 設定に書き込みます。ローカルの別名は生成しません。static/hybrid は +モデル一覧もコピーし、discovery-only は一覧を埋め込まずハブの origin を使います。 + +プロファイル、ファミリー、デフォルトはハブ側で管理します。ハブで変更してからクライアントで +再適用し、Desktop でモデルを選び直してください。以前クライアントだけで作成した別名も +再適用・再選択が必要です。`show`、ローカル編集、import/export はローカル設定だけを扱います。 +接続中の `ocx claude desktop import --apply` は未対応で、保存前に拒否します。 +`--apply` なしの import はローカル操作のままです。 + +取得には既存の接続のデータ用認証情報を使い、管理者トークンもプロファイルのアップロードも +不要です。古いハブが未対応の場合、不正な応答や空の Desktop 一覧の場合は適用に失敗します。 +ローカル一覧やループバック URL への代替は行いません。ハブを更新・設定して再適用してください。 + +この別名変更では、[#3719](https://github.com/lidge-jun/opencodex/issues/3719) の `thinking` / `redacted_thinking` 再送とプロンプトキャッシュの +別件は修正しません。プロキシの接続認証だけではネイティブ Anthropic パススルーは有効に +なりませんが、変換された Anthropic ルートでもキャッシュは利用できます。再送の保持と +キャッシュヒットの比較は別の作業です。 + +### キーのローテーション、復旧、切断 + +キーのローテーションと復旧では、ローカル接続の認証情報とともに接続管理下の Desktop +プロファイルのキーも更新します。キー移行のための手動再適用は不要です。モデル ID、 +ファミリー、デフォルト、現在のプロファイル選択は維持し、管理プロファイルの再選択や無効な +統合の再有効化は行いません。CLI JSON の `rotation: "committed"` は新しいキーが有効に +なったことを示します。`rotation: "rolled_back"` は以前のキーを保持または復元したことを +示し、新しいキーの確定や以前のキーの失効を意味しません。不確実・未完了の復旧は成功として +報告しません。 + +最初の接続中の適用で、復元対象の元の管理設定と選択を保存します。再適用やキー更新で +この最初の記録を置き換えません。`ocx disconnect` は接続が管理する設定を復元し、ユーザーが +追加したフィールドや他のプロファイルを保持します。管理プロファイルがまだ選択されている +場合だけ元の選択に戻し、その後選んだ別の有効なプロファイルは変更しません。新規プロファイルに +ユーザー設定が追加されていれば削除せず、読み込み可能な標準モードで残します。 +`--keep-catalog` が保持するのはカタログであり、Desktop の接続キーではありません。 + +元の設定記録がない旧管理プロファイルも、現在のハブと認識済みの接続キーへの所属が明確なら +移行できます。apply、ローテーション・復旧、直接の disconnect で処理でき、新しいフラグや +事前の再適用は不要です。元の設定が未記録のため切断時に標準モードを使うという警告を表示します。 +接続所有のゲートウェイ設定だけを除去し、ユーザーフィールドと別の有効な選択は保持します。 +この結果は元の復元ではなく標準モードへのフォールバックとして報告します。 + +管理設定の競合、不明な認証情報、破損した復元記録は上書きせず報告します。中断した処理は +同じ接続について再開でき、新しい接続を消したり復元前に完了と報告したりしません。 +切断前に保留中のキー復旧を完了し、切断を再試行するときは同じカタログ保持設定を使ってください。 + +適用、ローテーション・復旧、復元後は Claude Desktop を完全に終了して開き直してください。 +ディスク上の更新では実行中のアプリが保持するキーは変わらず、自動終了・再起動もしません。 +ローカルの切断はハブのキーや外部コピーを自動失効・削除しません。必要ならハブで別途失効させてください。 + ## /model ピッカー("From gateway") Claude Code 2.1.129 以降は `GET /v1/models?limit=1000` でゲートウェイモデルを探し、デフォルトの `/model` @@ -126,6 +179,14 @@ v2 エイリアスはエスケープを展開します。読みやすい形式 **モデル解決順序:** `[1m]` 標識の削除 → 読みやすいエイリアスのデコード → Desktop ハッシュエイリアスのデコード → `modelMap` の完全一致 → 日付を削除した値との一致(`-20250514` 削除) → パススルー順です。 +解決できない日付形式の Desktop ID は、モデル検出に含まれていない実際のネイティブモデル +かもしれません。判断材料が足りず ID を解決できない場合、Messages と count-tokens は固定エラー +`desktop_model_mapping_unavailable`と HTTP 503 を返します。これはモデルが無効だという判定ではありません。 +不明な旧ハッシュ別名は引き続き HTTP 400 で拒否します。どちらも日付を除去したり別ルートへ +フォールバックしたりしません。既知の ID、登録済みマッピング、正確な `modelMap` 一致、 +認識済みの実ネイティブ ID の処理は変わりません。モデル検出を更新するか接続先ハブの +プロファイルを再適用してから試してください。再試行だけで解決する保証はありません。 + 各項目には `gemini-3-pro (gemini)` のような表示名と公式 `ModelInfo` 形式の完全なモデル能力 (推論負荷段階、thinking 型)が含まれます。実際の Anthropic モデルは両画面で正式 ID を維持します。 @@ -245,6 +306,14 @@ Anthropic パススルーはそのまま維持します。 照合順序: 検索エイリアス → 完全一致 ID → 日付接尾辞を削除した ID(`-20250514`) → パススルー順です。 +解決できない日付形式の Desktop ID は、モデル検出に含まれていない実際のネイティブモデル +かもしれません。判断材料が足りず ID を解決できない場合、Messages と count-tokens は固定エラー +`desktop_model_mapping_unavailable`と HTTP 503 を返します。これはモデルが無効だという判定ではありません。 +不明な旧ハッシュ別名は引き続き HTTP 400 で拒否します。どちらも日付を除去したり別ルートへ +フォールバックしたりしません。既知の ID、登録済みマッピング、正確な `modelMap` 一致、 +認識済みの実ネイティブ ID の処理は変わりません。モデル検出を更新するか接続先ハブの +プロファイルを再適用してから試してください。再試行だけで解決する保証はありません。 + ## サイドカーマトリクス: ウェブ検索と画像理解 ルーティングモデルごとに使えるホスト型ツールと画像サポート範囲が異なります。opencodex はメインモデルが diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 06cd590084..d1d977b35c 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -197,8 +197,14 @@ ocx sync-cache 空または省略すると、検出されたすべてのモデルが公開されます。ホワイトリストにない ID はカタログに到達しません。 2. **`disabledModels`** (トップレベル) — カタログと `/v1/models` の両方からモデルを非表示にし、反転します 裸のネイティブ GPT スラッグを `visibility: "hide"` にします。 -3. **`liveModels: false` と空の `models`** — ライブ検出がオフで、`models` が空の場合、または -省略すると、opencodex はそのプロバイダーのルーティング モデルを公開しません。 +3. **`liveModels: false`** — `liveModels: false` で `models` が空または省略されている場合、初期一覧には設定済みの + `defaultModel`、`retainModels` の順で ID を追加し、重複は最初の出現だけを残します。 + 空でない `models` が明示されている場合は、`models`、`retainModels` の順になり、別の + `defaultModel` を暗黙に追加しません。そのモデルも `models` または `retainModels` に明示すれば + 含められます。どのフィールドにも ID がなければ初期一覧は空です。この順序は最終的なピッカーの + 表示順を保証しません。`selectedModels`、`disabledModels`、プロバイダーの無効化は引き続き適用されます。 + `authMode: "forward"` は別の分岐を維持し、このルーティング用の静的一覧を使いません。 + これらの規則はライブ検出失敗時のフォールバックを変更しません。 4. **Cursor `GetUsableModels`** — Cursor アダプターはその protobuf を通じてモデルを検出します。 `/models` ではなく `GetUsableModels` RPC であるため、カーソル側の変更により、他のプロバイダーとは独立して表示される ID が変更される可能性があります。 5. **キャッシュと `ocx sync`** - ライブ カタログは約 5 分間キャッシュされます (`modelCacheTtlMs`、 diff --git a/docs-site/src/content/docs/ja/guides/model-ordering.md b/docs-site/src/content/docs/ja/guides/model-ordering.md index 6108c08771..74febb1e78 100644 --- a/docs-site/src/content/docs/ja/guides/model-ordering.md +++ b/docs-site/src/content/docs/ja/guides/model-ordering.md @@ -22,6 +22,8 @@ account-qualified native id にはその selector の `i * N + j` が使用さ selector がない場合の priority は次のとおりです。 +以下の優先順位表と例は、ピッカー全体の並び替えを有効にしていない場合のものです。 + | カタログ項目 | Priority | 根拠 | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` から `4`) | `src/codex/catalog/sync.ts` の featured rank map | @@ -107,11 +109,56 @@ account selector がある場合、5 項目の制限は bare native の選択が 先頭モデルの順序を変更するサポート手段は、`subagentModels` を並べ替えることです。 ダッシュボードの **Sub-agents** ページでは bare native id と routed id を並べ替えられます。 設定と `ocx agent subagents set` は exact account-qualified -`/` id も受け付けますが、ダッシュボードはこれらの id を表示せず、 -リストの保存時にも保持しません。設定する id は最大 5 つにしてください。account selector がある +`/` id も受け付けます。ダッシュボードは保存済みの id を現在利用できなくても保持します。設定する id は最大 5 つにしてください。account selector がある 場合は 1 つの bare native が複数の selector-qualified 行に展開されるため、設定した選択肢と公開 される行は必ずしも一対一ではありません。 -現在 `OcxConfig` には一般 `modelOrder`、`providerOrder`、priority map 設定はありません。サポートされるソート -フィールドは `subagentModels` です。`disabledModels` と各プロバイダーの `selectedModels` は公開 -フィールドです。そのため残りのピッカー順序を変えるには設定変更ではなくコード動作の変更が必要です。 +`modelPickerOrder` はピッカーの表示順だけを指定します。ルーティング ID +`/` だけを指定した場合、一覧にある非 featured 行は指定順の表示帯 +(`1000 + i`)に並びます。一覧にないルーティング行は通常の優先順位を保ち、この表示帯より前に +残ります。`subagentModels` にも含まれる行は featured の優先順位を保ち、ネイティブ行の位置も変わりません。 +相対的な順序を指定したいルーティング行はすべて一覧に含めてください。 + +ピッカー全体を並び替えるには、`/` を含まない、空でも空白だけでもないカタログ ID +(例:`gpt-5.6-sol`)を含めます。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +指定した行が配列の順序で先頭に並び、未指定の行は本来の優先順位でその後に続きます。 +カタログ ID は完全一致で照合します。`gpt-5.6-sol` と `openai/gpt-5.6-sol` は別の行です。 +同じルーティング ID の未エンコード表記とエンコード済み表記も照合できますが、完全一致が優先されます。 +空の項目と空白だけの項目は無視します。アカウント別の行には selector を含む完全な ID を指定してください。 + +### 移行時の注意:既存の一覧に含まれるネイティブ ID + +以前は `modelPickerOrder` 内の bare native ID が無視されていました。既存の一覧にこのような ID が +あると、今後は featured 行を含むピッカー全体の並び替えが有効になります。従来のルーティング行だけの +動作を保つには、bare ID を取り除いてください。未設定、空、空白だけ、ルーティング ID だけの一覧は +従来どおり動作します。 + +`modelPickerOrder` は、自然な優先順位から最大 5 件の推奨候補を選ぶ OpenCodex の +サブエージェント向けガイダンス計算を保持します。移動した各行の自然な優先順位はネイティブの +`priority` とは別に残り、ピッカー順だけを変えてもこの計算結果は変わりません。 +正確なモデル名を指定する override の利用資格を制限するものでもありません。広告リストは許可リストではなく、 +認証、モデル、effort、バックエンドに関する既存の制約は引き続き適用されます。 + +ネイティブ Codex はネイティブの `priority` に従い、利用可能でピッカーに表示されるモデルの先頭 5 件を +`spawn_agent` に広告します。これは V1 と、モデル override を公開している V2 に当てはまります。 +そのため、OpenCodex の推奨候補が同じでも、ピッカー順を変えると広告される 5 件は変わる場合があります。 +V1 には OpenCodex の推奨候補リストを注入しません。V2 にはクライアントのカタログ状態が許す場合に +自然な優先順位に基づくガイダンスを追加できますが、ネイティブツールの広告リストは並び替えません。 + +`disabledModels` と各プロバイダーの `selectedModels` は +表示の有無を制御するフィールドです。別の `modelOrder`、`providerOrder`、priority map 設定はありません。 + +## ダッシュボードの並び順プリセット + +**Models**でデフォルト、モデル名A–Z、プロバイダー別、使用量スナップショットを選び、順序を適用します。現在選択可能なルーティングIDと `modelPickerOrderMode`(`alphabetical`、`provider`、`most-used`)を保存します。使用量は適用時に保持された全履歴を一度だけ読み、再読込やモデルの増減では再計算しません。既存のカスタム・ネイティブ全体順は明示的な適用まで保持されます。デフォルトは候補が空でも両フィールドを削除できます。 + +`GET/PUT /api/subagent-models` の `chosen` と `available` は無効・欠落した保存済みrosterも保持します。`pickerAvailable` は選択可能なルーティングIDのみです。Modelsは `pickerOrder` と `pickerOrderMode` を送り、`models` は送りません。Rosterだけの保存は順序設定を維持し、不正入力・保存失敗は以前の状態を保ちます。 + +featured・ネイティブの優先順位帯を保ち、CodexカタログとClaude検出のルーティンググループに適用します。Claudeのネイティブ先頭グループと明示的なDesktopプロファイル・alias所有権は維持されます。OpenCodexのガイド順位とfallback設定は変わりませんが、ネイティブCodexの上位5候補や推奨デフォルトは変わる場合があります。保存による再起動は行いません。更新が保留中、または古いカタログを保持するクライアントでは開き直しが必要な場合があります。 diff --git a/docs-site/src/content/docs/ja/guides/model-routing.md b/docs-site/src/content/docs/ja/guides/model-routing.md index 3fb9e5f55f..7dda1864c0 100644 --- a/docs-site/src/content/docs/ja/guides/model-routing.md +++ b/docs-site/src/content/docs/ja/guides/model-routing.md @@ -86,11 +86,15 @@ model ID は変更しません。`openai-apikey/` は API key transport - `provider.disabled: true` のプロバイダーはカタログ探索から除外されます。明示的 `provider/model` リクエストは 失敗し、`defaultModel` / `models[]` 検査でもスキップします。 - `providerContextCaps` はプロバイダーごとに Codex に表示するコンテキスト上限を指定します。 - `contextCapValue` はダッシュボードが併用する値でデフォルトは 350,000 です。ただしこの値だけを設定しても - 変化はなく、`providerContextCaps` にプロバイダーが含まれていて初めて適用されます。ダッシュボードの値を - 変更すると、「すべてのルーティング対象プロバイダーに適用」がオンになっている場合にのみ、有効なすべての - プロバイダーに再適用されます。それ以外の場合、各プロバイダーは独自の上限を維持します。既知のコンテキスト - サイズを下げるだけで、上げたり上流モデルの実際の上限を変えたりはしません。 + `contextCapValue` はダッシュボードの既定値(350,000)です。この値だけでは上限は適用されず、 + `providerContextCaps` にプロバイダーが含まれている必要があります。ダッシュボードの値を変更すると、 + 「すべてのルーティング対象プロバイダーに適用」がオンの場合に限り、有効な上限をすべて更新します。 + オフの場合は各プロバイダーの上限を保持します。通常の既知のウィンドウは縮小のみ可能ですが、 + 長いウィンドウに対応したネイティブモデルは、そのモデルが対応する上限まで拡張できます。 + 上流モデルの実際の制限は変わりません。上限を無効にしても選択値は `providerContextCapValues` に + 保存され、再読み込み後も残ります。再び有効にすると選択値を復元します。無効な間は保存値を制限として + 適用しません。`value` なしの `{ "setAll": true }` は、設定済みの全プロバイダーの上限を現在の + グローバル値で有効にし、保存された選択値も置き換えます。 ```json { diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index cd5223573f..e8fae4248f 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -114,6 +114,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | +Google Antigravity のアカウント・プロバイダーのクォータ確認は、モデル一覧へのフォールバックも含め、固定の Google エンドポイントを使用します。その宛先では透過 Fake-IP DNS に対応し、TLS 検証、リダイレクト拒否、プライベートアドレス検査を維持します。カスタム base URL はモデル要求にのみ適用されます。`NO_PROXY` は直接接続のポリシーを維持します。 + + Nous の refresh が終端失敗した場合は、再認証に `ocx login nous` を実行してください。 正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index b60dade0bb..cffc233143 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -60,13 +60,33 @@ OAuth は `POST /api/oauth/login` で開始し、コールバックできない ## Docker とトラブルシューティング -公式 Docker イメージはありませんが、リポジトリには digest 固定の Bun イメージをローカルビルドするための、管理された `Dockerfile` と `compose.yaml` があります。初回の通常起動時に、自己署名 TLS 証明書と秘密鍵を `ocx-state` ボリュームの `/home/bun/.opencodex/container-tls/cert.pem` と `/home/bun/.opencodex/container-tls/key.pem` に生成します。秘密鍵は所有者だけが読み取れ、以降の起動では同じ証明書と鍵を検証して再利用します。データエンドポイントは HTTPS です。 - -初回の通常起動前に、データキーを stdin から一度だけ初期化します。bootstrap helper が受け付けるのは最大 512 バイトの 1 行だけです。キーは表示されず、既存のキーは上書きせず、`ocx-state` ボリューム内の所有者限定 `service-api-token` に保存されます。 - -ホストに Git と Bun が必要です。イメージをビルドするたびに、Git 管理下のソースから正規のマニフェストを生成し、生成後はビルドまでソースを変更しないでください。生成 JSON は Git に追加せず、`.git` は Docker コンテキストから除外します。ホスト側は既定で `127.0.0.1:10100` にバインドします。`OPENCODEX_PORT` はホスト側ポートと管理対象 TLS の `publicOrigin` の両方を変更しますが、コンテナ内のリスナーは `10100` のままです。 - -ビルドは古いマニフェストを拒否し、すべての SHA-256 をコンテキストとコピー後のファイルに照合します。マニフェストは `Dockerfile`、`compose.yaml`、`.dockerignore`、Git 管理下のすべての Docker authority ファイル、`src/`、`package.json`、`bun.lock`、`scripts/model-metadata.source.json` を認証します。欠落・不一致のファイル、マニフェストにない余分なソースまたは Docker authority ファイル、シンボリックリンクは拒否されます。 +ロールバック時も両方のボリュームとマウント先を維持してください。既存ボリュームの所有者や権限は自動修復されません。Compose を使わない場合の名前付きマウントと独自の状態パスについては、[正本ガイド](/guides/remote-hub/#docker-compose)を参照してください。 + +状態は二つのボリュームに分けて永続化します。`ocx-state` は +`OPENCODEX_HOME=/home/bun/.opencodex`、`codex-state` は +`CODEX_HOME=/home/bun/.codex` に対応します。両製品の `auth.json` は形式が +異なるため、ホームを同じディレクトリにしないでください。読み取り専用の +ルートでも、この二つのホームは書き込み可能です。 + +カタログは自動生成されません。認証付き `/v1/catalog` の確認前に、有効な +`/home/bun/.codex/opencodex-catalog.json` を生成または取り込んでください。 +空のホームでは `catalog_not_found` の 404 が正常です。アップグレードは既存の +`ocx-state` を保持して `codex-state` を追加しますが、ファイルは自動移行しません。 +以前 `.opencodex` に置いたカタログはバックアップし、カタログだけを所有者限定の +権限で移してください。`auth.json` を相互に上書きしないでください。 +`CODEX_HOME` を変更する場合は、そのディレクトリ自体を書き込み可能なボリュームに +マウントし、既定のカタログを `${CODEX_HOME}/opencodex-catalog.json` に置きます。 +`model_catalog_json` で別のファイルを指定した場合は、その解決先も永続化します。 +カスタム構成は、明示的な移行が完了するまで環境変数とボリュームの対応を維持します。 +`docker compose down` は両ボリュームを保持しますが、`docker compose down --volumes` +は `ocx-state` と `codex-state` の両方を削除し、認証情報・使用履歴・データキー・ +Codex の状態とカタログも失われます。更新や再起動の代わりに使わないでください。 + +公式 Docker イメージはありませんが、リポジトリには digest 固定の Bun イメージをローカルビルドするための、管理された `Dockerfile` と `compose.yaml` があります。初回起動前にデータキーを stdin から一度だけ初期化します。キーは表示されず、`ocx-state` ボリューム内に所有者限定の権限で保存されます。 + +ホストに Git と Bun が必要です。イメージをビルドするたびに、Git 管理下のソースから正規のマニフェストを生成し、生成後はビルドまでソースを変更しないでください。生成 JSON は Git に追加せず、`.git` は Docker コンテキストから除外します。ホスト側は既定で `127.0.0.1` にバインドします。リモート公開は `OPENCODEX_BIND_ADDRESS= docker compose up -d` で明示的に指定し、`0.0.0.0` は全インターフェースを公開します。ファイアウォールと認証付き TLS/tailnet フロントエンドで保護してください。 + +ビルドは古いマニフェストを拒否し、すべての SHA-256 をコンテキストとコピー後のファイルに照合します。欠落・不一致のファイル、余分なソース、シンボリックリンクは拒否されます。`package.json`、`bun.lock`、および `scripts/` から唯一取り込む `scripts/model-metadata.source.json` が必須です。 ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -77,33 +97,7 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -ホストから確認するには、公開証明書だけをコピーしてローカル CA として使います。秘密鍵はコピーしないでください。 - -```bash -mkdir -p .tmp -docker compose cp hub:/home/bun/.opencodex/container-tls/cert.pem .tmp/opencodex-container-ca.pem -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/healthz -``` - -別のホスト側ポートを使う場合は、以降の Compose 実行でも同じ値を指定します。 - -```bash -OPENCODEX_PORT=10190 docker compose up -d -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10190/healthz -``` - -リモート公開は `OPENCODEX_BIND_ADDRESS=` で明示的に選択し、`0.0.0.0` は全インターフェースを公開します。生成される証明書が対象とするのは `localhost` と `127.0.0.1` だけです。直接リモート公開する場合は、生成済みの証明書と鍵を正確なリモート名に対応する証明書と鍵に置き換え、`OPENCODEX_PUBLIC_ORIGIN=https://hub.example.com:10100` のように、パス、認証情報、クエリ、フラグメントを含まない正確な HTTPS origin を指定してください。ファイアウォールと認証付き TLS/tailnet フロントエンドで保護します。 - -保持されている TLS 導入前のボリュームは、次の起動時にボリューム固有の TLS identity と公開ホストポートを使う HTTPS origin へ自動移行されます。独自の証明書パスは保持されます。古い HTTP 専用イメージへ戻す場合は、現行イメージが利用できるうちに hub を停止して TLS 設定だけを削除してから、古いイメージを起動してください。証明書ファイルはボリュームに残してかまいません。 - -```bash -docker compose down -docker compose run --rm hub bun run src/cli/index.ts config unset tls -# 古いイメージを選択またはビルドしてから hub を再作成する -docker compose up -d -``` - -コンテナは非 root の `bun` ユーザー、読み取り専用のルートファイルシステムで実行され、公開するのはデータポートだけです。`10101` は公開せず、秘密値を `ARG`、`ENV`、`COPY`、Compose、イメージ履歴、argv に入れないでください。コンテナ内の health/readiness probe が証明書検証を省略できるのは、固定されたコンテナループバックへの接続だけです。外部の受入確認では、コピーした公開証明書またはシステムの信頼ストアを使い、実際に接続する正確なホスト名を必ず検証してください。healthcheck 後にも認証済みカタログと実リクエストを別途確認します。`docker compose down` はボリュームを保持し、`docker compose down --volumes` は設定、認証情報、キーも削除します。 +コンテナは非 root の `bun` ユーザー、読み取り専用のルートファイルシステムで実行され、公開するのは `10100` だけです。`10101` は公開せず、秘密値を `ARG`、`ENV`、`COPY`、Compose、イメージ履歴、argv に入れないでください。healthcheck 後にも readiness、認証済みカタログ、実リクエストを別途確認します。`docker compose down` はボリュームを保持し、`docker compose down --volumes` は設定、認証情報、キーも削除します。 - hub 停止時はオフライン切断できますが、キー失効は未完了のままです。 - 一時障害時だけ検証済み LKG を維持し、認証・スキーマ・サイズ・プロトコル障害でローカルへフォールバックしません。 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 544b32bef8..76bd3cbfc5 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -48,6 +48,14 @@ bun run dev:gui | **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。ただし Windows のタスク スケジューラ バックエンドではダッシュボードが拒否し、`ocx stop` の実行を促します。タスク終了後もラッパーがプロキシを再起動しうるため、クライアント設定を戻す前にその再起動区間を確認できるのはプロキシの外で動く stop だけです。拒否されたときは何も変更されません。 | +### リクエストログの絞り込み + +Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。 + +時間は直近15分・1時間・1日で、Logsタブでは自動更新をオフにしても30秒ごとに更新します。速度はリクエスト全体の時間あたりの毎秒出力トークン数で、15未満、15以上50未満、50以上です。速度フィルター中は測定不能な行を除外します。成功は2xx、エラーは4xx/5xxです。 + +一致件数と読み込み総数を表示し、リセットで全行を復元します。一致なしと空ログを区別します。サーフェスは矢印キーとHome/Endで操作できます。読み込み範囲外の履歴は検索しません。 + ### セクションへのリンク レスポンシブなレイアウトは 1 つだけなので、切り替える設定はありません。デスクトップではサイドバーが主なナビゲーションになり、狭い画面では **メニューを開く** で同じページリンクを表示します。Dashboard の各セクションには URL もあります。`#dashboard` は Overview、`#dashboard/providers` と `#dashboard/models` は残りの 2 つです。再読み込み・ブックマーク・戻る操作のいずれでも、表示していたセクションが保たれます。**Logs** も `#logs` と `#logs/debug` で同じように動作します。以前の `#providers/workspace` のブックマークは `#providers` に移動します。 @@ -61,6 +69,24 @@ Overview にはリクエスト数とトークン数の **30日間のアクティ **モデル** スイッチは Codex での最終的な表示状態を示します。ルーティングモデルはプロバイダーの allowlist に含まれる(または allowlist がない)うえで、無効化されていない場合だけオンになります。オン操作は両方のフィルターを原子的に調整し、**すべてオン** は allowlist を解除して新しいモデルも含めます。 +### プロバイダー画面でモデルを管理する + +プロバイダーの **モデル** タブで **Delete(削除)** を選ぶと、保存されたカスタム定義を削除します。 +元のネイティブモデルやライブ検出されたモデルが再び表示され、モデル数が変わらない場合があります。 +**非表示** はカタログの表示だけを変更し、定義の削除や直接ルーティングのポリシー変更は行いません。 +**モデルで表示を管理** から **モデル** ページを開き、表示を復元できます。プロバイダーのタブが +空になっていても、この操作は利用できます。 + +**追加** はカスタム定義を保存しますが、既存の非表示設定やプロバイダーの選択ルールを解除しません。 +保存後もモデルが非表示のままになることがあります。すでに登録されたモデルの表示は **モデル** で +管理してください。保存が確認できた場合、カタログ更新に失敗しても保存自体は完了しています。 +再追加せず、更新の案内に従ってください。変更を確認できない場合は、モデルの状態を再読み込みしてから +再試行してください。 + +プロバイダーのモデル数は、サーバーが返した現在のモデル一覧のうち、無効でない重複なしの項目数です。 +検索や表示件数の制限を適用する前に数えます。許可リストの件数やライブ検出件数ではなく、上流で検出した +項目であることを示す値でもありません。選択バッジと検出情報は、この件数とは別に扱います。 + ## 委任セレクターとスポーンルーティングの違い ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を @@ -151,7 +177,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 次のリクエストで使うアカウントとプールルーティングポリシーを設定します。 | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | 実効アカウント(固定中かどうかを示す `pinned` と、固定されているアカウントを示す `pinnedAccountId` を含む)を読み、アカウント 1 件の選択順序を設定します。 | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | ブラウザログインでプールアカウントを追加します。 | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。`limit`/`offset` は最新行から過去方向にページングします(`offset=0` が最新ページ)。応答は `{ timeZone, total, logs }` で、`total` はページング前の一致件数です。 | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail、プロバイダー、正確な状態コードまたは状態等級で最近のリクエストメタデータを参照します。`limit`/`offset` は最新行から過去方向にページングします(`offset=0` が最新ページ)。応答は `{ timeZone, generatedAt, total, logs }` で、`total` はページング前の一致件数です。 | | `GET` / `PUT /api/subagent-models` | `spawn_agent` に優先公開するモデル 5 つを読むか設定します。 | | `POST /api/stop` | プロキシ/サービスを停止しネイティブ Codex を復元した後終了します。Windows タスク スケジューラ バックエンドでは `respawnable_service`、その状態を読み取れない場合は `service_state_unknown` で拒否し、どちらの場合も何も変更されません。 | diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index d6e9425b52..b187ff7fd3 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -156,6 +156,12 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 opencodex を、ログイン時に自動起動し、クラッシュ時に自動再起動するログイン管理バックグラウンド サービス (macOS **launchd**、Linux **systemd ユーザー ユニット**、Windows **タスク スケジューラ**) として実行します。サービスは `OCX_SERVICE=1` を設定して実行されるため、再起動によって Codex 設定が変更されることはありません。 +Windows タスク スケジューラでインストールするサービスは、通常のプロセス優先度(`Priority=4`)を使用します。 +以前のバックグラウンド優先度(`7`。省略時もスケジューラの既定値は `7`)では、CPU の競合により +ヘルスチェックへの応答が遅れ、プロセスが動作中でもトレイに Offline と表示されることがあります。 +アップグレード後に `ocx service repair` を実行すると、この登録済み優先度を移行してサービスを再起動します。 +移行時に UAC の承認が必要になる場合があります。すでに通常または高優先度の場合、優先度だけを理由に再登録しません。 + |サブコマンド |アクション | | --- | --- | |なし |未インストールなら作成して開始し、既存なら更新して再起動します。正常な Windows タスク スケジューラ定義は再利用しますが、古い定義は再登録され、昇格が必要になる場合があります。 | diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 8ae5610435..d906fbf52d 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -149,10 +149,11 @@ OAuth プロバイダーと API キー プロバイダーの場合、これに ### `ocx account auto-switch > [--json]` -`openai` Codex アカウント プールのみを制御します。 `on` は 80% を設定し、`off` は 0% を設定します。`status` は現在の値を読み取り、`threshold ` は 0 ~ 100 の整数を受け入れます。他のプロバイダーと無効な値は 1 を終了します。`--json` は次を返します。 +`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は現在適用されません。保存しても、しきい値による切り替え、プロバイダーの有効化設定、429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は未適用を示し、機能が不明な場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。 ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index e7e88e91ef..522f246801 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -53,7 +53,7 @@ V1 ガイダンスは、`max` または `ultra` でのみプロアクティブ 拒否し、ロールをスキップします(#1190)。TOML 内のレガシー `model_fallback` 行は後方互換性の ために引き続き読み取られますが、`ocx doctor` がそれをフラグ付けします。 -opencodex は、無効、ルーティング不能、異常、冷却期間、またはクォータしきい値の候補をスキップします。可用性スナップショットは `subagentModelFallbackPollMs` に対してキャッシュされます。暗号化された子タスクでは、チェーンを正規のネイティブ ChatGPT ターゲットと、`allowEncryptedV2AgentTasks: true` で明示的に信頼された直接のキー認証 Responses ルートに制限します。暗号化されたペイロードを処理できる対象がない場合、読み取り不可能な暗号文を別の場所へ送らず、リクエストは失敗します。コンボは引き続き正規のネイティブ対象だけを使用します。 +opencodex は、無効、ルーティング不能、異常、冷却期間、またはクォータしきい値の候補をスキップします。可用性スナップショットは `subagentModelFallbackPollMs` に対してキャッシュされます。暗号化された子タスクでは、チェーンを正規のネイティブ ChatGPT ターゲットと、`allowEncryptedV2AgentTasks: true` で明示的に信頼された直接のキー認証 Responses ルートに制限します。暗号化されたペイロードを処理できる対象がない場合、読み取り不可能な暗号文を別の場所へ送らず、リクエストは失敗します。コンボはまず利用可能な正規ネイティブ対象を試し、選択できるネイティブ対象がなく `agentTaskRecovery` が有効な場合、暗号化された `NEW_TASK` をルーティングされたコンボ送信の前に一度だけ復旧します。 ```json { diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 683ec4acb4..20389c353e 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -33,8 +33,9 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `providers` | `Record` | — |プロバイダー名からプロバイダー設定へのマップ。 | | `openaiProviderTierVersion?` | `2` |移行によって設定される |単一のオプション対応 OpenAI プロジェクションを完了としてマークします。 | | `disabledModels?` | `string[]` | — | Codex catalog と `/v1/models` から非表示にする model。直接の proxy 呼び出しはブロックしません。routed id は一覧から削除されます。account-qualified native id は該当する selector row だけを非表示にし、bare native GPT id は bare row とその model の全 account-selector row を非表示にします。Models ページに表示されるのは bare native 行と routed 行だけです。selector-qualified 行を 1 つだけ非表示にするには、この設定フィールドを直接編集してください。 | -| `providerContextCaps?` | `Record` | `{}` |プロバイダーごとの Codex に表示されるコンテキストの上限。キャップは既知のコンテキスト ウィンドウを下げるだけです。 | -| `contextCapValue?` | `number` | `350000` |ダッシュボードのコンテキストキャップ コントロールで使用される既定値。「すべてのルーティング済みプロバイダーに適用」がオンになっている場合のみ、変更によってすべてのルーティング済みプロバイダー(`providerContextCaps` エントリがまだないプロバイダーを含む)に値が適用されます。それ以外では各プロバイダーは独自のキャップを保持します。 | +| `providerContextCaps?` | `Record` | `{}` | プロバイダーごとの有効なコンテキスト上限。通常のウィンドウは縮小されます。長いウィンドウに対応したネイティブモデルは、そのモデルが対応する上限まで拡張できます。 | +| `providerContextCapValues?` | `Record` | `{}` | プロバイダーごとに最後に選択した上限。無効にしても保持され、この値だけで上限が有効になることはありません。有効な値が保存済みの値より優先されます。 | +| `contextCapValue?` | `number` | `350000` | 初回の有効化で使う既定値。再び有効にすると、そのプロバイダーの選択値を復元します。`setAll: true` とともにグローバル値を変更すると、有効な上限だけを更新します。値を指定せずに `setAll: true` を送ると、設定済みの全プロバイダーの上限を現在のグローバル値で有効にします。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | | `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。account-qualified picker row が有効な場合、target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | @@ -88,7 +89,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic キーのヘッダー スタイル。デフォルトはネイティブ `x-api-key` です。キー認証 `anthropic` プロバイダーにのみ有効です。 | | `apiKeyPool?` | `ApiKeyPoolEntry[]` |マルチキープール。 `apiKey` はアクティブなエントリをミラーリングします。各項目には `id`、`key`、オプションの `label`、およびオプションの数値 `addedAt` があります。 | | `defaultModel?` | `string` |このプロバイダーが明示的なモデルなしで選択された場合に使用されるモデル。 | -| `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 | +| `models?` | `string[]` | 初期/フォールバックモデル一覧。`liveModels: false` で `models` が空でなければ、その後に `retainModels` を追加します。`models` が空または省略されている場合は、設定済みの `defaultModel`、`retainModels` の順に初期一覧を作り、重複 ID は最初の出現だけを残します。 | | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | | `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。マップは検出上限と同じ 2,000 件までです。 | @@ -363,7 +364,16 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ ## 静的モデルのホワイトリスト -`models` のみを公開するように `liveModels: false` を設定します。 `models` が空であるか省略されている場合、プロバイダーはルーティングされたモデルを公開しません。ライブ ディスカバリは、キャッシュする前に 4 MiB または 2,000 を超える生のモデル行を拒否します。組み込みのプリセットは下限を使用し、チャットに適した行にフィルターをかけることができます。サイズが大きすぎる、または形式が正しくない結果は、古い/構成されたフォールバックに続きます。ゼロに適格な有効な結果は引き続き権威を持ち、暗黙的に置き換えられたり切り捨てられたりすることはありません。 +`liveModels: false` で `models` が空または省略されている場合、初期一覧には設定済みの +`defaultModel`、`retainModels` の順で ID を追加し、重複は最初の出現だけを残します。 +空でない `models` が明示されている場合は、`models`、`retainModels` の順になり、別の +`defaultModel` を暗黙に追加しません。そのモデルも `models` または `retainModels` に明示すれば +含められます。どのフィールドにも ID がなければ初期一覧は空です。この順序は最終的なピッカーの +表示順を保証しません。`selectedModels`、`disabledModels`、プロバイダーの無効化は引き続き適用されます。 +`authMode: "forward"` は別の分岐を維持し、このルーティング用の静的一覧を使いません。 +これらの規則はライブ検出失敗時のフォールバックを変更しません。 + +ライブ ディスカバリは、キャッシュする前に 4 MiB または 2,000 を超える生のモデル行を拒否します。組み込みのプリセットは下限を使用し、チャットに適した行にフィルターをかけることができます。サイズが大きすぎる、または形式が正しくない結果は、古い/構成されたフォールバックに続きます。ゼロに適格な有効な結果は引き続き権威を持ち、暗黙的に置き換えられたり切り捨てられたりすることはありません。 検出を実行する必要があるが、選択した ID のみが Codex および `/v1/models` に表示される必要がある場合は、`selectedModels` を使用します。ダッシュボードには、後で許可リストを変更できるように、検出された完全なリストが保持されます。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index f12b81ce02..8b673c91ae 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -175,3 +175,7 @@ Anthropic OAuth サイドカーは、opencodex の既存のクロード コー `runtimeRole` の既定値は `standalone` です。hub は `hub.managementPublicOrigin`、loopback 限定の `hub.managementIngress`(未設定時 `enabled:false`)、正確な `remoteGui.allowedTailscaleUsers`(未設定時は空)を使います。クライアントキーは `config.json` ではなく `service-api-token` に保存され、更新中だけ `service-api-token.prev` が存在する場合があります。使用量はミラーリングされません。 `remoteGui.allowInsecureHttp` は、古い strict-schema 設定を読み込むためだけに残された非推奨の no-op です。設定から削除してください。pairing grant は loopback または認証済み HTTPS でのみ受け付けられ、この値を `true` にしても平文 HTTP pairing は再び有効になりません。 + +## Codex クォータのネットワーク診断 + +メイン Codex アカウント行の `quotaRefresh` はクォータ取得の診断情報であり、残量やモデルへのアクセス権を示すものではありません。キャッシュ利用時や取得を行わない場合は省略されることがあります。取得には操作中のシェルではなく、実行中のプロキシサービスの環境が使われます。`proxy` 未設定では既存の環境を維持し、`"auto"` は起動時に Windows の静的プロキシ設定だけを読みます。PAC/WPAD、SOCKS のみの設定、実行中の変更は自動反映されません。TUN での成功だけでは HTTP プロキシ経路の正常性は確認できません。[コマンドと状態の説明(英語)](/reference/configuration/server/#codex-quota-network-diagnostics)を参照してください。 diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index e6b4f653ec..f9d9cb54ce 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -144,12 +144,15 @@ Authorization: Bearer | `GET /api/models` |ダッシュボード/CLI モデルの行を返す |収集が飽和したときの `catalog_busy` | | `GET /api/client-config?client=...` |サポートされているファイル連携の読み取り専用クライアント設定を作成する | 400 クライアントがサポートされていません。 503 カタログは利用できません | | `PUT /api/disabled-models` |共有の無効モデル リストを置き換える | 400 無効な JSON | -| `PUT /api/model-visibility` |プロバイダーレベルまたはモデルレベルの可視性をアトミックに変更 | 400 プロバイダー、スコープ、ターゲット、または本文が無効です。 +| `PUT /api/model-visibility` |プロバイダーレベルまたはモデルレベルの可視性をアトミックに変更 | 400 プロバイダー、スコープ、ターゲット、または本文が無効です。; 409 `initial_model_selection_pending` (モデル一覧を更新してから再試行してください。) | | `GET, POST /api/custom-models` |カスタム モデルをリストするか追加する | 400 個の無効なフィールド。 404 プロバイダーがありません。 409 複製モデル | | `PUT, DELETE /api/custom-models/{id}` | 1 つのカスタム モデルを編集または削除する | 400 個の無効な ID/フィールド。 404 が見つかりません。 409 複製モデル | | `GET, PUT /api/selected-models` | プロバイダーの許可リストと可用性を読む、または許可リストを置き換える | 400 プロバイダー/本文の不足; 404 不明なプロバイダー; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | プリセット情報を読む、または preset/all/custom モードを選ぶ | 400 不正なモードまたは未提供のプリセット; 404 不明なプロバイダー; PUT 409 `initial_model_selection_pending` | +手動モデルは、Models ダッシュボードで provider と model ID が一致する行を置き換えます。OpenAI の手動行は `openai/` を維持し、表示状態を変更できます。削除すると、アカウント修飾子のないネイティブ行が復元されます。アカウント修飾付きのネイティブ行は別に保持されます。ネイティブルートやアカウントの権限は変更しません。OpenAI の非ネイティブ表示対象は、設定済みの手動モデルと一致する必要があります。 + + 信頼できる初回モデル一覧が確定するまで、有効な `PUT /api/selected-models` と `PUT /api/model-presets` も HTTP 409 とコード `initial_model_selection_pending` を返します。`GET /api/models` などでモデル一覧を更新し、取得に成功してから再試行してください。 ### OAuth アカウント、プロバイダー キー、およびデータプレーン キー @@ -188,6 +191,16 @@ Authorization: Bearer | `GET, PUT /api/provider-context-caps` |グローバル、全プロバイダー、または 1 つのプロバイダーのコンテキスト キャップを読み取りまたは更新します。 400 無効なリクエスト。 404 不明なプロバイダ | | `GET /api/provider-presets` |ランタイム レジストリから派生した GUI プロバイダー プリセットを返します。 — | +コンテキスト上限のレスポンスには `caps`(有効な上限)と `values`(無効化後も保持される最後の選択値)が +含まれます。`value` を指定せずにプロバイダーの上限を有効にすると選択値を復元し、初回はグローバルの +`contextCapValue` を使います。OpenAI でも同様で、スイッチが特別な 922k モードを選ぶことはありません。 +有効な上限はすべてのネイティブウィンドウに適用されます。長いコンテキストに対応したモデルは、 +そのモデルが対応する上限まで拡張できます。 +`{ "value": 600000, "setAll": true }` はグローバル値と有効な上限だけを更新します。 +上限が無効なプロバイダーは選択値を保持し、後で有効にすると復元します。 +`value` なしの `{ "setAll": true }` は、設定済みの全プロバイダーの上限を現在のグローバル値で有効にし、 +保存された選択値も置き換えます。無効化しても選択値は再読み込み後まで保持されますが、制限としては適用されません。 + `provider_has_dependent_combos` は安全バリアです。プロバイダーを削除する前に、依存するコンボを削除または編集してください。 ### サイドバーと同意に基づくアクション diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 2ea319351d..4fe37a385a 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -22,7 +22,7 @@ provider events → internal adapter events → client dialect | OpenAI チャットの完了 | `POST /v1/chat/completions` | JSON | `chat.completion` `chat.completion.chunk` SSE で終わる `[DONE]` | |人間的なメッセージ | `POST /v1/messages` |人類 `message` JSON |人間的メッセージ SSE | |人間トークン数 | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` |該当なし | -|モデルの発見 | `GET /v1/models` | 3 つのカタログ契約のうちの 1 つ |該当なし | +|モデルの発見 | `GET /v1/models` | カタログまたは明示的な Desktop スナップショット |該当なし | |音声とリアルタイム | `POST /v1/live`、`POST /v1/realtime/calls` |中継されたコール作成応答 |別のサイドバンド WebSocket がフレームを両方向に中継します。 |応答の圧縮 | `POST /v1/responses/compact` |置換履歴 JSON |該当なし | @@ -164,16 +164,46 @@ admission secret も削除され、別の実際の Anthropic 認証情報は維 { "input_tokens": 123 } ``` +解決できない日付形式の Desktop ID は、モデル検出に含まれていない実際のネイティブモデル +かもしれません。判断材料が足りず ID を解決できない場合、Messages と count-tokens は固定エラー +`desktop_model_mapping_unavailable`と HTTP 503 を返します。これはモデルが無効だという判定ではありません。 +不明な旧ハッシュ別名は引き続き HTTP 400 で拒否します。どちらも日付を除去したり別ルートへ +フォールバックしたりしません。既知の ID、登録済みマッピング、正確な `modelMap` 一致、 +認識済みの実ネイティブ ID の処理は変わりません。モデル検出を更新するか接続先ハブの +プロファイルを再適用してから試してください。再試行だけで解決する保証はありません。 + ## `GET /v1/models` -同じルートは、互換性のないカタログ エンベロープを予期する 3 つのクライアントにサービスを提供します。 `client_version` も存在しない限り、人間味が優先されます。 +`format=desktop-config` を指定しない場合、通常のカタログ契約は次のとおりです。 -|契約 |トリガー |トップレベルの形状 |モデル ID の動作 | | --- | --- | --- | --- | |人類モデルのリスト | `anthropic-version` ヘッダーまたは `?flavor=anthropic`、`client_version` なし | Anthropic モデル情報エントリのある `{ "data": [...] }` |クロード コードは読み取り可能な ID を受け取ります。デスクトップはプロファイル固有のエイリアス ファミリを受け取ることができます。 |Codexカタログ | `client_version` クエリパラメータ | `{ "models": [...] }` |ネイティブおよびルーティングされたエントリには、より豊富な Codex カタログ フィールド、可視性、労力、WebSocket、およびマルチエージェント メタデータが含まれています。 |プレーンな OpenAI リスト |どちらのトリガーもありません | `{ "object": "list", "data": [...] }` |表示されるネイティブ ID は裸です。ルーティング ID はエイリアスまたは `provider/model` | +### Desktop 設定スナップショット + +`GET /v1/models?ids=desktop&format=desktop-config` は user-agent に関係なく Desktop +スナップショットを明示的に選択します。応答は `{ "version": 1, "models": [...] }` で、 +`Cache-Control: no-store` を含みます。クライアントは `Accept: application/json`、 +`anthropic-version: 2023-06-01` と既存のデータ用認証情報を送ります。管理者トークンや +プロファイルのアップロードは不要です。項目はハブが発行した Desktop 設定用モデルであり、 +Codex カタログの行ではありません。 + +この形式に `ids=cli` または `client_version` を併用すると HTTP 400 になります。形式指定が +なければ上記の通常の契約を維持します。Claude が無効なら `{ "version": 1, "models": [] }` +を返し、接続中の Desktop apply は利用不可として設定を書き換えません。バージョン 1 ではなく +通常のカタログを返す古いハブは未対応で、ローカル生成 ID に切り替えることはありません。 + +スナップショットは読み取り専用のモデル一覧であり、キーローテーションやプロファイル送信の +API ではありません。Desktop のキー移行・復旧・切断は既存の接続ライフサイクルで処理します。 +ローテーションはモデルと選択を保持し、CLI の `rotation` は `committed` と `rolled_back` を +区別します。切断は管理設定を復元するか、確認済み旧プロファイルを標準モードへ戻し、 +ユーザーフィールドと後から選んだ有効なプロファイルを保持します。競合や未完了の復旧を完了とは +報告しません。ファイル変更の反映には Desktop の再起動が必要で、切断はハブのキーを自動失効 +させません。[Desktop ガイド](/ja/guides/claude-code/)を参照してください。thinking 再送と +キャッシュは別件 [#3719](https://github.com/lidge-jun/opencodex/issues/3719)です。 + ## `POST /v1/live` とRealtime サイドバンド `POST /v1/live` は、ChatGPT/Codex アプリのフレームレス通話作成サーフェスを受け入れます。 `POST /v1/realtime/calls` は、OpenAI Realtime 呼び出し作成サーフェスを受け入れます。 opencodex は、適格な OpenAI ファミリ ルートを選択し、アップストリーム認証モードのコール作成リクエストを正規化し、制限付き応答を中継します。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 3019610f16..7894531b5b 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -117,6 +117,62 @@ hook을 제거해요. Claude Desktop은 별도 profile을 사용하며 shell hoo `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. +## 원격 허브에 연결된 Claude Desktop + +허브에 연결된 컴퓨터에서 `ocx claude desktop apply` 또는 `ocx claude desktop`을 실행하면 +허브의 Desktop 모델 스냅샷을 받아요. 로컬 별칭을 새로 만들지 않고 허브가 발급한 모델 ID와 +연결된 허브 origin을 로컬 Desktop 설정에 써요. static·hybrid 모드는 모델 목록도 복사하고, +discovery-only 모드는 목록을 넣지 않고 허브 origin을 사용해요. + +Desktop 프로필과 모델 계열 배치·기본값은 허브에서 관리해요. 허브에서 바꾼 뒤 연결된 +클라이언트에서 다시 적용하고 Desktop에서 모델을 다시 선택하세요. 과거에 클라이언트에서만 +만든 별칭은 자동 이전되지 않으므로 재적용·재선택이 필요해요. 로컬 `show`, 프로필 편집, +import/export는 로컬 설정만 다뤄요. 허브 프로필을 바꾸지 않아요. 연결 중에는 +`ocx claude desktop import --apply`를 지원하지 않으며 저장 전에 거절해요. +`--apply` 없는 import는 로컬 작업으로 남아요. + +스냅샷은 기존 연결의 데이터 자격 증명으로 읽어요. 관리자 토큰이나 프로필 업로드는 +필요하지 않아요. 구형 허브가 스냅샷을 지원하지 않거나 응답이 잘못됐거나 Desktop 모델이 +없으면 적용에 실패해요. 로컬 목록이나 루프백 주소로 대신 적용하지 않아요. +허브를 업데이트하거나 설정을 확인한 뒤 다시 적용하세요. + +이번 별칭 변경에는 [#3719](https://github.com/lidge-jun/opencodex/issues/3719)의 별도 `thinking` / `redacted_thinking` 재전송과 프롬프트 캐시 +요청은 포함되지 않아요. 프록시 접속 자격 증명만으로 네이티브 Anthropic 패스스루가 켜지지는 +않지만, 번역된 Anthropic 요청도 프롬프트 캐시를 쓸 수 있어요. 재전송 보존과 캐시 적중률 +비교는 별도 작업으로 남아요. + +### 키 회전·복구와 연결 해제 + +키 회전과 복구는 로컬 연결 자격 증명과 함께 이 연결이 관리하는 Desktop 프로필의 키도 +갱신해요. 키를 바꾸려고 Desktop apply를 수동으로 다시 실행할 필요는 없어요. 기존 모델 ID, +계열·기본값과 현재 프로필 선택을 유지하며, 관리 프로필을 다시 선택하거나 꺼둔 통합을 켜지 +않아요. CLI JSON의 `rotation: "committed"`는 새 키가 활성화됐다는 뜻이에요. +`rotation: "rolled_back"`는 이전 키를 유지하거나 복원했다는 뜻이며, 새 키 적용이나 이전 키 +폐기를 뜻하지 않아요. 복구 결과가 불확실하거나 미완료면 성공으로 표시하지 않아요. + +처음 연결된 Desktop 설정을 적용할 때 복원에 필요한 기존 관리 설정과 선택을 기록해요. +재적용과 키 회전은 이 최초 기록을 유지해요. `ocx disconnect`는 연결이 관리하던 설정을 +복원하면서 사용자가 추가한 필드와 다른 프로필을 보존해요. 관리 프로필이 아직 선택돼 있을 +때만 이전 선택으로 돌아가며, 이후 사용자가 다른 유효한 프로필을 선택했다면 그대로 둬요. +새로 만든 프로필에 사용자 설정이 추가됐다면 지우지 않고 읽을 수 있는 표준 모드로 남겨요. +`--keep-catalog`는 카탈로그를 남기는 옵션이지 Desktop의 연결 키를 남기는 옵션이 아니에요. + +이전 설정 기록이 없는 구형 관리 프로필도 현재 허브와 확인된 연결 키에 속하면 이전할 수 +있어요. apply, 키 회전·복구 또는 바로 disconnect를 실행하면 되고, 새 플래그나 사전 재적용은 +필요하지 않아요. 이 경우 이전 설정이 기록되지 않아 연결 해제 시 표준 모드로 바뀐다는 +경고를 표시해요. 연결이 관리하던 게이트웨이 설정만 제거하고 사용자 필드와 별도로 선택한 +유효한 프로필을 보존해요. 이 결과는 원본 복원이 아닌 표준 모드 전환으로 표시해요. + +관리 설정 충돌, 알 수 없는 자격 증명, 손상된 복원 기록은 덮어쓰지 않고 문제를 알려줘요. +중단된 정리는 같은 연결에 한해 이어갈 수 있으며, 새 연결을 지우거나 복원이 끝나기 전에 +완료됐다고 하지 않아요. 연결 해제 전에 진행 중인 키 회전 복구를 마치고, 연결 해제를 +재시도할 때는 처음 고른 카탈로그 유지 옵션을 그대로 쓰세요. + +적용·키 회전·복구·설정 복원 후에는 Claude Desktop을 완전히 종료하고 다시 여세요. +파일을 바꿔도 실행 중인 앱이 가진 키는 바뀌지 않으며, 앱을 자동 종료하거나 재시작하지 +않아요. 연결 해제는 로컬에서 처리하고 허브 키나 외부에 따로 복사한 키를 자동 폐기하지 +않아요. 폐기가 필요하면 허브에서 별도로 처리하세요. + ## /model 선택기("From gateway") 각 항목은 `gemini-3-pro (gemini)` 같은 정직한 표시 이름과 함께, 공식 ModelInfo 형태의 모델 능력 정보(추론 강도 사다리, thinking 타입)를 실어 보냅니다 — Claude Desktop의 서드파티 @@ -159,6 +215,14 @@ v2 별칭은 이스케이프를 펼쳐요. 읽기 쉬운 형식으로 표현할 **모델 해석 순서:** `[1m]` 표식 제거 → 읽기 쉬운 별칭 디코딩 → Desktop 해시 별칭 디코딩 → `modelMap` 정확히 일치 → 날짜를 제거한 값과 일치(`-20250514` 제거) → 패스스루 순서예요. +해결되지 않은 날짜형 Desktop ID는 모델 탐색에서 빠진 실제 네이티브 모델일 수도 있어요. +확인된 정보만으로 ID를 해석할 수 없으면 Messages와 count-tokens는 고정된 `desktop_model_mapping_unavailable` +오류와 HTTP 503을 반환해요. 모델이 잘못됐다고 확정한 것은 아니에요. 알 수 없는 레거시 +해시 별칭은 계속 HTTP 400으로 거절해요. 두 경우 모두 날짜를 떼거나 다른 경로로 폴백하지 +않아요. 알려진 ID, 등록된 매핑, 정확한 `modelMap` 일치와 인식된 실제 네이티브 ID는 기존 +방식대로 처리해요. 모델 탐색을 새로 하거나 연결된 허브 프로필을 다시 적용한 뒤 시도하세요. +재시도만으로 해결된다는 보장은 없어요. + 각 항목에는 `gemini-3-pro (gemini)` 같은 표시 이름과 공식 `ModelInfo` 형식의 전체 모델 기능 (reasoning-effort 단계, thinking 유형)이 들어 있어요. 실제 Anthropic 모델은 두 화면 모두에서 정식 ID를 유지해요. @@ -283,6 +347,14 @@ Anthropic 패스스루는 그대로 유지해요. 조회 순서: 검색 별칭 → 정확한 ID → 날짜 접미사를 제거한 ID(`-20250514`) → 패스스루 순서예요. +해결되지 않은 날짜형 Desktop ID는 모델 탐색에서 빠진 실제 네이티브 모델일 수도 있어요. +확인된 정보만으로 ID를 해석할 수 없으면 Messages와 count-tokens는 고정된 `desktop_model_mapping_unavailable` +오류와 HTTP 503을 반환해요. 모델이 잘못됐다고 확정한 것은 아니에요. 알 수 없는 레거시 +해시 별칭은 계속 HTTP 400으로 거절해요. 두 경우 모두 날짜를 떼거나 다른 경로로 폴백하지 +않아요. 알려진 ID, 등록된 매핑, 정확한 `modelMap` 일치와 인식된 실제 네이티브 ID는 기존 +방식대로 처리해요. 모델 탐색을 새로 하거나 연결된 허브 프로필을 다시 적용한 뒤 시도하세요. +재시도만으로 해결된다는 보장은 없어요. + ## 사이드카 매트릭스: 웹 검색과 이미지 이해 라우팅 모델마다 쓸 수 있는 호스팅 도구와 이미지 지원 범위가 달라요. opencodex는 메인 모델이 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 3f777153ec..44551de837 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -197,7 +197,14 @@ Codex에서 model이 빠졌거나 catalog 순서/가시성이 이상해 보이 1. provider의 **`selectedModels`** - 비어 있지 않은 allowlist는 해당 id만 Codex에 노출합니다. 비어 있거나 생략하면 발견된 model이 모두 노출됩니다. allowlist에 없는 id는 catalog에 절대 들어가지 않습니다. 2. **`disabledModels`**(top level) - catalog와 `/v1/models`에서 model을 숨기고, bare native GPT slug는 `visibility: "hide"`로 바꿉니다. -3. **`liveModels: false`와 비어 있는 `models`** - live discovery가 꺼져 있고 `models`가 비어 있거나 생략되면, opencodex는 그 provider에 대해 routed model을 하나도 노출하지 않습니다. +3. **`liveModels: false`** — `liveModels: false`에서 `models`가 비어 있거나 생략되면 초기 목록은 설정된 `defaultModel`, + `retainModels` 순으로 구성합니다. 중복 ID는 처음 나온 항목만 남깁니다. 비어 있지 않은 `models`를 + 명시하면 `models`, `retainModels` 순으로 구성하며, 다른 `defaultModel`을 자동으로 추가하지 않습니다. + 그 모델도 `models`나 `retainModels`에 직접 넣으면 포함할 수 있습니다. 어느 필드에도 ID가 없으면 + 초기 목록은 비어 있습니다. 이 순서는 최종 선택기의 표시 순서를 보장하지 않습니다. + `selectedModels`, `disabledModels`, 공급자 비활성화 정책은 그대로 적용됩니다. + `authMode: "forward"`는 기존 별도 분기를 따르며 이 정적 라우팅 목록을 사용하지 않습니다. + 이 규칙은 라이브 발견 실패 시 폴백 동작을 바꾸지 않습니다. 4. **Cursor `GetUsableModels`** - Cursor adapter는 `/models`가 아니라 protobuf `GetUsableModels` RPC로 model을 찾습니다. 그래서 Cursor 쪽 변경이 다른 provider와 무관하게 어떤 id가 보이는지 바꿀 수 있습니다. 5. **캐시와 `ocx sync`** - live catalog는 약 5분(`modelCacheTtlMs`, 기본값 `300000`) 동안 캐시됩니다. `ocx sync`를 실행하면 새로 가져와서 catalog를 즉시 다시 쓸 수 있습니다. 6. **실행 중인 Codex `app-server`** - 오래 살아 있는 Codex `app-server`(Desktop / CLI background host)가 이전 목록을 메모리에 쥐고 있으면 디스크 catalog를 다시 쓰는 것만으로는 부족합니다. `ocx sync`와 `ocx sync-cache`는 그런 process를 감지하면 경고합니다. `ocx sync --restart-codex`로 다시 시작하거나(아니면 일치하는 `app-server` process를 직접 중지한 뒤), Codex가 다시 만들게 해서 새 목록이 보이게 하세요. diff --git a/docs-site/src/content/docs/ko/guides/model-ordering.md b/docs-site/src/content/docs/ko/guides/model-ordering.md index 3c960b1840..e921bfdfbc 100644 --- a/docs-site/src/content/docs/ko/guides/model-ordering.md +++ b/docs-site/src/content/docs/ko/guides/model-ordering.md @@ -22,6 +22,8 @@ native id는 해당 selector의 `i * N + j`를 사용합니다. Codex는 계속 selector가 없을 때의 priority는 다음과 같습니다. +아래 우선순위 표와 예시는 선택기 전체 정렬을 켜지 않은 경우를 설명합니다. + | 카탈로그 항목 | Priority | 근거 | | --- | ---: | --- | | `subagentModels[i]` | `i` (`0`부터 `4`) | `src/codex/catalog/sync.ts`의 featured rank map | @@ -107,10 +109,55 @@ account selector가 있으면 bare native 선택이 selector-qualified 그룹으 선두 모델 순서를 바꾸는 지원 수단은 `subagentModels`를 재정렬하는 것입니다. 대시보드의 **Sub-agents** 페이지에서는 bare native와 routed id의 순서를 바꿀 수 있습니다. 설정과 `ocx agent subagents set`은 exact account-qualified `/` id도 -지원하지만, 대시보드는 이러한 id를 제공하지 않으며 목록을 저장할 때도 보존하지 않습니다. 설정 id는 +지원합니다. 대시보드는 이미 저장된 id를 현재 사용할 수 없어도 보존합니다. 설정 id는 최대 5개만 사용하세요. account selector가 있으면 bare native 하나가 여러 selector-qualified 행으로 확장될 수 있으므로 설정 항목과 노출 행이 항상 일대일로 대응하지는 않습니다. -현재 `OcxConfig`에는 일반 `modelOrder`, `providerOrder`, priority map 설정이 없습니다. 지원되는 정렬 -필드는 `subagentModels`입니다. `disabledModels`와 각 프로바이더의 `selectedModels`는 노출 -필드입니다. 따라서 나머지 선택기 순서를 바꾸려면 설정 수정이 아니라 코드 동작 변경이 필요합니다. +`modelPickerOrder`는 선택기의 표시 순서만 지정합니다. 라우팅 ID인 `/`만 +넣으면 목록에 있는 비 featured 행이 지정 순서대로 별도 표시 구간(`1000 + i`)에 배치됩니다. +목록에 없는 라우팅 행은 원래 우선순위를 유지하므로 이 구간보다 앞에 남습니다. `subagentModels`에도 +들어 있는 행은 featured 우선순위를 유지하고, 네이티브 행도 원래 위치를 유지합니다. +상대적 순서를 정할 라우팅 행은 모두 목록에 넣어야 합니다. + +선택기 전체를 정렬하려면 `/`가 없는 카탈로그 ID를 하나 이상 넣으세요. `gpt-5.6-sol`처럼 실제 문자가 +있는 bare ID여야 하며, 빈 문자열이나 공백만 있는 항목은 해당하지 않습니다. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +지정한 행이 배열 순서대로 먼저 나오고, 나머지 행은 원래 우선순위대로 뒤에 나옵니다. +카탈로그 ID는 정확히 일치하는 값으로 찾습니다. `gpt-5.6-sol`과 `openai/gpt-5.6-sol`은 서로 다른 행입니다. +같은 라우팅 ID의 원문 표기와 인코딩 표기도 허용하지만, 정확히 일치하는 항목이 우선합니다. +빈 항목과 공백뿐인 항목은 무시합니다. 계정별 행을 지정할 때는 selector가 포함된 전체 ID를 써야 합니다. + +### 마이그레이션 주의: 기존 목록에 들어 있는 네이티브 ID + +이전에는 `modelPickerOrder`의 bare native ID를 무시했습니다. 이제 기존 목록에 이런 ID가 있으면 +featured 행을 포함한 선택기 전체 정렬이 활성화됩니다. 기존 라우팅 전용 동작을 유지하려면 bare ID를 +제거하세요. 미설정 목록, 빈 목록, 공백만 있는 목록, 라우팅 ID만 있는 목록은 기존 동작을 유지합니다. + +`modelPickerOrder`는 자연 우선순위로 최대 5개의 선호 후보를 고르는 OpenCodex의 +서브에이전트 안내용 계산을 보존합니다. 이동한 각 행의 자연 우선순위는 네이티브 `priority`와 별도로 +남으며, 선택기 순서만 바꿔서는 이 계산 결과가 달라지지 않습니다. 정확한 모델 이름으로 override를 +지정할 자격도 제한하지 않습니다. 광고 목록은 허용 목록이 아니며, 기존 인증·모델·effort·백엔드 제약은 +그대로 적용됩니다. + +네이티브 Codex는 네이티브 `priority` 순서에서 사용 가능하고 선택기에 표시되는 모델 중 앞의 5개를 +`spawn_agent`에 광고합니다. V1과 모델 override를 공개하는 V2가 여기에 해당합니다. +따라서 OpenCodex의 선호 후보가 그대로여도 선택기 순서에 따라 광고되는 5개는 달라질 수 있습니다. +V1에는 OpenCodex의 선호 후보 목록을 주입하지 않습니다. V2는 클라이언트 카탈로그 상태가 허용할 때 +자연 우선순위 기반 안내를 추가로 받을 수 있지만, 이 안내가 네이티브 도구의 광고 목록을 재정렬하지는 않습니다. + +`disabledModels`와 각 공급자의 `selectedModels`는 노출 여부를 정하는 필드입니다. +별도의 `modelOrder`, `providerOrder`, priority map 설정은 없습니다. + +## 대시보드 정렬 프리셋 + +**Models**에서 기본값·모델 이름순·프로바이더별·사용량순 스냅샷을 선택한 뒤 **순서 적용**을 누르세요. 현재 표시 가능한 라우팅 ID와 `modelPickerOrderMode`(`alphabetical`, `provider`, `most-used`)를 저장합니다. 사용량순은 적용 시 보관된 전체 사용량을 한 번 읽습니다. 다시 열거나 모델이 추가·삭제되어도 자동 재계산하지 않습니다. 기존 사용자 지정·네이티브 전체 순서는 명시적으로 적용하기 전까지 유지됩니다. 기본값은 사용 가능한 모델이 없어도 두 피커 필드를 지웁니다. + +`GET/PUT /api/subagent-models`의 `chosen`·`available`은 비활성·누락된 저장 roster도 보존하고, `pickerAvailable`은 선택 가능한 라우팅 ID만 제공합니다. Models는 `pickerOrder`·`pickerOrderMode`만 보내며 `models`를 보내지 않습니다. Roster만 저장하면 피커 설정은 유지됩니다. 잘못된 요청이나 저장 실패는 이전 상태를 보존합니다. + +라우팅 전용 프리셋은 featured·네이티브 우선순위 구간을 유지하며 Codex 카탈로그와 Claude 검색 목록의 라우팅 그룹에 적용됩니다. Claude 네이티브 선두 그룹과 명시적 Desktop 프로필·alias 소유권은 유지됩니다. OpenCodex 가이드 순위와 fallback 설정은 유지되지만 네이티브 Codex에 표시되는 상위 5개·권장 기본 모델은 달라질 수 있습니다. 저장은 클라이언트를 재시작하지 않으며 카탈로그 갱신이 미완료이면 나중에 다시 열어야 할 수 있습니다. diff --git a/docs-site/src/content/docs/ko/guides/model-routing.md b/docs-site/src/content/docs/ko/guides/model-routing.md index f9b1eff92c..ac6425f342 100644 --- a/docs-site/src/content/docs/ko/guides/model-routing.md +++ b/docs-site/src/content/docs/ko/guides/model-routing.md @@ -84,12 +84,15 @@ fallback하지 않습니다. 직접 라우팅은 그대로 두고, 카탈로그와 `/v1/models`에 내보낼 모델만 줄입니다. - `provider.disabled: true`인 프로바이더는 카탈로그 탐색에서 제외됩니다. 명시적 `provider/model` 요청은 실패하고, `defaultModel` / `models[]` 검사에서도 건너뜁니다. -- `providerContextCaps`는 프로바이더별로 Codex에 표시할 컨텍스트 상한을 지정합니다. - `contextCapValue`는 대시보드가 함께 쓰는 값이며 기본값은 350,000입니다. 다만 이 값만 설정해서는 - 아무 변화가 없고 `providerContextCaps`에 프로바이더가 들어 있어야 적용됩니다. 대시보드 값을 변경하면 - '모든 라우팅 대상 프로바이더에 적용' 토글이 켜져 있을 때만 모든 활성 프로바이더에 다시 적용되며, - 그렇지 않으면 각 프로바이더는 자체 한도를 유지합니다. 이미 알려진 컨텍스트 크기를 낮추기만 하며, - 더 키우거나 업스트림 모델의 실제 한도를 바꾸지는 않습니다. +- `providerContextCaps`는 공급자별로 Codex에 표시할 컨텍스트 상한을 지정합니다. + `contextCapValue`는 대시보드의 기본값이며 기본 설정은 350,000입니다. 이 값만으로는 상한이 적용되지 않고, + `providerContextCaps`에 공급자가 있어야 적용됩니다. '모든 라우팅 대상 공급자에 적용' 토글을 켠 상태에서 + 대시보드 값을 바꾸면 활성 상한만 갱신합니다. 토글이 꺼져 있으면 각 공급자의 상한을 유지합니다. + 일반적인 기존 윈도는 줄일 수만 있지만, 장문 윈도를 지원하는 네이티브 모델은 해당 모델의 지원 상한까지 + 확장할 수 있습니다. 업스트림 모델의 실제 한도는 바뀌지 않습니다. 상한을 꺼도 선택값은 + `providerContextCapValues`에 남고 다시 불러와도 유지됩니다. 다시 켜면 이 선택값을 복원하며, + 꺼져 있는 동안에는 저장된 값을 제한으로 적용하지 않습니다. `value` 없이 `{ "setAll": true }`를 보내면 + 설정된 모든 공급자의 상한을 현재 전역 값으로 켜고, 저장된 선택값도 이 값으로 바꿉니다. ```json { diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 20eb9cf4b9..b7a9dce566 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -113,6 +113,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | +Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. + + Nous refresh가 종료 실패한 경우, `ocx login nous`로 재인증하세요. 정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index b786a38eb6..e924175672 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -86,13 +86,29 @@ ocx connect rotate --admin-token-stdin ## Docker -opencodex는 공식 컨테이너 이미지를 배포하지 않지만, 저장소 루트의 `Dockerfile`과 `compose.yaml`로 digest가 고정된 소스 이미지를 직접 빌드할 수 있습니다. 최초 정상 시작 시 자체 서명 TLS 인증서와 개인 키를 `ocx-state` 볼륨의 `/home/bun/.opencodex/container-tls/cert.pem`과 `/home/bun/.opencodex/container-tls/key.pem`에 생성합니다. 개인 키는 소유자만 읽을 수 있으며 이후 시작에서는 같은 인증서와 키를 검증한 뒤 다시 사용합니다. 데이터 엔드포인트는 HTTPS입니다. +롤백할 때도 두 볼륨과 마운트 경로를 유지하세요. 기존 볼륨의 소유권과 권한은 자동으로 복구되지 않습니다. Compose 없이 실행할 때의 named volume 지정과 별도 상태 경로는 [영문 기준 가이드](/guides/remote-hub/#docker-compose)를 참고하세요. -최초 정상 시작 전에 데이터 키를 stdin으로 한 번만 초기화하세요. bootstrap helper는 최대 512바이트인 한 줄만 허용합니다. 키를 출력하거나 기존 키를 덮어쓰지 않고 `ocx-state` 볼륨의 소유자 전용 `service-api-token`에 저장합니다. +상태는 두 볼륨에 분리해 보관합니다. `ocx-state`는 +`OPENCODEX_HOME=/home/bun/.opencodex`, `codex-state`는 +`CODEX_HOME=/home/bun/.codex`에 연결됩니다. 두 제품의 `auth.json` 형식이 다르므로 +홈을 같은 폴더로 합치지 마세요. 루트 파일 시스템이 read-only여도 이 두 홈은 쓰기 가능합니다. -호스트에 Git과 Bun이 필요합니다. 이미지를 빌드할 때마다 Git이 추적하는 소스로 정식 매니페스트를 생성하고, 생성부터 빌드 사이에는 소스를 변경하지 마세요. 생성된 JSON은 Git에 추가하지 않으며 `.git`은 Docker 컨텍스트에서 제외됩니다. 호스트 포트는 기본적으로 `127.0.0.1:10100`에 바인딩됩니다. `OPENCODEX_PORT`는 호스트 포트와 관리되는 TLS의 `publicOrigin`을 함께 변경하지만 컨테이너 내부 리스너는 `10100`을 유지합니다. +카탈로그는 자동 생성되지 않습니다. 인증된 `/v1/catalog` 검사 전에 유효한 +`/home/bun/.codex/opencodex-catalog.json`을 생성하거나 가져와야 합니다. +빈 홈에서 `catalog_not_found` 404는 정상입니다. 업그레이드는 기존 `ocx-state`를 +유지하고 `codex-state`를 추가하지만 파일을 자동 이동하지 않습니다. 이전 우회 설정으로 +`.opencodex`에 둔 카탈로그는 백업한 뒤 카탈로그만 owner-only 권한으로 옮기세요. +두 제품의 `auth.json`을 서로 덮어쓰면 안 됩니다. 사용자 지정 `CODEX_HOME`은 그 정확한 +디렉터리를 쓰기 가능한 볼륨에 연결하고, 기본 카탈로그를 +`${CODEX_HOME}/opencodex-catalog.json`에 준비해야 합니다. `model_catalog_json`으로 +별도 파일을 지정했다면 그 경로도 영속 보관하세요. 명시적 이전이 완료되기 전까지는 +기존 사용자 지정 환경 변수와 볼륨 경로의 대응을 유지하세요. -빌드는 오래된 매니페스트를 거부하며 모든 SHA-256을 컨텍스트와 복사된 파일에 각각 대조합니다. 매니페스트는 `Dockerfile`, `compose.yaml`, `.dockerignore`, Git이 추적하는 모든 Docker authority 파일, `src/`, `package.json`, `bun.lock`, `scripts/model-metadata.source.json`을 인증합니다. 누락되거나 일치하지 않는 파일, 매니페스트에 없는 추가 소스 또는 Docker authority 파일, 심볼릭 링크는 거부됩니다. +opencodex는 공식 컨테이너 이미지를 배포하지 않지만, 저장소 루트의 `Dockerfile`과 `compose.yaml`로 digest가 고정된 소스 이미지를 직접 빌드할 수 있습니다. 최초 실행 전에 데이터 키를 stdin으로 초기화하세요. 키는 출력되지 않으며 `ocx-state` 볼륨의 owner-only `service-api-token`에 저장됩니다. + +호스트에 Git과 Bun이 필요합니다. 이미지를 빌드할 때마다 Git이 추적하는 소스로 정식 매니페스트를 생성하고, 생성부터 빌드 사이에는 소스를 변경하지 마세요. 생성된 JSON은 Git에 추가하지 않으며 `.git`은 Docker 컨텍스트에서 제외됩니다. 호스트 포트는 기본적으로 `127.0.0.1`에 바인딩됩니다. 원격 공개는 `OPENCODEX_BIND_ADDRESS= docker compose up -d`로 명시적으로 선택하며, `0.0.0.0`은 모든 인터페이스에 공개합니다. 방화벽과 인증된 TLS/tailnet 프런트엔드로 보호하세요. + +빌드는 오래된 매니페스트를 거부하며 모든 SHA-256을 컨텍스트와 복사된 파일에 각각 대조합니다. 누락·불일치 파일, 매니페스트에 없는 추가 소스, 심볼릭 링크는 거부됩니다. `package.json`, `bun.lock`과 `scripts/`에서 유일하게 포함하는 `scripts/model-metadata.source.json`이 필수입니다. ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -103,37 +119,11 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -호스트에서 기본 게시를 확인하려면 공개 인증서만 복사해 로컬 CA로 사용하세요. 개인 키는 복사하지 마세요. - -```bash -mkdir -p .tmp -docker compose cp hub:/home/bun/.opencodex/container-tls/cert.pem .tmp/opencodex-container-ca.pem -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/healthz -``` - -다른 호스트 포트를 사용한다면 이후 Compose 실행에도 같은 값을 지정하세요. - -```bash -OPENCODEX_PORT=10190 docker compose up -d -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10190/healthz -``` - -원격 공개는 `OPENCODEX_BIND_ADDRESS=`로 명시적으로 선택하며, `0.0.0.0`은 모든 인터페이스에 공개합니다. 생성된 인증서는 `localhost`와 `127.0.0.1`만 포함합니다. 원격으로 직접 공개하려면 생성된 인증서와 키를 정확한 원격 이름에 맞는 인증서와 키로 교체하고, `OPENCODEX_PUBLIC_ORIGIN=https://hub.example.com:10100`처럼 경로, 자격 증명, 쿼리, 프래그먼트가 없는 정확한 HTTPS origin을 지정하세요. 방화벽과 인증된 TLS/tailnet 프런트엔드로 보호해야 합니다. - -TLS 도입 전에 만든 볼륨을 유지하고 있다면 다음 시작에서 볼륨별 TLS identity와 게시된 호스트 포트를 사용하는 HTTPS origin으로 자동 마이그레이션합니다. 운영자가 지정한 인증서 경로는 보존됩니다. 이전 HTTP 전용 이미지로 롤백하려면 현재 이미지를 사용할 수 있을 때 hub를 중지하고 TLS 설정만 제거한 뒤 이전 이미지를 시작하세요. 인증서 파일은 볼륨에 남아 있어도 됩니다. - -```bash -docker compose down -docker compose run --rm hub bun run src/cli/index.ts config unset tls -# 이전 이미지를 선택하거나 빌드한 다음 hub를 다시 생성 -docker compose up -d -``` - -이미지는 non-root `bun` 사용자로 실행되고 루트 파일 시스템은 read-only이며 공개 포트는 `10100` 하나뿐입니다. 토큰을 `ARG`, `ENV`, `COPY`, Compose YAML, 이미지 기록, 명령행에 넣지 마세요. Docker socket, 호스트 홈, Codex 홈, SSH agent, 프로바이더 키도 마운트하지 마세요. 컨테이너 안의 `127.0.0.1:10101` 관리 포트는 같은 네트워크 네임스페이스의 TLS/tailnet 프런트엔드로만 연결하고 직접 publish하지 마세요. +이미지는 non-root `bun` 사용자로 실행되고 루트 파일 시스템은 read-only이며 공개 포트는 `10100` 하나뿐입니다. 토큰을 `ARG`, `ENV`, `COPY`, Compose YAML, 이미지 기록, 명령행에 넣지 마세요. Docker socket, 호스트의 홈이나 Codex 홈, SSH agent, 프로바이더 키도 마운트하지 마세요. 컨테이너 안의 `127.0.0.1:10101` 관리 포트는 같은 네트워크 네임스페이스의 TLS/tailnet 프런트엔드로만 연결하고 직접 publish하지 마세요. -컨테이너 내부 health/readiness probe가 인증서 검증을 생략할 수 있는 범위는 고정된 컨테이너 루프백 연결뿐입니다. 외부 인수 검사에서는 복사한 공개 인증서나 시스템 신뢰 저장소를 사용해 실제로 접속하는 정확한 호스트 이름을 반드시 검증하세요. 컨테이너 healthcheck의 `/healthz`가 통과한 뒤 인증된 `/v1/catalog`와 실제 모델 응답도 별도로 확인해야 합니다. +컨테이너 healthcheck의 `/healthz`가 통과한 뒤 `/readyz`, 인증된 `/v1/catalog`, 실제 모델 응답을 별도로 확인하세요. -`docker compose down`은 `ocx-state` 볼륨을 보존합니다. `docker compose down --volumes`는 설정, OAuth 인증 정보, 사용량 기록, 데이터 키를 함께 삭제하므로 파괴적 작업으로 취급하세요. +`docker compose down`은 `ocx-state`와 `codex-state`를 모두 보존합니다. `docker compose down --volumes`는 두 볼륨을 모두 삭제하여 설정, OAuth 인증 정보, 사용량 기록, 데이터 키, Codex 상태와 카탈로그를 지웁니다. 업그레이드나 재시작 대신 사용하지 마세요. ## 롤백과 문제 해결 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 21833ad961..01e8fda575 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -48,6 +48,27 @@ bun run dev:gui | **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). 단, Windows 작업 스케줄러로 관리되는 경우에는 대시보드가 거절하고 `ocx stop`을 안내합니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 클라이언트 설정을 되돌리기 전에 그 재시작 구간을 확인할 수 있는 건 프록시 바깥에서 도는 stop뿐입니다. 거절될 때는 아무것도 바뀌지 않습니다. | +### 요청 로그 필터 + +Logs에서는 클라이언트 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, +속도, 대화 ID 조건을 함께 적용할 수 있습니다. 현재 불러온 로그만 필터링하며, +공급자·모델 선택지에는 폴백 시도도 포함됩니다. 모델명은 대소문자와 앞뒤 공백을 +무시하지만 부분 이름은 일치하지 않습니다. 선택한 공급자나 모델이 불러온 로그에서 +사라지면 해당 필터만 전체로 돌아갑니다. + +시간 범위는 최근 15분·1시간·1일입니다. Logs 탭에서는 자동 새로고침을 꺼도 +30초마다 시간 필터를 갱신합니다. 프록시가 응답에 담은 시각에 브라우저의 경과 시간을 +더해 계산하므로, 두 기기의 시계가 달라도 범위가 밀리지 않습니다. 시각을 보내지 않는 +이전 프록시에서는 유효한 응답을 받기 전까지 브라우저 시계를 사용합니다. 속도는 전체 요청 시간으로 계산한 초당 출력 토큰 +수이며, 15 미만·15 이상 50 미만·50 이상으로 나뉩니다. 속도 필터를 켜면 측정값이 +없는 요청은 제외됩니다. 성공은 2xx, 오류는 4xx·5xx입니다. + +필터를 적용하면 일치하는 건수와 불러온 전체 건수가 표시됩니다. 필터를 초기화하면 +불러온 모든 행이 다시 나타나고 키보드 포커스는 클라이언트 종류의 전체 선택으로 돌아갑니다. +조건에 맞는 요청이 없는 상태와 로그 자체가 빈 +상태는 구분해서 표시합니다. 클라이언트 종류는 방향키와 Home/End로 선택할 수 있습니다. +불러온 범위 밖의 과거 로그는 조회하지 않습니다. + ### 섹션으로 바로 가기 반응형 레이아웃은 하나뿐이라 전환할 설정이 없습니다. 데스크톱에서는 사이드바가 기본 탐색 역할을 하고, 좁은 화면에서는 **메뉴 열기**로 같은 페이지 링크를 엽니다. Dashboard의 섹션마다 주소도 있습니다. `#dashboard`는 Overview, `#dashboard/providers`와 `#dashboard/models`는 나머지 두 섹션입니다. 새로고침하거나 북마크해도, 뒤로 가도 보던 섹션이 그대로 유지됩니다. **Logs**도 `#logs`와 `#logs/debug`로 똑같이 동작합니다. 예전 `#providers/workspace` 북마크는 `#providers`로 넘어갑니다. @@ -66,6 +87,22 @@ Overview에는 30일 동안의 요청 및 token 추이를 보여 주는 **30일 **Models** 스위치는 Codex의 최종 노출 상태를 나타냅니다. 라우팅 모델은 프로바이더 allowlist에 포함되거나 allowlist가 없고, 동시에 비활성화되지 않았을 때만 켜집니다. 모델을 켜면 두 필터를 원자적으로 조정하며, **모두 활성화**는 allowlist를 해제해 새로 발견되는 모델도 켭니다. +### 프로바이더 화면에서 모델 관리하기 + +프로바이더의 **모델** 탭에서 **삭제**를 누르면 저장된 커스텀 정의를 지웁니다. 원래 네이티브 모델이나 +라이브 발견 모델이 다시 나타날 수 있으므로 모델 수는 그대로일 수 있습니다. **숨기기**는 카탈로그 노출만 +바꾸며, 정의를 삭제하거나 직접 라우팅 정책을 바꾸지 않습니다. **모델에서 노출 관리**를 누르면 **모델** +페이지에서 다시 표시할 수 있습니다. 프로바이더 탭에 행이 하나도 없어도 이 이동 버튼을 사용할 수 있습니다. + +**추가**는 커스텀 정의를 저장하며, 기존 숨김 상태나 프로바이더 선택 규칙을 해제하지 않습니다. +저장된 모델이 계속 숨겨져 있을 수 있습니다. 이미 등록된 모델은 **모델**에서 노출 상태를 관리하세요. +저장이 확인됐다면 카탈로그 갱신이 실패해도 정의는 저장된 상태입니다. 다시 추가하지 말고 갱신 안내를 +따르세요. 변경 여부를 확인하지 못했다면 모델 상태를 새로고침한 뒤 다시 시도하세요. + +프로바이더의 모델 수는 서버가 반환한 현재 모델 목록에서 비활성 항목을 제외하고 중복 없이 센 값입니다. +검색이나 표시 개수 제한을 적용하기 전에 계산합니다. 허용 목록의 크기나 라이브 발견 개수가 아니며, +업스트림에서 발견한 모델임을 뜻하지도 않습니다. 선택 배지와 발견 정보는 이 개수와 별도로 표시합니다. + ## 위임 선택기와 스폰 라우팅의 차이 Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적인 `injectionEffort`를 @@ -177,7 +214,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 다음 요청에 사용할 계정과 풀 라우팅 정책을 설정합니다. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | 실효 계정(고정 여부를 나타내는 `pinned`와 고정된 계정을 알려주는 `pinnedAccountId` 포함)을 읽고 계정 하나의 선택 순서를 설정합니다. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 브라우저 로그인으로 pool 계정을 추가합니다. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. `limit`/`offset`은 최신 행에서 과거 방향으로 페이지네이션합니다(`offset=0`이 최신 페이지). 응답은 `{ timeZone, total, logs }`이며 `total`은 페이지네이션 전 필터 일치 건수입니다. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | tail, 프로바이더, 정확한 상태 코드 또는 상태 등급으로 최근 요청 메타데이터를 조회합니다. `limit`/`offset`은 최신 행에서 과거 방향으로 페이지네이션합니다(`offset=0`이 최신 페이지). 응답은 `{ timeZone, generatedAt, total, logs }`이며 `total`은 페이지네이션 전 필터 일치 건수입니다. | | `GET` / `PUT /api/subagent-models` | `spawn_agent`에 우선 노출할 모델 5개를 읽거나 설정합니다. | | `POST /api/stop` | 프록시/서비스를 멈추고 네이티브 Codex를 복원한 뒤 종료합니다. Windows 작업 스케줄러 백엔드에서는 `respawnable_service`로, 그 상태를 읽을 수 없으면 `service_state_unknown`으로 거절하며, 두 경우 모두 아무것도 바뀌지 않습니다. | @@ -186,3 +223,11 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 프로바이더 설정에 복사됩니다. 별도 분류 작업 없이도 [비전 사이드카](/ko/guides/sidecars/)가 올바른 조건에서만 실행됩니다. ::: + +### 계정 선택과 자동 전환 + +GUI에서 OAuth 계정을 선택하면 풀 모드에서도 다음 요청에 반영돼요. 일반 OAuth 계정은 +정상적으로 사용할 수 있는 선택 계정을 유지하며, 다른 계정의 남은 할당량이 더 많다는 +이유만으로 바꾸지 않아요. 선택 계정이 429를 반환하면 풀이 꺼져 있어도 사용 가능한 다른 +계정으로 자동 전환해요. 자동 선택이 저장되면 GUI의 활성 표시도 즉시 바뀌어요. +이미 서버로 보낸 요청의 인증 정보는 바꾸지 않아요. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 081c791bbb..4847614674 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -203,6 +203,12 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 유닛, Windows **Task Scheduler**). 로그인 시 자동 시작하고 충돌 시 자동 재시작합니다. 서비스 실행은 `OCX_SERVICE=1`을 설정하므로 재시작해도 Codex 설정이 흔들리지 않습니다. +Windows 작업 스케줄러로 설치하는 서비스는 보통 프로세스 우선순위(`Priority=4`)를 사용합니다. +이전의 백그라운드 우선순위(`7`, 생략 시에도 스케줄러 기본값은 `7`)에서는 CPU 경합으로 상태 확인 응답이 +늦어져 프로세스가 살아 있어도 트레이에 Offline이 표시될 수 있습니다. 업그레이드 후 `ocx service repair`를 +실행하면 등록된 해당 우선순위를 변경하고 서비스를 재시작합니다. 이 과정에서 UAC 승인이 필요할 수 있습니다. +이미 보통 또는 높음 우선순위인 경우 우선순위만을 이유로 다시 등록하지 않습니다. + | 하위 명령 | 동작 | | --- | --- | | 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 새로 고쳐 재시작합니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 710a955894..1049a56b78 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -209,10 +209,11 @@ OAuth 및 API 키 제공자에는 제공자의 할당량 보고 엔드포인트 ### `ocx account auto-switch > [--json]` -`openai` Codex 계정 풀만 제어합니다. `on`은 80%, `off`는 0%를 설정하고, `status`는 현재 값을 읽으며, `threshold `은 0부터 100까지의 정수를 받습니다. 다른 제공자와 잘못된 값은 종료 코드 1로 끝납니다. `--json`은 다음을 반환합니다: +`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 현재 동작에 적용되지 않습니다. 저장해도 임계값 기반 전환이나 제공자 활성화 설정이 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 일반 풀의 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`이면 임계값이 적용되지 않으며, 기능 지원을 알 수 없을 때도 `enabled: true`로 표시하지 않습니다. API 키 제공자, Anthropic 및 잘못된 값은 거부합니다. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 956408135c..d6656f8c16 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -53,7 +53,7 @@ V1 안내는 `max` 또는 `ultra`에서만 선제 텍스트로 제공됩니다. 거부하고 역할을 건너뜁니다 (#1190). TOML의 기존 `model_fallback` 줄은 하위 호환성을 위해 계속 읽히지만 `ocx doctor`가 이를 표시합니다. -opencodex는 비활성, 라우팅 불가, 비정상, 쿨다운 중, 또는 할당량 임계값에 걸린 후보를 건너뜁니다. 사용 가능성 스냅샷은 `subagentModelFallbackPollMs` 동안 캐시됩니다. 암호화된 하위 작업은 정규 네이티브 ChatGPT 대상과 `allowEncryptedV2AgentTasks: true`로 명시적으로 신뢰한 직접 키 인증 Responses 라우트만 후보로 사용합니다. 암호화된 페이로드를 처리할 수 있는 대상이 없으면 읽을 수 없는 암호문을 다른 곳으로 보내지 않고 요청이 실패합니다. 콤보는 계속 정규 네이티브 대상만 사용합니다. +opencodex는 비활성, 라우팅 불가, 비정상, 쿨다운 중, 또는 할당량 임계값에 걸린 후보를 건너뜁니다. 사용 가능성 스냅샷은 `subagentModelFallbackPollMs` 동안 캐시됩니다. 암호화된 하위 작업은 정규 네이티브 ChatGPT 대상과 `allowEncryptedV2AgentTasks: true`로 명시적으로 신뢰한 직접 키 인증 Responses 라우트만 후보로 사용합니다. 암호화된 페이로드를 처리할 수 있는 대상이 없으면 읽을 수 없는 암호문을 다른 곳으로 보내지 않고 요청이 실패합니다. 콤보는 먼저 사용 가능한 정규 네이티브 대상을 시도하고, 선택 가능한 네이티브 대상이 없으며 `agentTaskRecovery`가 켜져 있으면 암호화된 `NEW_TASK`를 라우팅된 콤보 전송 전에 한 번 복구합니다. ```json { diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 82e2ca347d..6e773f0730 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -33,8 +33,9 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `providers` | `Record` | — | 공급자 이름을 공급자 설정에 매핑합니다. | | `openaiProviderTierVersion?` | `2` | 마이그레이션으로 설정됨 | 옵션을 인식하는 단일 OpenAI 투영이 완료되었음을 표시합니다. | | `disabledModels?` | `string[]` | — | Codex catalog와 `/v1/models`에서는 숨기지만 직접 proxy 호출은 차단하지 않습니다. routed id는 목록에서 제거됩니다. account-qualified native id는 해당 selector row만 숨기고, bare native GPT id는 bare row와 그 model의 모든 account-selector row를 숨깁니다. Models 페이지에는 bare native 행과 routed 행만 표시됩니다. selector-qualified 행 하나만 숨기려면 이 설정 필드에 직접 추가하세요. | -| `providerContextCaps?` | `Record` | `{}` | 공급자별 Codex 표시 컨텍스트 상한입니다. 상한은 이미 알려진 컨텍스트 윈도만 낮춥니다. | -| `contextCapValue?` | `number` | `350000` | 대시보드의 컨텍스트 상한 컨트롤이 사용하는 기본값입니다. "모든 라우팅된 공급자에 적용" 토글이 켜져 있을 때만 값을 변경하면 기존 `providerContextCaps` 항목이 없는 공급자를 포함해 모든 라우팅된 공급자에 값이 적용됩니다. 그렇지 않으면 각 공급자는 자체 상한을 유지합니다. | +| `providerContextCaps?` | `Record` | `{}` | 공급자별 활성 컨텍스트 상한입니다. 일반 윈도는 줄어들며, 장문 윈도를 지원하는 네이티브 모델은 해당 모델의 지원 상한까지만 확장할 수 있습니다. | +| `providerContextCapValues?` | `Record` | `{}` | 공급자별로 마지막에 선택한 상한입니다. 꺼도 선택값이 남으며, 저장된 값만으로는 상한이 활성화되지 않습니다. 활성 값이 저장된 선택값보다 우선합니다. | +| `contextCapValue?` | `number` | `350000` | 처음 켤 때 쓰는 기본값입니다. 다시 켜면 공급자별 선택값을 복원합니다. `setAll: true`와 함께 전역 값을 바꾸면 활성 상한만 갱신합니다. 값 없이 `setAll: true`를 보내면 설정된 모든 공급자의 상한을 현재 전역 값으로 켭니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | | `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 계정 한정 선택기 행이 활성화되어 있으면 target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | @@ -88,7 +89,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 키 헤더 형식입니다. 기본값은 네이티브 `x-api-key`이며, 키 인증 `anthropic` 공급자에만 유효합니다. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 다중 키 풀입니다. `apiKey`는 활성 항목을 그대로 반영하며, 각 항목에는 `id`, `key`, 선택적 `label`, 선택적 숫자 `addedAt`가 들어갑니다. | | `defaultModel?` | `string` | 이 공급자를 선택할 때 모델을 따로 지정하지 않으면 사용하는 모델입니다. | -| `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. | +| `models?` | `string[]` | 초기/폴백 모델 목록입니다. `liveModels: false`에서 `models`가 비어 있지 않으면 `models`, `retainModels` 순으로 구성합니다. `models`가 비어 있거나 생략되면 설정된 `defaultModel`, `retainModels` 순으로 구성하고, 중복 ID는 처음 나온 항목만 남깁니다. | | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | | `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | @@ -370,7 +371,16 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 ## 정적 모델 허용 목록 -`liveModels: false`로 두면 `models`만 노출합니다. `models`가 비어 있거나 생략되면 공급자는 어떤 라우팅 모델도 노출하지 않습니다. 라이브 발견은 캐싱 전에 4 MiB 또는 원시 모델 행 2,000개를 넘으면 거부합니다. 내장 프리셋은 더 낮은 한도를 쓰고 chat 가능한 행만 필터링할 수 있습니다. 너무 크거나 형식이 잘못된 결과는 오래된/설정된 폴백을 따릅니다. 유효하지만 선택 가능한 항목이 0개인 결과는 그대로 권위가 있으며, 조용히 다른 값으로 바꾸거나 잘라내지 않습니다. +`liveModels: false`에서 `models`가 비어 있거나 생략되면 초기 목록은 설정된 `defaultModel`, +`retainModels` 순으로 구성합니다. 중복 ID는 처음 나온 항목만 남깁니다. 비어 있지 않은 `models`를 +명시하면 `models`, `retainModels` 순으로 구성하며, 다른 `defaultModel`을 자동으로 추가하지 않습니다. +그 모델도 `models`나 `retainModels`에 직접 넣으면 포함할 수 있습니다. 어느 필드에도 ID가 없으면 +초기 목록은 비어 있습니다. 이 순서는 최종 선택기의 표시 순서를 보장하지 않습니다. +`selectedModels`, `disabledModels`, 공급자 비활성화 정책은 그대로 적용됩니다. +`authMode: "forward"`는 기존 별도 분기를 따르며 이 정적 라우팅 목록을 사용하지 않습니다. +이 규칙은 라이브 발견 실패 시 폴백 동작을 바꾸지 않습니다. + +라이브 발견은 캐싱 전에 4 MiB 또는 원시 모델 행 2,000개를 넘으면 거부합니다. 내장 프리셋은 더 낮은 한도를 쓰고 chat 가능한 행만 필터링할 수 있습니다. 너무 크거나 형식이 잘못된 결과는 오래된/설정된 폴백을 따릅니다. 유효하지만 선택 가능한 항목이 0개인 결과는 그대로 권위가 있으며, 조용히 다른 값으로 바꾸거나 잘라내지 않습니다. `selectedModels`는 발견은 계속하되, 선택된 id만 Codex와 `/v1/models`에 나타나게 하고 싶을 때 사용합니다. 대시보드는 나중에 허용 목록을 바꿀 수 있도록 발견된 전체 목록을 보관합니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index f4e9985ed3..8a97a6a3c1 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -184,3 +184,7 @@ Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprin `runtimeRole` 기본값은 `standalone`입니다. 허브는 `hub.managementPublicOrigin`, 로컬에만 열리는 `hub.managementIngress`(없으면 `enabled:false`), 정확한 `remoteGui.allowedTailscaleUsers`(없으면 빈 목록)를 사용합니다. 클라이언트 데이터 키는 `config.json`이 아니라 `service-api-token`에 저장되며 교체 중에는 `service-api-token.prev`가 잠시 생길 수 있습니다. 사용량 기록은 서로 복제하지 않습니다. `remoteGui.allowInsecureHttp`는 이전 strict-schema 설정을 계속 읽기 위해서만 남겨 둔 폐기된 no-op입니다. 설정에서 제거하세요. 페어링 grant는 loopback 또는 인증된 HTTPS에서만 허용되며, 이 값을 `true`로 설정해도 평문 HTTP 페어링은 다시 활성화되지 않습니다. + +## Codex 할당량 네트워크 진단 + +메인 Codex 계정 행의 `quotaRefresh`는 할당량 조회 결과를 분류하는 진단값입니다. 남은 할당량이나 모델 접근 권한을 뜻하지 않으며, 캐시를 쓰거나 조회하지 않았다면 생략될 수 있습니다. 요청은 명령을 입력한 터미널이 아니라 실행 중인 프록시 서비스의 환경을 따릅니다. `proxy`를 지정하지 않으면 기존 환경을 유지하고, `"auto"`는 시작할 때 Windows의 정적 프록시 설정만 읽습니다. PAC/WPAD, SOCKS 전용 설정과 실행 중 변경은 자동으로 반영하지 않습니다. TUN에서 성공했다고 HTTP 프록시 경로도 정상이라는 뜻은 아닙니다. 명령과 상태값은 [네트워크 진단(영문)](/reference/configuration/server/#codex-quota-network-diagnostics)에서 확인하세요. diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 3e0d16cab3..d7a54f9ff1 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -147,12 +147,15 @@ Authorization: Bearer | `GET /api/models` | 대시보드/CLI model 행을 반환합니다 | 수집이 포화 상태이면 `catalog_busy` | | `GET /api/client-config?client=...` | 지원되는 파일 연동의 읽기 전용 client config를 만듭니다 | 400 지원되지 않는 client; 503 catalog 사용 불가 | | `PUT /api/disabled-models` | 공유 disabled-model 목록을 교체합니다 | 400 잘못된 JSON | -| `PUT /api/model-visibility` | provider 또는 model 수준의 visibility를 원자적으로 변경합니다 | 400 잘못된 provider, scope, target, 또는 본문 | +| `PUT /api/model-visibility` | provider 또는 model 수준의 visibility를 원자적으로 변경합니다 | 400 잘못된 provider, scope, target, 또는 본문; 409 `initial_model_selection_pending` (목록을 새로고침한 뒤 다시 시도하세요.) | | `GET, POST /api/custom-models` | custom model을 나열하거나 하나를 추가합니다 | 400 잘못된 필드; 404 provider 없음; 409 중복 model | | `PUT, DELETE /api/custom-models/{id}` | custom model 하나를 수정하거나 삭제합니다 | 400 잘못된 id/필드; 404 찾을 수 없음; 409 중복 model | | `GET, PUT /api/selected-models` | provider allowlist와 가용성을 읽거나 allowlist 하나를 교체합니다 | 400 provider/body 누락; 404 알 수 없는 provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | 프리셋 정보를 읽거나 preset/all/custom 모드를 선택합니다 | 400 잘못된 mode 또는 지원하지 않는 프리셋; 404 알 수 없는 provider; PUT 409 `initial_model_selection_pending` | +수동 모델은 Models 대시보드에서 provider와 model ID가 같은 행을 대체합니다. OpenAI 수동 행은 `openai/`을 유지하며 표시 여부를 바꿀 수 있습니다. 수동 행을 삭제하면 계정 한정자가 없는 네이티브 행이 다시 나타납니다. 계정 한정자가 있는 네이티브 행은 별도로 유지됩니다. 네이티브 경로나 계정 권한은 바뀌지 않습니다. OpenAI의 비네이티브 표시 대상은 설정된 수동 모델과 일치해야 합니다. + + 신뢰할 수 있는 초기 모델 목록을 확보하기 전에는 유효한 `PUT /api/selected-models`와 `PUT /api/model-presets` 요청도 HTTP 409와 `initial_model_selection_pending` 코드를 반환합니다. `GET /api/models` 등으로 모델 목록을 정상적으로 갱신한 뒤 재시도하세요. ### OAuth 계정, provider key, 데이터 평면 키 @@ -191,6 +194,15 @@ Authorization: Bearer | `GET, PUT /api/provider-context-caps` | 전역, 모든 provider, 또는 하나의 provider context cap을 읽거나 업데이트합니다 | 400 잘못된 요청; 404 알 수 없는 provider | | `GET /api/provider-presets` | 런타임 registry에서 파생된 GUI provider preset을 반환합니다 | — | +컨텍스트 상한 응답에는 `caps`(활성 상한)와 `values`(꺼도 유지되는 마지막 선택값)가 포함됩니다. +`value` 없이 공급자의 상한을 켜면 선택값을 복원하고, 처음 켤 때는 전역 `contextCapValue`를 씁니다. +OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모드가 선택되지는 않습니다. +활성 상한은 모든 네이티브 윈도에 적용됩니다. 장문 컨텍스트를 지원하는 모델은 해당 모델의 지원 상한까지만 +확장할 수 있습니다. `{ "value": 600000, "setAll": true }`는 전역 값과 활성 상한만 갱신합니다. +상한이 꺼진 공급자는 선택값을 유지하고, 나중에 켜면 그 값을 복원합니다. +`value` 없이 `{ "setAll": true }`를 보내면 설정된 모든 공급자의 상한을 현재 전역 값으로 켜고, +저장된 선택값도 바꿉니다. 상한을 꺼도 선택값은 다시 불러온 뒤까지 유지되지만 제한으로 적용되지는 않습니다. + `provider_has_dependent_combos`는 안전 장치입니다. provider를 삭제하기 전에 종속된 combo를 제거하거나 수정하십시오. ### 사이드바 및 동의가 필요한 작업 diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 9c54ed0578..d6d9f4001d 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -27,7 +27,7 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `[DONE]`으로 끝나는 `chat.completion.chunk` SSE | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | 해당 없음 | -| 모델 탐색 | `GET /v1/models` | 세 가지 카탈로그 계약 중 하나 | 해당 없음 | +| 모델 탐색 | `GET /v1/models` | 카탈로그 또는 명시적 Desktop 스냅샷 | 해당 없음 | | Voice and Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | 릴레이된 call-creation 응답 | 별도의 sideband WebSocket이 양방향 프레임을 릴레이함 | | Responses compaction | `POST /v1/responses/compact` | 대체 히스토리 JSON | 해당 없음 | @@ -208,10 +208,17 @@ Responses로 변환되어 일반적으로 라우팅된 뒤, Anthropic JSON 또 { "input_tokens": 123 } ``` +해석되지 않은 날짜형 Desktop ID는 탐색 결과에서 빠진 실제 네이티브 모델일 수도 있습니다. +정보가 부족해 ID를 해석할 수 없으면 Messages와 count-tokens는 고정된 `desktop_model_mapping_unavailable` 오류와 +HTTP 503을 반환합니다. 모델이 잘못됐다는 판정은 아닙니다. 미등록 레거시 해시 별칭은 계속 +HTTP 400을 반환합니다. 두 경우 모두 날짜 제거나 다른 경로로의 폴백은 하지 않습니다. +알려진 ID, 등록된 매핑, 정확한 `modelMap` 일치와 인식된 실제 네이티브 ID의 처리는 유지됩니다. +모델 탐색을 갱신하거나 연결된 허브 프로필을 다시 적용한 뒤 시도하세요. 재시도만으로 +해결된다는 보장은 없습니다. + ## `GET /v1/models` -같은 경로가 서로 호환되지 않는 카탈로그 envelope를 기대하는 세 가지 클라이언트를 모두 처리합니다. -`client_version`이 함께 있지 않으면 Anthropic 형식이 우선합니다. +`format=desktop-config`를 지정하지 않으면 다음 기본 카탈로그 계약을 사용합니다. | 계약 | 트리거 | 최상위 형식 | 모델 id 동작 | | --- | --- | --- | --- | @@ -219,6 +226,29 @@ Responses로 변환되어 일반적으로 라우팅된 뒤, Anthropic JSON 또 | Codex 카탈로그 | `client_version` 쿼리 파라미터 | `{ "models": [...] }` | 네이티브 및 라우팅 항목은 더 풍부한 Codex 카탈로그 필드, 표시 여부, effort, WebSocket, 다중 에이전트 메타데이터를 담음 | | 일반 OpenAI list | 어느 트리거도 아님 | `{ "object": "list", "data": [...] }` | 보이는 네이티브 id는 그대로이며, 라우팅 id는 alias 또는 `provider/model` | +### Desktop 설정 스냅샷 + +`GET /v1/models?ids=desktop&format=desktop-config`는 user-agent와 관계없이 Desktop +스냅샷을 선택합니다. 응답은 `{ "version": 1, "models": [...] }`이며 +`Cache-Control: no-store`를 포함합니다. 연결된 클라이언트는 `Accept: application/json`, +`anthropic-version: 2023-06-01`과 기존 데이터 자격 증명을 보냅니다. 관리자 토큰이나 프로필 +업로드는 필요하지 않습니다. 항목은 Codex 카탈로그 행이 아니라 허브가 발급한 Desktop 설정용 모델입니다. + +이 형식에 `ids=cli` 또는 `client_version`을 함께 보내면 HTTP 400을 반환합니다. 형식 선택자가 +없으면 위의 기본 응답 계약을 유지합니다. Claude가 꺼져 있으면 +`{ "version": 1, "models": [] }`를 반환하며, 연결된 Desktop apply는 사용 불가로 처리하고 +대체 프로필을 쓰지 않습니다. 버전 1 대신 일반 카탈로그를 반환하는 구형 허브는 지원하지 않으며, +클라이언트가 로컬에서 만든 ID로 대신 적용하지 않습니다. + +스냅샷은 읽기 전용 모델 목록이며 키 회전이나 프로필 업로드 API가 아닙니다. 연결된 Desktop의 +키 이전·복구·연결 해제는 기존 클라이언트 수명주기에서 처리합니다. 회전은 모델 항목과 선택을 +유지하며 CLI의 `rotation`은 `committed`와 `rolled_back`을 구분합니다. 연결 해제는 관리 설정을 +복원하거나 확인된 구형 프로필을 표준 모드로 전환하고, 사용자 필드와 이후의 유효한 선택을 +보존합니다. 충돌이나 미완료 복구를 완료로 표시하지 않습니다. 디스크 변경을 읽으려면 Desktop을 +재시작해야 하며, 연결 해제는 허브 키를 자동 폐기하지 않습니다. +[Claude Desktop 안내](/ko/guides/claude-code/)를 참고하세요. thinking 재전송과 프롬프트 캐시는 +별도 [#3719](https://github.com/lidge-jun/opencodex/issues/3719)에서 다룹니다. + ## `POST /v1/live`와 Realtime sideband `POST /v1/live`는 ChatGPT/Codex App Frameless call-creation 표면을 받습니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index e0ea5ffd1a..2f4d41a4e0 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -23,12 +23,31 @@ adapter own retries/timeouts, while `runTurn` supports transports that cannot be HTTP fetch followed by one response stream. [`bridge.ts`](/reference/architecture/#the-bridge) then turns the events into Responses SSE. +## External task input on translated Responses routes + +Codex task coordination can deliver input as `function_call_output` with nonblank +`id`, `name` and `namespace` fields and no `call_id` property. OpenCodex maps this +complete envelope to a user message before adapter translation. Its output must be +nonblank text or a fully supported array of text and `input_image` URL parts. Text +and image order are preserved; image detail `original` maps to `high`. + +Empty content, malformed or opaque parts, file-id-only images and partial envelopes +remain invalid. Ordinary function/custom tool results still require a nonempty +`call_id`. The envelope metadata identifies a compatibility shape and grants no +additional permissions. Native passthrough and compaction retain their raw-body rules. + ## `openai-chat` **Targets:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`; a trailing `/chat/completions` or `/` on `baseUrl` is stripped first) and every compatible provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), and more. **Auth:** `key` (Bearer). +For xAI, the resolved upstream adapter can be `openai-chat` or `openai-responses`, +depending on model defaults and explicit `modelAdapters` overrides. Both support +public xAI API-key authentication and Grok CLI OAuth. The usage log's +[`attempts[].credentialSource`](/reference/management-api/) follows that resolved +transport; it does not infer subscription attribution from the inbound protocol. + - Converts internal messages to OpenAI roles; maps tools to `{type:"function", function:{…}}` and `tool_choice` (`auto`/`none`/`required` or a named function). - **Tool-result images** ride in a follow-up user vision message (`image_url` parts) released once @@ -49,6 +68,15 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), and mor tiers, accepts reasoning deltas from either `delta.reasoning_content` or `delta.reasoning`, requests streamed usage with `stream_options.include_usage`, and reads usage from non-stream response envelopes. +Streaming tool calls retain their identity when a provider first sends an ID, +then associates that ID with an index, and later sends index-only argument +fragments. Those fragments assemble into one call with the original name and +complete arguments; parallel calls retain separate identities. +When present, streamed tool-call indexes must be non-negative safe integers. Non-numeric +values and negative, fractional, or unsafe numbers terminate the stream with an upstream +error before identity matching. Missing and null indexes remain absent-index placeholders; +numeric strings are not coerced. + ## `ollama-native` **Targets:** Ollama's own **Chat API** (`POST /api/chat`) rather than its OpenAI-compatible @@ -95,11 +123,23 @@ body and response, with narrow compatibility rewrites for routed gateways. `forward` uses configured static headers without relaying caller authorization; `key` uses the configured provider key. +Adapter selection does not select the upstream transport. Eligible requests can use the +[upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported +WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's +HTTP proxy rules and does not inherit the WSS-specific `ALL_PROXY` fallback. + Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a collision-safe public function tool. Matching request history and JSON/SSE function calls are translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward keeps the native private type unchanged. +For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other +than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient +metadata. This conversion leaves encrypted or unknown content unchanged and does not apply +to other destinations. Providers using `authMode: "forward"` retain these items unchanged. +See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages) +for the separate opt-in encrypted-task recovery behavior. + The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that its stricter backend rejects: fully textual `system` messages inside `input` are appended to the top-level `instructions` string in request order, and the top-level `truncation` field is removed. @@ -136,6 +176,19 @@ of the HTTP retry loop. ChatGPT account id, and the OpenAI beta/originator/session headers. This is the ChatGPT-login path that also powers the [sidecars](/guides/sidecars/). +## Command Code session affinity + +The OAuth `command-code` adapter derives an opaque `x-session-id` from the client +thread identity, then the reasoning-replay conversation identity. When neither is +available, it uses a prompt-cache key only if the integration has explicitly +classified that key as belonging to one conversation. Shared or unclassified cache +keys do not establish session affinity; requests without a usable identity receive +a fresh session ID. Recovery and cached-history replay preserve this classification. + +The API-key `commandcode` provider uses the `openai-chat` adapter and supports +forwarding `prompt_cache_key`. This is separate from the OAuth adapter's session +header and does not guarantee a provider cache hit. + ## `anthropic` **Targets:** Anthropic **Messages** (`/v1/messages`). @@ -233,6 +286,13 @@ automatically, while operator-customized model lists are preserved. - Builds Kiro `conversationState`, maps Codex tools and tool results, and sends image blocks supported by the Kiro wire. +- Coalesces adjacent outputs from the same original tool call into one Kiro result. Text remains + ordered, images retain the existing per-message limits, and any error flag remains set. User, + developer, assistant or another tool's output ends the group. Distinct original IDs that map + to the same normalized Kiro ID are rejected. +- Combined outputs keep real text and failure information without inserting an empty-output hint + for a later blank chunk. A single result keeps its existing normalization; an entirely text-empty + group receives one fallback, with neutral wording when images or an error flag are present. - Treats a client `parallel_tool_calls: true` value as permission rather than a wire requirement. Kiro remains serialized: the routed catalog advertises no parallel-tool capability and the adapter sends no parallel-control field upstream, but ordinary Codex tool turns are not rejected @@ -395,6 +455,13 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and - `commandCodeVersion` pins `x-command-code-version` (default `0.52.1`). `permissionMode` stays `"standard"` and `mode` stays `"agent"`. - Command Code quota reports separate rolling 5-hour/weekly limits from subscription credits; the dashboard shows credit exhaustion independently, and upstream insufficient-credit messages are preserved as quota errors. +Codex-compatible shell schemas retain sandbox permissions, justification, reusable +prefix rules and login mode. Freeform tools expose one required string `input` +and preserve its tool-specific guidance, such as the required patch envelope; +bare `exec_command` and `shell_command` names are reserved for non-freeform shell +bridges. Namespace a custom freeform tool that uses either name. These schema +declarations do not grant approval or change execution policy. + ## `azure-openai` (alias: `azure`) **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). @@ -425,3 +492,18 @@ Shared helpers used by the vision-aware adapters: Anthropic/Google image blocks. - `contentPartsToText(content)` — flatten content parts to text for text-only tool messages (an undescribed image becomes a short `[image]` marker, never a token-exploding base64 blob). + +## Grok Build terminal snapshots + +Requests marked with `x-opencodex-grok: 1` opt into a narrow Responses terminal +repair. If `response.completed.response.output` is missing or empty, opencodex +can reconstruct it from real, uniquely indexed, contiguous `output_item.done` +items whose raw fields satisfy the supported shapes. Deltas alone do not create +output. Malformed, contradictory, duplicated, gapped or oversized evidence keeps +the empty terminal unchanged; failed and incomplete responses never become success. + +The marker is a client-selected compatibility option, not authenticated identity +or a permission grant. Unmarked clients retain their existing behavior. This +repair runs before the separate provider `responsesSnapshotRepair` option and +does not enable that broader lifecycle repair. Existing tool-search, custom-tool, +function-completion and undeclared-tool handling keep their established order. diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 96a3aec6dc..8e1e361a81 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -200,3 +200,40 @@ The internal model lives in `types.ts`: `OcxParsedRequest`, `OcxContext`, the `O `OcxContentPart` (text / image), `OcxToolCall`, `OcxTool`, `AdapterEvent`, and the config types (`OcxConfig`, `OcxProviderConfig`). Two helpers are widely used: `namespacedToolName()` and `modelInList()` (tolerant `:size`-tag matching for `noVisionModels` / `noReasoningModels`). + + +### Incomplete quota terminals + +A native forward response that ends with quota or rate-limit evidence in an +`incomplete` terminal records account quota failure and spawn-fallback health. +Structured `incomplete_details.reason` and error codes are accepted without a +message; ordinary output-limit, filtering, steering and stall incompletes do not +cool an account. Cyber-policy classification retains precedence. The terminal is +not replayed after output, and fixed-account request selection remains fixed. + +Remote compact requests can buffer their response for longer than the server's +request-idle timeout. That listener timeout is disabled after the request body is +accepted; client cancellation and upstream operation deadlines still apply. + +Buffered routed compaction treats nonempty text and reasoning deltas as progress +without exposing partial summary text. Comments, empty deltas and gateway +keepalives do not reset the adapter-event stall watchdog. The default stall +timeout stays 300 seconds; encrypted compaction content is preserved unchanged. + +Native compact response buffering also enforces a body-byte inactivity deadline +using `stallTimeoutSec` (300 seconds by default). Nonempty chunks reset that +deadline; a stalled body returns HTTP 504, client cancellation retains HTTP 499, +and cleanup does not wait for a stuck upstream cancellation promise. The 32 MiB +response ceiling and the original body bytes are preserved. + +A canonical upstream WebSocket refused-create error can become an HTTP 4xx only +before the response is committed and after stream correlation checks. Permitted +quota headers are bounded and rebuilt without upstream framing headers; the JSON +response is not cacheable. Post-commit and 5xx errors keep the no-resend path. + +When encrypted agent-task recovery refuses a routed task, its existing 400 error +can include a bounded `recovery_reason`: `unsupported_envelope`, +`admission_denied`, `recovery_unavailable`, `caller_cancelled`, or `input_changed`. +The field is omitted when no classified recovery result exists. +`recovery_unavailable` includes cache/singleflight capacity and does not prove an +upstream request was attempted. No retry or broader envelope acceptance is enabled. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 0e91777061..e75a2b6241 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -237,6 +237,12 @@ Run opencodex as a login-managed background service (macOS **launchd**, Linux ** Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash. Service runs set `OCX_SERVICE=1` so a restart does not churn the Codex config. +Windows Task Scheduler installs use normal process priority (`Priority=4`). The older background +priority (`7`, also the scheduler default when omitted) can delay the proxy's health responses under +CPU contention, making the tray report Offline even while the process is alive. After upgrading, +run `ocx service repair` to migrate that registered priority and restart the service. This migration +may request UAC approval; a priority already set to normal or high does not itself trigger replacement. + The Windows wrapper verifies its baked Bun runtime and CLI entry before every start attempt. If an interrupted package update removed either file, it logs one `installation is incomplete` message and stops instead of retrying the same missing executable every five seconds. Reinstall opencodex, then diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 81c4476dd1..0ca2093c8d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -58,6 +58,28 @@ Use `--api-key` or an OAuth login for anything secret. ## Authentication +### Diagnosing missing main-account quota + +`ocx account list openai --quota --refresh --json` includes a `quotaRefresh` object on +the main-account row when that operation attempts a WHAM usage read. The existing +`GET /api/codex-auth/accounts?refresh=1` response exposes the same diagnostic. + +Its `status` is `ok`, `not_reported` (no parseable quota in a successful response), +`http_error`, `timeout`, `network_error`, `invalid_response`, or `internal_error`. +Only `http_error` includes a numeric `httpStatus`. No raw response, error message, +credential, or account identifier is included in this object. Cache-only reads, +credential deferrals, and invalidated account snapshots omit it; older servers +also omit it. Absence is not proof of success. A non-success HTTP status remains +`http_error` even if its error body cannot be read; `timeout` and `network_error` +describe failures before headers or while reading a successful response. + +A valid login does not guarantee that this separate usage request succeeds. +These categories do not change authentication, account selection, or quota +freshness rules, and do not turn unknown quota into zero usage. This diagnostic +currently covers the native main account, not pool-account refreshes. When +reporting missing quota, share the category and HTTP status rather than credential +files or a raw network capture. + ### `ocx login ` Start the provider's registered login flow. OAuth providers open a browser and store auto-refreshed @@ -75,6 +97,16 @@ ocx login xai ocx login anthropic ``` +OAuth reauthentication preserves operator settings such as model selections, pricing overrides, +and account failover preferences. Login-owned transport/authentication fields and registry-owned +catalog metadata are refreshed. A live-discovery provider keeps its selected default model; a +static provider can replace a default that no longer exists in its refreshed catalog. + +For Antigravity, an upstream `401` can refresh the rejected account’s OAuth credential and +retry the request once. The retry uses that credential’s Cloud Code Assist project. If refresh +fails or no usable project is available, the request returns an authentication error; use the +reauthentication flow above. A second `401` does not start another refresh/retry cycle. + A proxy that is already running picks up the new credential without a restart: the CLI asks it to reload that one provider from disk, and the request carries no credential of its own. If the running proxy cannot accept that request — most often because it started from a build that predates @@ -299,12 +331,11 @@ instead (exit 0), matching the dashboard's quota bars. ### `ocx account auto-switch > [--json]` -Controls only the `openai` Codex account pool. `on` sets 80%, `off` sets 0%, `status` reads the current -value, and `threshold ` accepts an integer from 0 through 100. Other providers and invalid values -exit 1. `--json` returns: +Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. Generic pool thresholds are currently inert: saving one does not enable threshold-based switching, change the provider enablement override, or disable reactive 429 rotation. `status` and mutation output for generic pools use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is not applied, and unknown capability never reports `enabled: true`. API-key providers, Anthropic and invalid values are rejected. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 1a5b441dca..a75061a763 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -190,8 +190,9 @@ opencodex skips disabled, unroutable, unhealthy, cooling-down, or quota-threshol availability snapshot is cached for `subagentModelFallbackPollMs`. Encrypted child tasks restrict the chain to canonical native ChatGPT targets plus direct key-auth Responses routes explicitly trusted with `allowEncryptedV2AgentTasks: true`; if none can consume the encrypted payload, the -request fails instead of routing unreadable ciphertext elsewhere. Combo routing remains -canonical-native-only. +request fails instead of routing unreadable ciphertext elsewhere. Combo routing first tries an +available canonical native target; when none is selectable and `agentTaskRecovery` is enabled, +an encrypted `NEW_TASK` is recovered once before routed combo dispatch. ```json { @@ -276,9 +277,13 @@ Enable this only when the additional authenticated request, quota use, plaintext and private-backend dependency are acceptable. Prefer a native ChatGPT child or v1 heterogeneous delegation when they are not. -This recovery path applies to direct-routed children. At most 32 recovery requests can be active at -once; additional misses fail closed. Combo routing keeps its existing native-only filter for -encrypted tasks and does not invoke recovery. +This recovery path applies to direct-routed children and encrypted combo `NEW_TASK` spawns. At +most 32 recovery requests can be active at once; additional misses fail closed. A combo with an +available canonical native target still sends ciphertext directly; recovery runs only when no +native target is selectable. After a stored Pool account's refresh and same-account replay are +exhausted, recovery can use the incoming caller credential for one available routed target without +trying another native account. Policy refusals remain terminal. Failed recovery, exhausted targets, +or unavailable targets still fail closed without forwarding ciphertext to a routed provider. ## Effort caps diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2474d65cc9..5dc2262363 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -28,8 +28,9 @@ After GUI registration or OAuth login, the confirmation dialog lets you open the | `providers` | `Record` | — | Map of provider name to provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | | `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. A routed id is removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. The dashboard Models page exposes only routed and bare native rows; use this configuration field directly to hide one selector-qualified row. | -| `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers a known context window. | -| `contextCapValue?` | `number` | `350000` | Default value used by the dashboard context-cap controls. Changing it applies the value to every routed provider — including providers without an existing `providerContextCaps` entry — only when "apply to every routed provider" is toggled on; otherwise each provider keeps its own cap. | +| `providerContextCaps?` | `Record` | `{}` | Active provider context limits. Ordinary windows are lowered; native models with a supported long window can expand only up to their own supported ceiling. | +| `providerContextCapValues?` | `Record` | `{}` | Last selected provider limits, retained while disabled. These values do not activate a cap. An enabled value takes precedence over a remembered value. | +| `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | | `codexQuotaAutoRefresh?` | `Record` | `{}` | Per-Codex-login-account opt-in for automatic `fiveHour` and `weekly` window activation in Pool mode; Direct mode does not run this worker. In Providers/Codex Auth **Advanced settings**, one control enables or disables both supported windows across all current main and added accounts. New accounts are not opted in automatically. Enable skips windows absent from live WHAM data; disable also clears stale enabled windows. The UI reuses granular `/api/settings` writes, reconciles partial failures, and retries the original ON/OFF intent without replacing unrelated settings or completed reset markers. The API still rejects enabling an unavailable window with HTTP 409. At a reported reset time, opencodex sends one minimal non-stored Codex message using that account's quota and persists the activated timestamp. This does not apply to API-key providers. | @@ -139,7 +140,7 @@ predictions. Explicit provider/model price overrides still take precedence. | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | -| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | +| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, a nonempty `models` list is followed by `retainModels`; an empty or omitted `models` list instead seeds `defaultModel` (if configured), then `retainModels`, removing duplicate ids in first-seen order. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | | `retainModels?` | `string[]` | Ids kept in the catalog even when live discovery omits them. They need not be repeated in `models`. Empty or omitted keeps today's behavior. | @@ -203,6 +204,14 @@ predictions. Explicit provider/model price overrides still take precedence. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +Custom-model `reasoningEfforts` normally override discovered provider metadata. The bounded +exception is an explicit Astra or Daybreak custom row on the canonical `openai` Codex-forward +destination: its advertised list is intersected with that model's pinned native capabilities. +An explicit empty list remains empty with no default; a nonempty incompatible list falls back +to the native default as a single choice. Defaults must belong to the final list. This changes +the catalog projection, not stored configuration or arbitrary gateway models sharing a GPT name. +See [custom native catalog examples](/guides/codex-app-models/). + ### Discovered model display names Use `modelDisplayNames` when a provider returns machine friendly ids but the Codex model picker @@ -771,8 +780,16 @@ container usually has no unlocked keychain session, so requests would fail close `${ENV_VAR}` reference in the service environment there instead. Env references are left untouched by `store`. -Set `liveModels: false` to expose only `models`. If `models` is empty or omitted, the provider exposes -no routed models. Live discovery rejects more than 4 MiB or 2,000 raw model rows before caching; +With `liveModels: false`, an empty or omitted `models` list seeds the configured `defaultModel` +first, followed by `retainModels`; duplicate ids are removed while preserving first occurrence. +A nonempty explicit `models` list instead seeds `models` followed by `retainModels`, without +implicitly adding a different `defaultModel`. That default can still be listed explicitly in +`models` or `retainModels`. If none of these fields supplies an id, the static seed is empty. +This is seed order, not a promise of final picker order. `selectedModels`, `disabledModels` and +provider-disabled policy still apply. `authMode: "forward"` keeps its separate branch and does +not use this routed static seed. These rules do not change live-discovery failure fallback. + +Live discovery rejects more than 4 MiB or 2,000 raw model rows before caching; built-in presets may use lower limits and filter to chat-eligible rows. Oversized or malformed results follow stale/configured fallback. A valid zero-eligible result remains authoritative and is not silently replaced or truncated. @@ -843,3 +860,51 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "visionSidecar": { "enabled": true } } ``` + +## OpenCode Go reasoning efforts + +Go catalog rows preserve their configured reasoning efforts exactly, including during +catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. +Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted +upstream values. Key these per-provider maps by upstream model ID, not the routed +`opencode-go/` catalog slug. For example, a configured `["high", "max"]` list +remains exactly those two choices; a configured `["high", "xhigh"]` list does not gain `max`. +See the [OpenCode Go model list](https://opencode.ai/docs/go/#models) for the current roster. +A configured subset can exclude the lower tiers. Other providers retain their existing behavior. + +For a native-first picker, include native ids in `modelPickerOrder` followed by the +routed ids. This orders the complete picker while preserving OpenCodex's separate natural-priority +guidance calculation. Native Codex's advertised five follow picker priority and may change; +exact-name override eligibility is not limited to that advertisement. Routed-only orders keep +their previous behavior. See the +[ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). +`modelDisplayNames` on a provider controls readable labels without changing wire ids. + +## OpenCode Go session and agent messages + +With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and +base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items +become user messages when `authMode` is not `"forward"` (for example, `"key"`). +Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including +renamed provider entries; other Responses destinations keep their input unchanged. +Author and recipient remain explicit text metadata, and the content parts are preserved. +Encrypted and unknown content is not normalized; native encrypted tasks still require the +separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). + +With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only +after validating the caller and matching the parent-thread scope. Replay restoration +does not make a new recovery request or extend cache expiry. Expired or unseen +ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same +opt-in recovery path, including native-parent `send_message` delivery. Message type, +sender, recipient, parent scope and caller credentials remain part of validation or cache identity. + +When a request contains several agent messages, cached replay restoration checks each +message independently. The cache separates message type, sender, recipient and ciphertext +within the admitted caller/account and parent scope. Fresh recovery only handles the +current tail message (ignoring trailing `compaction_trigger` or `additional_tools` metadata). +It does not batch-recover unseen historical messages; those remain unchanged. A cache miss +or expiry does not extend the history-recovery contract. + +Sender and recipient on Go Responses are context for the receiving model, not a new +machine-readable routing protocol. Tool routing continues to use the existing collaboration +contracts. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 0d425d37ec..d8873cb68b 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -51,6 +51,56 @@ If an older development build changed resume-history metadata before backup supp It force-relabels every user-message `opencodex` row, including legitimate dedicated-provider history; review the full-scope warning in the lifecycle reference before running it. +## Codex quota network diagnostics + +The main Codex account row may include `quotaRefresh` when a quota fetch was +attempted. This describes that fetch, not remaining quota, model access or +permission to retry. Cached reads and rows without a fetch may omit it; absence +does not mean success. A `null` quota value means unavailable, not zero quota. + +To request fresh data and display only the diagnostic in PowerShell: + +```powershell +$quotaReport = ocx account list openai --quota --refresh --json | ConvertFrom-Json +$quotaReport.accounts | + ForEach-Object { if ($_.quotaRefresh) { $_.quotaRefresh } } | + ConvertTo-Json -Depth 3 +``` + +If no diagnostic is present, this projection produces no diagnostic object. Share +only these fields when comparing network modes, rather than the full account list. + +| `quotaRefresh.status` | Meaning | +| --- | --- | +| `ok` | The fetch completed and a quota object was parsed. | +| `not_reported` | The response contained no usable quota object. | +| `http_error` | The upstream returned an HTTP failure; `httpStatus` contains its status code. | +| `timeout` | The quota fetch timed out. | +| `network_error` | The request failed before a classified HTTP response. | +| `invalid_response` | The response was not a usable quota document. | +| `internal_error` | An internal refresh step failed. | + +Only `http_error` includes `httpStatus`. Other statuses do not imply HTTP 0 or an +account entitlement problem. + +### Which proxy path is used? + +The running proxy service fetches quota. It uses its own environment, not the +interactive shell that later runs `ocx account list`. Configure the service's +proxy setting or environment, then restart it; changing variables in another +terminal does not update an already running service. + +An unset `proxy` leaves inherited proxy variables unchanged. An explicit HTTP(S) +proxy URL fills `HTTP_PROXY` and `HTTPS_PROXY` only where they are unset. +`"proxy": "auto"` reads the Windows static WinINET proxy once at startup; existing +proxy environment variables take precedence. Auto discovery does not resolve +PAC/WPAD, SOCKS-only settings or live proxy changes. Use a supported static HTTP +proxy setting or an explicit HTTP(S) proxy URL when needed. + +Compare the diagnostic on the same machine and account under the two network +modes. A successful TUN test alone does not identify why the service's HTTP proxy +path failed, and does not establish a general fix. + ## Remote access The default `127.0.0.1` bind is loopback-only. A non-loopback address such as `0.0.0.0` requires @@ -372,6 +422,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | --- | --- | --- | --- | | `claudeCode.bodyStallSec?` | `number` | `90` | Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly `0` disables. | | `claudeCode.bodyMaxBytes?` | `number` | `67108864` | Cumulative native-passthrough body cap for streamed and buffered responses. Exactly `0` disables. | +| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional compatibility admission for translated `/v1/messages` requests. `shadow` records unsupported features and continues; `enforce` returns an Anthropic-shaped 400 before inference. Native Anthropic passthrough remains unchanged. | | `claudeCode.authMode?` | `"proxy" \| "subscription"` | auto | How launch handles `ANTHROPIC_AUTH_TOKEN`. Auto detects auth each launch; an explicit value is never overridden. | | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | @@ -380,6 +431,25 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | `claudeCode.compatibility?` | `"shadow" \| "enforce"` | `enforce` | Compatibility gate for routed Claude ingress: `enforce` rejects unsupported requests before upstream activity with `400 invalid_request_error`; `shadow` records ordinary incompatibilities without rejecting, but signed-thinking ownership and other safety invariants still fail closed. | +The compatibility policy applies to Claude Code, Desktop and other clients using translated +Messages, including `?beta=true` and non-streaming requests. Every translated target uses the +same conservative policy, including Anthropic and native Responses adapters. It rejects +document content, thinking/redacted-thinking replay, hosted search and execution tools, +tool-search references, active deferred loading, strict tools, non-default caller modes, +structured-output formats, explicit service-tier intent, MCP connector features, context +management, containers, inference placement and unsupported protocol fields or blocks. + +Unset preserves legacy translation. Cache hints, tool input examples and ordinary +thinking/effort settings are deliberately admitted with possible degradation: this setting +does not guarantee cache breakpoints or TTL, retained examples, exact thinking budgets or +lossless translation. Beta headers alone are not validated for feature support. Shadow +evidence contains only fixed protocol codes and derived reasons, retained in request logs +and `usage.jsonl` and restored on restart. An invalid non-unset mode returns a fixed 503 +configuration error on translated Messages. Configure the value in `config.json` and +restart the proxy to load it; there is no dedicated GUI setter. Count-tokens and direct +Responses/Chat APIs are outside this policy; successful token counting does not imply +Messages admission. This setting does not add a global authorization boundary. + Auto auth selects subscription when stored Claude auth is found, proxy when none is found, and subscription with a warning when detection is inconclusive. See [Claude Code auth mode](/guides/claude-code/#auth-mode). diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 2546468b0d..8273af6d4b 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -166,7 +166,47 @@ the fallback for ciphertext produced elsewhere. A first upgrade retires legacy c | `DELETE /api/client-integrations/journal?opId=...` | Retire one older rollback operation and remove its snapshot when possible. Success returns `snapshotRemoved`; `false` means cleanup was retained for maintenance retry. | 400 missing `opId`; 404 missing or already retired operation; 409 newest operation for that client | Deletion appends a tombstone instead of rewriting the journal. The newest operation for each client -is protected server-side so the current undo point remains available. +is protected server-side so the current undo point remains available. For Aside, protection is +per profile, and journal rows include `profileId`. + +### Aside profile controls + +Use these dedicated paths with a compatible running proxy. `{profileId}` is a registered +nonnegative integer account ID returned by the profile list; it is not a browser path. + +| Method and path | Purpose | Notable errors | +| --- | --- | --- | +| `GET /api/client-integrations/aside/profiles` | List `profiles[]`, desired `enabledCount`/`allEnabled`, actual `appliedCount`, and `total` | HTTP 200 may contain an empty, unsafe aggregate with an `error` when discovery is unavailable | +| `PUT /api/client-integrations/aside/profiles` | Set every registered profile's desired state and apply it; body `{ "enabled": true }`, with optional `overwriteConflict` when enabling | 400 invalid body; 409 operation busy; 500 preference-save failure; 207 per-profile refusals | +| `GET /api/client-integrations/aside/profiles/{profileId}` | Read one profile's desired `enabled` and actual integration status | 400 invalid ID; 404 unregistered profile | +| `PUT /api/client-integrations/aside/profiles/{profileId}` | Change one profile with the same body as bulk PUT, leaving sibling preferences unchanged | 400 invalid body/ID; 404 unknown profile; 409 busy or refusal; 500 save/write failure | +| `GET /api/client-integrations/aside/profiles/journal` | List history across registered Aside profiles | Rows include `profileId`, snapshot availability, `undoable`, and `deletable` | +| `GET /api/client-integrations/aside/profiles/{profileId}/journal` | List one profile's history, including matching legacy operations | 400 invalid ID; 404 unknown profile | +| `DELETE /api/client-integrations/aside/profiles/journal?opId=...` | Retire an older operation, resolving its profile from history | 400 missing ID; 404 missing operation; 409 newest operation for its profile | +| `DELETE /api/client-integrations/aside/profiles/{profileId}/journal?opId=...` | Retire an older operation belonging to the selected profile | Same deletion errors; an operation cannot target a different profile | +| `POST /api/client-integrations/aside/profiles/{profileId}/restore` | Undo an operation using `{ "opId": "..." }`; optional `confirmDrift: true` permits replacing later edits | 404 missing operation/profile; 409 busy, mismatch, or required drift confirmation; 410 expired snapshot; 500 save/write failure | +| `POST /api/client-integrations/aside/sync` | Refresh enabled profiles through the server's mutation owner; body `{}` | 400 nonempty body/profile selector; 409 busy; 207 per-profile refusals | + +Bulk PUT returns `{ ok, clientId, changed, state, message, results }`; each result identifies +its `profileId` and reports the writer outcome. Sync returns `{ ok, clientId, results }`, with +per-profile refresh outcomes. Both return HTTP 200 when all returned attempts succeed and +HTTP 207 with `ok: false` when any attempt refuses. HTTP 207 is a partial-result envelope, +including when every attempted profile refuses: inspect each result rather than treating a +2xx response as complete success. A successful no-op can have `changed: false`; sync does not +attempt disabled profiles. Single-profile writes and restores return HTTP 200 on success or +the corresponding error status on refusal. + +Explicit changes save desired preferences before writing files. A preference-save failure +leaves profile files unchanged. A later file refusal preserves saved intent and successful +sibling writes; inspect the affected profile before retrying. Refusals may include +`snapshotPath` and `residual: true` when recovery did not finish. Restore also reconciles the +target profile's desired state, so the next sync does not reverse Undo. Deletion returns +`snapshotRemoved`; `false` means snapshot cleanup still needs maintenance. + +The legacy `GET, PUT /api/client-integrations/aside` aliases remain available. New clients +should use the dedicated paths above so an older proxy cannot ignore a profile selector. +See [Aside profile controls](/guides/integrations/#aside-profile-controls) for CLI commands and +the proxy upgrade, restart, and retry sequence. ### Combos @@ -198,6 +238,14 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou ### Logs, usage, and storage +`GET /api/logs` accepts an optional opaque `cursor` from its previous response. The envelope preserves +`logs`, `total`, `generatedAt` and `timeZone`, and adds `cursor` and `reset`. Without a cursor it returns +the full filtered window. A valid unchanged prefix returns only appended rows; `reset: true` replaces +the client window after edits, eviction, query changes or restart. Invalid cursors return HTTP 400 with +`error.code: "invalid_cursor"`. Authentication is unchanged. The dashboard falls back to full snapshots +for older servers. This reduces response bytes for stable windows; server projection remains bounded +by the current window size. + | Method and path | Purpose | Notable errors | | --- | --- | --- | | `GET /api/logs` | Query filtered in-memory request logs | — | @@ -217,6 +265,14 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable | +New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth` +for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key +transport. This fixed label contains no credential or account identifier. It belongs to +each item in `attempts`, so a combo's aggregate token total must not be attributed to its +final provider. Custom destinations and historic rows omit the field; consumers must not +infer subscription usage from the current configuration, model name, or inbound API key. +The log reports usage, not subscription invoice amounts. + `GET /api/usage` reads `~/.opencodex/usage.jsonl` from the beginning through the current ledger snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather than every normalized request row. Later refreshes validate the previous line boundary and fold only @@ -255,6 +311,10 @@ Storage cleanup endpoints can move or permanently remove archived session data. first and submit the returned digest. Prefer quarantine when recovery may be needed. ::: +Cleanup recovery manifests are published atomically, preserving the previous complete record +if a replacement fails before publication. This does not reverse a permanent purge: restore +can still fail when a recorded session has no surviving rollout file. + ### Models and catalog | Method and path | Purpose | Notable errors | @@ -263,14 +323,31 @@ first and submit the returned digest. Prefer quarantine when recovery may be nee | `GET /api/models` | Return the dashboard/CLI model rows | `catalog_busy` when gathering is saturated | | `GET /api/client-config?client=...` | Build a read-only client config for any supported file integration | 400 unsupported client; 503 catalog unavailable | | `PUT /api/disabled-models` | Replace the shared disabled-model list | 400 invalid JSON | -| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body | +| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body; 409 `initial_model_selection_pending` (refresh the model list and retry) | | `GET, POST /api/custom-models` | List custom models or add one | 400 invalid fields; 404 provider missing; 409 duplicate model | | `PUT, DELETE /api/custom-models/{id}` | Edit or delete one custom model | 400 invalid id/fields; 404 not found; 409 duplicate model | | `GET, PUT /api/selected-models` | Read provider allowlists and availability, or replace one allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Read preset summaries or choose preset/all/custom mode | 400 invalid mode or unsupported preset; 404 unknown provider; PUT 409 `initial_model_selection_pending` | +A manual model replaces the Models dashboard row with the same provider and model ID. +For OpenAI, the manual row keeps `openai/` and supports the same visibility controls +as other routed models; removing it restores the bare native dashboard row. Explicit +account-qualified native rows stay separate. This does not rename bare native routes or +change account entitlements. Non-native OpenAI visibility targets must match a configured +manual model. + Valid PUT requests to `/api/selected-models` and `/api/model-presets` return HTTP 409 with code `initial_model_selection_pending` until a reliable initial model list is available. Refresh model discovery (for example, `GET /api/models`) and retry after it succeeds. +Successful visibility/selection writes to `/api/disabled-models`, `/api/model-visibility`, +`/api/selected-models`, and `/api/model-presets` report follow-up outcomes in `catalogRefresh` +and `clientIntegrations` when that refresh path runs. HTTP 200 and `ok: true` confirm the +selection save; they do not guarantee every client catalog updated. Inspect +`clientIntegrations[]` for `ok: false`, `client`, optional Aside `profileId`, and the refusal +`reason`; recovery details may also include `refusalReason`, `snapshotPath`, and `residual`. +The Models page keeps the saved selection and shows a separate client-refresh warning. +Inspect Integrations and resolve the reported issue before retrying `ocx sync`. Missing +outcome fields from an older server do not establish successful recovery. + ### OAuth accounts, provider keys, and data-plane keys | Method and path | Purpose | Notable errors | @@ -309,6 +386,18 @@ keys are not returned to dashboard clients. | `GET, PUT /api/provider-context-caps` | Read or update global, all-provider, or one-provider context caps | 400 invalid request; 404 unknown provider | | `GET /api/provider-presets` | Return GUI provider presets derived from the runtime registry | — | +The provider context-cap response includes `caps` (active limits) and `values` (last selected +values, retained while disabled). Enabling a provider without `value` restores its selection, +or uses the global `contextCapValue` on first enable. This also applies to OpenAI: the switch +does not select a special 922k mode. An active cap bounds every native window; models with a +supported long-context window may expand only up to their own supported ceiling. +Updating the global value with `{ "value": 600000, "setAll": true }` changes only enabled +provider caps; disabled providers keep their remembered selections when later enabled. +In contrast, `{ "setAll": true }` without `value` enables every configured provider at the +current global value, replacing their remembered selections. Turning a cap off does not +activate its remembered value or erase the selection. + + `provider_has_dependent_combos` is a safety barrier: remove or edit the dependent combos before deleting their provider. @@ -330,7 +419,7 @@ whether to star the repository. | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps; raw errors and paths are never returned. | — | +| `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — | | `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection | Returns 202; repeated calls report the existing drain | | `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | | `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index de7f5403f6..7008194e19 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -28,7 +28,7 @@ should select among several targets. | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE ending in `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Not applicable | -| Model discovery | `GET /v1/models` | One of three catalog contracts | Not applicable | +| Model discovery | `GET /v1/models` | Catalog or explicit Desktop snapshot | Not applicable | | Voice and Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | Relayed call-creation response | A separate sideband WebSocket relays frames in both directions | | Responses compaction | `POST /v1/responses/compact` | Replacement-history JSON | Not applicable | @@ -52,66 +52,6 @@ non-empty `model`. `input` may be a string or an array of Responses items. | Service and execution | `stream`, `service_tier`, `parallel_tool_calls`, `instructions`, `metadata`, and `user` | | Extended Responses fields | `background`, `include`, `prompt`, `text`, and `truncation` are accepted for compatible routes | -### Google provider options - -Responses requests may opt into a strict Google GenerateContent extension under -`provider_options.google`: - -```json -{ - "model": "gemini-3.7-flash", - "input": "Explain this result", - "provider_options": { - "google": { - "thinking_budget": 4096, - "include_thoughts": false, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_MEDIUM_AND_ABOVE" - } - ], - "cached_content": "cachedContents/my-cache" - } - } -} -``` - -The accepted keys are exactly `thinking_budget`, `include_thoughts`, -`safety_settings`, and `cached_content`; unknown keys at either nested level, -or unknown safety-setting keys, fail request validation. The parser maps these -snake-case request fields to typed internal fields; they are not arbitrary -provider passthrough data. - -- `thinking_budget` must be a safe integer greater than or equal to `-1`. - An explicit budget takes precedence over the routed model's derived thinking - level. `include_thoughts` augments the resulting thinking configuration, and - an explicit `false` is preserved. -- `safety_settings` accepts at most 16 entries, with no duplicate categories. - Categories are `HARM_CATEGORY_HATE_SPEECH`, - `HARM_CATEGORY_SEXUALLY_EXPLICIT`, `HARM_CATEGORY_DANGEROUS_CONTENT`, - `HARM_CATEGORY_HARASSMENT`, `HARM_CATEGORY_CIVIC_INTEGRITY`, and - `HARM_CATEGORY_JAILBREAK`. Thresholds are - `HARM_BLOCK_THRESHOLD_UNSPECIFIED`, `BLOCK_LOW_AND_ABOVE`, - `BLOCK_MEDIUM_AND_ABOVE`, `BLOCK_ONLY_HIGH`, `BLOCK_NONE`, and `OFF`. -- `cached_content` must be exactly one of these Google resource-name forms: - `cachedContents/{id}` for AI Studio, or - `projects/{project}/locations/{location}/cachedContents/{cachedContent}` for - Vertex. Each segment must be non-empty; whitespace, query strings, fragments, - and extra segments are rejected. - -The extension is supported only when the final route uses the Google adapter in -AI Studio or Vertex mode. Cloud Code Assist (including the -`google-antigravity` provider) and every non-Google route are rejected with a -400 before an upstream request is made. This check is applied after routing and -adapter overrides are resolved, including retry and fallback paths. - -`cached_content` opts into reuse of a provider-side Google cache that already -exists; it is not a local prompt-cache key. The resource name identifies -provider-managed content, so use it only when the caller is authorized to reuse -that content and accepts Google's retention and access policies. opencodex does -not create, inspect, or delete the provider cache through this field. - Unknown item types are accepted as loose typed items for forward compatibility. Translated adapters handle only the item types they recognize, and may reject a feature their provider cannot represent. On the canonical ChatGPT Codex forward route, text-only `system` input messages are folded into @@ -135,11 +75,7 @@ With `stream: true`, the response is `text/event-stream`. The bridge emits Respo `data: [DONE]`. With `stream: false` or no `stream`, the same adapter events are collected into one Responses JSON -object. The canonical ChatGPT Codex backend itself requires `stream: true`, so opencodex sends that -route as SSE and reconstructs a bounded JSON response for the non-streaming client. Reconstruction -retains indexed `response.output_item.done` records because the terminal snapshot can omit its -`output` array. Both client-facing forms preserve the selected model, output items, terminal status, -and usage. +object. Both forms preserve the selected model, output items, terminal status, and usage. For native HTTP/SSE passthrough, a client cancellation without an observed upstream terminal is logged as `499` with `closeReason: "client_cancel"` and does not penalize the account pool. @@ -147,6 +83,11 @@ This applies to both tee inspection and eager relay, including Windows rewrite t even when the upstream read rejects before the response-body cancellation hook runs. A terminal captured during the bounded post-disconnect drain retains its actual outcome. +If native passthrough rewriting fails, including when it exceeds the translation +buffer budget, the relay reports the failure without waiting for upstream inspection +to finish. It cancels the upstream work and emits `response.failed` followed by +`data: [DONE]`; a budget overflow uses the `translation_buffer_limit` error code. + Client-facing Responses SSE frames are limited to 4 MiB per frame, measured in raw bytes before the SSE block delimiter. On HTTP, an unterminated upstream frame that exceeds the limit fails closed with a synthetic `response.failed` event followed by `data: [DONE]`. On the Responses WebSocket @@ -154,13 +95,41 @@ bridge, the same condition emits a 502 `websocket_protocol_error` and cancels th A complete Responses terminal frame is authoritative: oversized or malformed trailing bytes after that terminal are dropped rather than replacing the completed turn with a transport failure. -For canonical ChatGPT forward streaming, stable Bun 1.4.0 or newer may use Codex's upstream -WebSocket transport when `wsUpstream: true` is set, or when `OCX_CODEX_WS_UPSTREAM=true`/`1` is -set with no provider override. Omitted, invalid, and explicit false values stay on HTTP/SSE. -Bundled Bun 1.3.14, prereleases, and unverifiable runtime identities also use HTTP/SSE. The -upstream WS adapter keeps the same downstream SSE contract, caps both the raw JSON frame and its -SSE envelope at 4 MiB, and closes the upstream when its 8 MiB byte queue would overflow. That -overflow emits a terminal downstream `response.failed` event followed by `[DONE]`. +:::note +For native passthrough, a Responses terminal event is authoritative. A premature `data: [DONE]` is +held until that event. On the ordinary native path, a clean HTTP 200 EOF without a parsed terminal +emits one `response.incomplete` with `incomplete_details.reason: "adapter_eof"`, followed by one +`data: [DONE]`; syntactically valid delimiter-less terminal JSON is accepted exactly once, while +malformed or truncated JSON remains incomplete. For providers opted into model-scoped terminal +repair, unframed terminal-like suffixes and a premature `data: [DONE]` at EOF fail closed with +`missing_terminal_event` when no complete lifecycle candidate can be promoted; a complete candidate +is promoted to `response.completed`. High-confidence `cyber_policy` +terminal shapes normalize to `response.failed` with `error.code: "cyber_policy"` for semantic +logging/accounting (status 400), while an already-started streamed HTTP response remains 200. This +committed-request boundary does not retry or replay and does not resolve +[#2423](https://github.com/lidge-jun/opencodex/issues/2423) or +[#2486](https://github.com/lidge-jun/opencodex/issues/2486). +::: + +For canonical ChatGPT forward streaming, stable Bun 1.4.0 or newer may transparently use +Codex's upstream WebSocket transport. Bundled Bun 1.3.14, prereleases, and unverifiable runtime +identities use HTTP/SSE. The upstream WS adapter keeps the same downstream SSE contract, caps both +the raw JSON frame and its SSE envelope at 4 MiB, and closes the upstream when its 8 MiB byte queue +would overflow. That overflow emits a terminal downstream `response.failed` event followed by +`[DONE]`. + +The upstream WebSocket checks `NO_PROXY`/`no_proxy` first. Otherwise it uses the first non-empty +`HTTPS_PROXY`, `https_proxy`, `ALL_PROXY`, or `all_proxy` value; `HTTP_PROXY` alone does not proxy a +WSS connection. HTTP and HTTPS proxy URLs are passed to Bun. If the selected value is invalid or +uses an unsupported protocol, opencodex skips the WebSocket attempt and uses HTTP/SSE instead of +dialing the upstream directly. + +These rules belong to the upstream WebSocket transport, independently of the selected provider +adapter. HTTP fetch-based Responses requests, including SSE fallback, use Bun's HTTP proxy rules +and do not use `ALL_PROXY`. `config.proxy` fills missing `HTTP_PROXY`/`HTTPS_PROXY` values; the +resulting scheme-specific value also takes precedence over an existing `ALL_PROXY` for WebSocket. +For an HTTPS upstream that requires a proxy, set `HTTPS_PROXY` or `config.proxy`; `HTTP_PROXY` +alone leaves both WSS and its HTTPS fallback without a scheme-matched proxy. Every terminal Responses usage object includes both detail objects, even when the provider did not report those details: @@ -269,25 +238,40 @@ effort default to `reasoning.summary: "auto"` so thinking streams back as `reasoning.summary: "none"`. An explicit `reasoning.summary` of `auto`, `concise`, `detailed`, or `none` wins over `include_reasoning`. -Structured output is part of that translation. `response_format` with `json_object` or -`json_schema` is forwarded to routed `openai-chat` models, subject to the provider's -`noStructuredOutputModels` opt-out: listed models omit `response_format`, while sibling models -keep it. Routed Google models lower supported requests to Gemini JSON mode -(`responseMimeType` / `responseSchema`), but skip that lowering when the request has tools, the -selected model is Claude, or the model is image-capable. Kiro rejects structured output. -Cursor has no structured-output wire field and rejects before transport. - -On `POST /v1/responses`, the equivalent request field is `text.format`: native Responses routes -preserve it in the raw Responses body, and it is translated to `response_format` when the model -routes to an `openai-chat` provider. Adapter behavior is capability-specific: an adapter may -forward, skip, ignore, or reject a feature according to its implementation, rather than every -unrepresentable feature failing closed. +Structured output is part of that translation: `response_format` with `json_object` or +`json_schema` is forwarded to routed `openai-chat` models. On `POST /v1/responses` the +equivalent request field is `text.format`: native Responses routes preserve it in the raw +Responses body, and it is translated to `response_format` when the model routes to an +`openai-chat` provider. A model listed in the provider's `noStructuredOutputModels` omits +`response_format` on that chat wire; sibling models keep the translation. Unclassified backends +receive the field and return their own error instead of the proxy guessing their capability. Non-streaming output has `object: "chat.completion"`. Streaming output uses SSE objects with `object: "chat.completion.chunk"`, choice deltas, a terminal choice with `finish_reason`, and `data: [DONE]`. Tool-call and usage information are translated back where the source events carry them. +If a streaming Chat request receives a complete JSON Responses result upstream, the proxy +synthesizes SSE from the converted completion. It preserves answer and reasoning content, +function tool calls (with a separate stream `index` for each call), usage, and the converted +`finish_reason`, including `tool_calls` and `length`. This fallback delivers the completed result +in chunks; it cannot provide token-by-token delivery before the upstream JSON response arrives. +It does not issue an additional inference request. An incomplete response caused by the output +token limit or content filtering retains `length` or `content_filter`, even if it includes tool +output. Other incomplete boundaries return an upstream error instead of claiming a normal finish. + +Refusal text stays separate from answer text: JSON completions use nullable `message.refusal`, +and streaming chunks use `delta.refusal`. Native Chat JSON-to-SSE and SSE-to-JSON conversions +preserve that field; native streaming relay preserves the provider's refusal deltas. On translated +Responses streams, refusal parts are buffered until the terminal event and emitted once in their +original output/content order. Compatible repeated or sparse snapshots do not duplicate or erase +text. Contradictory refusal snapshots and buffer overflow produce a typed error without a successful +finish or `[DONE]`. This preserves the upstream refusal; it does not introduce a proxy policy decision. + +Because the internal execution path is Responses-based, a provider adapter can impose a narrower +feature set. For example, a request feature that cannot be represented by the selected adapter is +returned as an error instead of silently changing its meaning. + ## `POST /v1/messages` and `count_tokens` These endpoints speak the Anthropic Messages dialect used by Claude Code and compatible clients. @@ -335,10 +319,17 @@ documented estimate over system content, messages, and tools and return: { "input_tokens": 123 } ``` +An unresolved date-shaped Desktop ID can also be a genuine native model missing from discovery. +Messages and count-tokens return HTTP 503 with the fixed `desktop_model_mapping_unavailable` error when the available +evidence cannot resolve that ID; this does not establish that the model is invalid. Unknown legacy +hash aliases still return HTTP 400. Neither case strips the date or falls back to another route. +Known IDs, registered mappings and exact `modelMap` matches keep their existing behavior, including +recognized real native IDs. Refresh model discovery or reapply the connected hub profile before +trying again; retrying alone does not guarantee resolution. + ## `GET /v1/models` -The same route serves three clients that expect incompatible catalog envelopes. Anthropic flavor -wins unless `client_version` is also present. +Without `format=desktop-config`, the ordinary catalog contracts are: | Contract | Trigger | Top-level shape | Model-id behavior | | --- | --- | --- | --- | @@ -346,6 +337,29 @@ wins unless `client_version` is also present. | Codex catalog | `client_version` query parameter | `{ "models": [...] }` | Native and routed entries carry the richer Codex catalog fields, visibility, effort, WebSocket, and multi-agent metadata | | Plain OpenAI list | Neither trigger | `{ "object": "list", "data": [...] }` | Visible native ids are bare; routed ids are aliases or `provider/model` | +### Desktop configuration snapshot + +`GET /v1/models?ids=desktop&format=desktop-config` explicitly selects the Desktop snapshot, +independently of user-agent detection. The response is `{ "version": 1, "models": [...] }` +with `Cache-Control: no-store`. The connected client sends `Accept: application/json`, +`anthropic-version: 2023-06-01` and its existing data credential; no admin token or profile +upload is involved. Entries are the hub-issued Desktop configuration models, not Codex catalog rows. + +Combining this format with `ids=cli` or any `client_version` returns HTTP 400. Without the +format selector, the ordinary contracts above remain unchanged. When Claude is disabled, +the snapshot is `{ "version": 1, "models": [] }`; connected Desktop apply treats this as +unavailable and does not write a replacement profile. Old hubs returning an ordinary catalog +instead of version 1 are unsupported; the client does not fall back to locally generated IDs. + +The snapshot remains a read-only model-list contract; it is not a key-rotation or profile-upload +API. Connected Desktop key migration, recovery and disconnect operate through the existing client +lifecycle. Rotation preserves model entries and selections; CLI `rotation` distinguishes +`committed` from `rolled_back`. Disconnect restores owned settings or reports a known-legacy +standard fallback, preserving user fields and later valid selections. Conflicts or incomplete +recovery prevent a completion claim. Restart Desktop to load disk changes; disconnect does not +automatically revoke the hub key. See [Claude Desktop lifecycle](/guides/claude-code/). +Thinking replay and prompt-cache work remain separate in [#3719](https://github.com/lidge-jun/opencodex/issues/3719). + ## `POST /v1/live` and Realtime sideband `POST /v1/live` accepts the ChatGPT/Codex App Frameless call-creation surface. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 75a56d8191..fdc7b6075a 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -101,6 +101,63 @@ Proxy admission secret в любом provider-заголовке удаляет Отключается параметром `claudeCode.nativePassthrough: false`; другой адрес задаётся через `claudeCode.anthropicBaseUrl`. +## Claude Desktop через удалённый хаб + +На подключённой машине `ocx claude desktop apply` или `ocx claude desktop` получает снимок +Desktop с хаба и записывает его origin и точные выданные им ID моделей в локальную конфигурацию. +Локальные псевдонимы заново не создаются. Режимы static/hybrid копируют список моделей; +discovery-only использует origin хаба без встроенного списка. + +Профиль, распределение по семействам и значения по умолчанию принадлежат хабу. Измените их +на хабе, повторите применение на клиенте и заново выберите модель в Desktop. Это нужно и для +старых псевдонимов, созданных только на клиенте. `show`, локальное редактирование и import/export +остаются локальными операциями. При подключении `ocx claude desktop import --apply` +не поддерживается и отклоняется до сохранения; import без `--apply` остаётся локальным. + +Снимок читается с учётными данными существующего подключения для доступа к данным, без +администраторского токена и загрузки профиля на хаб. Старый несовместимый хаб, некорректный ответ +или пустой список Desktop приводят к отказу применения, без подстановки локального каталога или +loopback-адреса. Обновите или настройте хаб и повторите применение. + +Это изменение псевдонимов не исправляет отдельный запрос [#3719](https://github.com/lidge-jun/opencodex/issues/3719) о повторной передаче +`thinking` / `redacted_thinking` и кеше промптов. Сам по себе доступ к прокси не включает нативный +проброс Anthropic, но преобразованные маршруты Anthropic могут использовать кеш. Сохранение +блоков при повторной передаче и сравнение попаданий в кеш остаются отдельной работой. + +### Ротация ключей, восстановление и отключение + +Ротация и восстановление обновляют ключ в управляемом подключением профиле Desktop вместе +с локальными учётными данными. Повторять apply вручную ради смены ключа не нужно. ID моделей, +семейства, значения по умолчанию и текущий выбор профиля сохраняются; ротация не выбирает +управляемый профиль заново и не включает отключённую интеграцию. В JSON CLI +`rotation: "committed"` означает, что новый ключ активен, а `rotation: "rolled_back"` — что +предыдущий сохранён или восстановлен, а не отозван. Неопределённое или неполное восстановление +не выдаётся за успешную ротацию. + +Первое применение при подключении сохраняет прежние управляемые настройки и выбор профиля. +Повторное применение и ротация не заменяют эту исходную запись. `ocx disconnect` восстанавливает +настройки подключения, сохраняя добавленные пользователем поля и другие профили. Прежний выбор +возвращается только если управляемый профиль всё ещё выбран; более поздний выбор другого +действительного профиля сохраняется. Созданный профиль с пользовательскими добавлениями остаётся +читаемым в стандартном режиме. `--keep-catalog` сохраняет каталог, а не ключ подключения в Desktop. + +Старый управляемый профиль без исходной записи можно мигрировать, если он однозначно относится +к текущему хабу и распознанному ключу подключения. Это делают apply, ротация/восстановление или +прямое отключение, без нового флага и предварительного apply. Предупреждение объясняет, что +при отключении будет использован стандартный режим, поскольку прежние настройки не записаны. +Удаляются только настройки шлюза, принадлежащие подключению; пользовательские поля и отдельный +действительный выбор сохраняются. Это стандартный fallback, а не восстановление оригинала. + +Конфликты управляемых настроек, неизвестные ключи и повреждённые записи восстановления +сохраняются и сообщаются пользователю. Прерванная очистка продолжается только для того же +подключения, не удаляя новое и не объявляя неполное восстановление завершённым. До отключения +завершите восстановление ротации; при повторе отключения сохраняйте тот же выбор сохранения каталога. + +После применения, ротации/восстановления или возврата настроек полностью закройте и снова откройте +Claude Desktop: работающий процесс может хранить прежний ключ. Автоматического перезапуска нет. +Локальное отключение не отзывает ключ хаба и не стирает внешние копии; при необходимости +отзовите ключ на хабе отдельно. + ## Селектор /model («From gateway») Claude Code 2.1.129+ обнаруживает модели шлюза через `GET /v1/models?limit=1000` и показывает их @@ -136,6 +193,15 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а декодирование Desktop-хеша → точное совпадение в `modelMap` → совпадение без даты (удаляется `-20250514`) → проброс. +Неразрешённый Desktop ID в формате даты может быть реальным нативным модельным ID, +отсутствующим в результатах обнаружения. Если имеющихся данных недостаточно для разрешения ID, +Messages и count-tokens возвращают HTTP 503 с фиксированной ошибкой `desktop_model_mapping_unavailable`; +это не доказывает недействительность модели. Неизвестные старые хеш-псевдонимы по-прежнему дают +HTTP 400. В обоих случаях дата не удаляется и другая маршрутизация не подставляется. Известные ID, +зарегистрированные сопоставления и точные записи `modelMap`, включая распознанные реальные +нативные ID, обрабатываются как прежде. Обновите обнаружение моделей или повторно примените +профиль подключённого хаба перед новой попыткой; один лишь повтор не гарантирует разрешения. + Каждая запись содержит отображаемое имя вида `gemini-3-pro (gemini)` и полные возможности модели (шкала уровней рассуждений, типы thinking) в официальном формате `ModelInfo`. Настоящие модели Anthropic сохраняют канонические id в обоих интерфейсах. @@ -264,6 +330,15 @@ Anthropic и автоматически срабатывает при упоми Порядок поиска: алиас обнаружения → точный id → id без датировочного суффикса (`-20250514`) → проброс. +Неразрешённый Desktop ID в формате даты может быть реальным нативным модельным ID, +отсутствующим в результатах обнаружения. Если имеющихся данных недостаточно для разрешения ID, +Messages и count-tokens возвращают HTTP 503 с фиксированной ошибкой `desktop_model_mapping_unavailable`; +это не доказывает недействительность модели. Неизвестные старые хеш-псевдонимы по-прежнему дают +HTTP 400. В обоих случаях дата не удаляется и другая маршрутизация не подставляется. Известные ID, +зарегистрированные сопоставления и точные записи `modelMap`, включая распознанные реальные +нативные ID, обрабатываются как прежде. Обновите обнаружение моделей или повторно примените +профиль подключённого хаба перед новой попыткой; один лишь повтор не гарантирует разрешения. + ## Матрица сайдкаров: веб-поиск и понимание изображений Не у всех маршрутизируемых моделей одинаковый набор серверных (hosted) инструментов и поддержка diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index e33bb6c835..9707a3ea44 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -304,8 +304,14 @@ Codex на встроенный провайдер `openai` и удалите л allowlist, никогда не попадёт в каталог. 2. **`disabledModels`** (верхний уровень) — скрывает модели и из каталога, и из `/v1/models`, а у голых нативных GPT-slug устанавливает `visibility: "hide"`. -3. **`liveModels: false` и пустой `models`** — если живое обнаружение выключено, а `models` пуст - или отсутствует, opencodex не показывает ни одной маршрутизируемой модели этого провайдера. +3. **`liveModels: false`** — При `liveModels: false`, если `models` пуст или отсутствует, начальный список содержит сначала + настроенный `defaultModel`, затем `retainModels`. Дубликаты удаляются с сохранением первого вхождения. + Если явно задан непустой `models`, за ним следует `retainModels`, а другой `defaultModel` автоматически + не добавляется. Его можно явно указать в `models` или `retainModels`. Если ни одно из этих полей + не содержит ID, начальный список пуст. Этот порядок не гарантирует итоговый порядок в селекторе. + Правила `selectedModels`, `disabledModels` и отключения провайдера продолжают действовать. + `authMode: "forward"` сохраняет отдельную ветвь и не использует этот статический список + маршрутизируемых моделей. Эти правила не меняют резервное поведение при сбое живого обнаружения. 4. **Cursor `GetUsableModels`** — адаптер Cursor получает модели через protobuf RPC `GetUsableModels`, а не через `/models`, поэтому изменение на стороне Cursor может менять видимые id независимо от остальных провайдеров. diff --git a/docs-site/src/content/docs/ru/guides/model-ordering.md b/docs-site/src/content/docs/ru/guides/model-ordering.md index d5a3683834..5ff04e4892 100644 --- a/docs-site/src/content/docs/ru/guides/model-ordering.md +++ b/docs-site/src/content/docs/ru/guides/model-ordering.md @@ -25,6 +25,8 @@ selector-групп. Приоритеты без селекторов: +Таблицы приоритетов и пример ниже описывают режим без сортировки всего селектора. + | Запись каталога | Priority | Источник | | --- | ---: | --- | | `subagentModels[i]` | `i` (от `0` до `4`) | Карта рангов избранных в `src/codex/catalog/sync.ts` | @@ -116,12 +118,63 @@ native-выбора в selector-qualified группы. Поддерживаемый способ настроить порядок ведущих моделей — переставить элементы `subagentModels`. Страница **Sub-agents** в дашборде позволяет менять порядок bare native- и routed-id. Конфигурация и `ocx agent subagents set` также принимают точные account-qualified id -`/`, но дашборд не предлагает и не сохраняет их при записи списка. +`/`, а дашборд сохраняет уже записанные ID, даже если они недоступны. Используйте не более пяти настроенных id. При активных селекторах одна bare native-модель может развернуться в несколько selector-qualified строк, поэтому число настроенных вариантов и объявляемых строк не обязательно совпадает. -Общих настроек `modelOrder`, `providerOrder` или карты приоритетов в `OcxConfig` сейчас нет. -Поддерживаемое поле порядка — `subagentModels`; `disabledModels` и `selectedModels` каждого -провайдера — поля видимости. Изменение остальной части порядка селектора потребовало бы изменения -поведения на уровне кода, а не правки конфигурации. +`modelPickerOrder` управляет только порядком отображения в селекторе. Если список содержит лишь +маршрутизируемые ID `/`, указанные строки вне избранных попадают в отдельный +диапазон отображения (`1000 + i`) в порядке списка. Неуказанные маршрутизируемые строки сохраняют +обычный приоритет и остаются перед этим диапазоном. Строки из `subagentModels` сохраняют приоритет +избранных, а нативные строки — обычные позиции. Укажите все маршрутизируемые строки, относительный +порядок которых нужно задать. + +Чтобы сортировать весь селектор, включите хотя бы один непустой ID каталога без `/`, например +`gpt-5.6-sol`. Строка из одних пробелов не включает этот режим. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Указанные строки идут первыми в порядке массива, затем неуказанные — по исходному приоритету. +Сопоставление учитывает точный ID каталога: `gpt-5.6-sol` и `openai/gpt-5.6-sol` — разные строки. +Допускаются исходная и закодированная формы одного маршрутизируемого ID, но точное совпадение +имеет приоритет над эквивалентным. Пустые строки и строки из одних пробелов игнорируются. +Для строки конкретного аккаунта укажите полный ID с селектором. + +### Миграция: нативные ID в существующих списках + +Раньше нативные ID без префикса в `modelPickerOrder` игнорировались. Теперь такой ID в существующем +списке включает сортировку всего селектора, включая избранные строки. Удалите ID без префикса, +чтобы сохранить прежнее поведение только для маршрутизируемых строк. Отсутствующий или пустой +список, список из одних пробельных строк и список только с маршрутизируемыми ID работают как раньше. + +`modelPickerOrder` сохраняет расчёт OpenCodex, который выбирает до пяти предпочтительных +кандидатов для рекомендаций субагентам по исходному приоритету. У каждой перемещённой строки +этот приоритет хранится отдельно от нативного `priority`; изменение только порядка селектора +не должно менять результат этого расчёта. Оно также не ограничивает допустимость переопределения +модели по точному имени: объявленный список не является списком разрешений. Существующие +ограничения аутентификации, модели, effort и бэкенда продолжают действовать. + +Нативный Codex использует нативный `priority`, чтобы объявить через `spawn_agent` первые пять +допустимых моделей, видимых в селекторе. Это относится к V1 и к V2 с открытыми переопределениями +моделей. Поэтому объявленные пять моделей могут меняться вместе с порядком селектора, даже если +предпочтительные кандидаты OpenCodex не изменились. В V1 OpenCodex не внедряет список +предпочтительных моделей. V2 может дополнительно получать рекомендации по исходным приоритетам, +если состояние каталога клиента это допускает; эти рекомендации не меняют порядок списка, +объявленного нативным инструментом. + +`disabledModels` и `selectedModels` каждого провайдера +по-прежнему управляют видимостью. Отдельных настроек `modelOrder`, `providerOrder` или карты +приоритетов нет. + +## Пресеты в дашборде + +На странице **Models** выберите порядок по умолчанию, по имени A–Z, по провайдеру или снимок использования и примените его. Сохраняются доступные маршрутизируемые ID и `modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`). Сохранённая статистика читается один раз при применении; перезагрузка и изменения каталога не пересчитывают снимок. Пользовательский порядок, включая полный нативный, сохраняется до явного применения. Сброс удаляет оба поля даже при пустом списке моделей. + +`GET/PUT /api/subagent-models` сохраняет отключённые и отсутствующие выбранные ID в `chosen` и `available`; `pickerAvailable` содержит допустимые маршрутизируемые ID. Models отправляет `pickerOrder` и `pickerOrderMode`, но не `models`. Сохранение только roster не меняет порядок; неверный ввод и ошибка записи сохраняют предыдущее состояние. + +Диапазоны приоритетных и нативных моделей сохраняются. Порядок применяется к каталогу Codex и маршрутизируемым группам обнаружения Claude, сохраняя нативный префикс Claude, явные профили Desktop и владельцев alias. Ранги подсказок OpenCodex и настройки fallback не меняются, но пять вариантов, объявляемых нативным Codex, и рекомендуемая модель могут измениться. Сохранение не перезапускает клиенты; обновление может ожидать завершения, а старый каталог потребовать повторного открытия клиента. diff --git a/docs-site/src/content/docs/ru/guides/model-routing.md b/docs-site/src/content/docs/ru/guides/model-routing.md index eb4178cd3f..ce96fc95b8 100644 --- a/docs-site/src/content/docs/ru/guides/model-routing.md +++ b/docs-site/src/content/docs/ru/guides/model-routing.md @@ -91,12 +91,16 @@ description: Как opencodex решает, какой провайдер буд - `provider.disabled: true` убирает провайдера из обнаружения каталога. Явные запросы `provider/model` завершаются ошибкой, а проверки `defaultModel` / `models[]` его пропускают. - `providerContextCaps` задаёт видимые для Codex лимиты контекста по провайдерам. - `contextCapValue` — значение по умолчанию для дашборда (по умолчанию 350 000), но сам по себе он - ничего не делает, пока провайдер не указан в `providerContextCaps`. Изменение значения на дашборде - переназначает все включённые провайдеры только при включённом переключателе «применить ко всем - маршрутизируемым провайдерам»; в противном случае каждый провайдер сохраняет собственный лимит. - Лимиты только понижают известное контекстное окно; они никогда не повышают его и не меняют - фактический предел вышестоящей модели. + `contextCapValue` — значение по умолчанию для дашборда (350 000); само по себе оно не применяет + ограничение, пока провайдер не указан в `providerContextCaps`. Изменение значения в дашборде + обновляет только активные лимиты и только при включённом переключателе «применить ко всем + маршрутизируемым провайдерам»; иначе каждый провайдер сохраняет свой лимит. Обычные известные + окна можно только уменьшать; нативные модели с поддержкой длинного контекста могут расширять + окно до собственного поддерживаемого предела. Фактический предел вышестоящей модели не меняется. + При отключении лимита выбор сохраняется в `providerContextCapValues`, в том числе после + перезагрузки. Повторное включение восстанавливает выбор. Сохранённое значение не ограничивает + окно, пока лимит отключён. `{ "setAll": true }` без `value` включает лимиты всех настроенных + провайдеров с текущим глобальным значением и заменяет их сохранённые значения. ```json { diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 8b43b8d4c2..8e7684417e 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -123,6 +123,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | +Проверки квот аккаунтов и провайдера Google Antigravity используют фиксированные адреса Google, включая резервный запрос списка моделей. Для этих адресов поддерживается прозрачный Fake-IP DNS с сохранением проверки TLS, запрета перенаправлений и проверки частных адресов. Пользовательский base URL меняет только запросы моделей; `NO_PROXY` сохраняет политику прямого подключения. + + После терминального сбоя обновления Nous выполните `ocx login nous`, чтобы пройти повторную аутентификацию. Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`) diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index 5e1fbab08d..0887baf9a8 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -60,13 +60,35 @@ OAuth запускается через `POST /api/oauth/login`. Если callba ## Docker и устранение неполадок -Официального Docker-образа нет, но репозиторий содержит поддерживаемые `Dockerfile` и `compose.yaml` для локальной сборки Bun-образа, закреплённого по digest. При первом обычном запуске контейнер создаёт самоподписанный TLS-сертификат `/home/bun/.opencodex/container-tls/cert.pem` и закрытый ключ `/home/bun/.opencodex/container-tls/key.pem`. Ключ доступен только владельцу в volume `ocx-state`, а endpoint данных с этого момента работает по HTTPS. - -До первого запуска один раз передайте токен данных через stdin. Bootstrap принимает не более одной строки размером 512 байт, никогда не выводит токен, отказывается заменять существующий и сохраняет его в каноническом защищённом файле `service-api-token`. - -На хосте нужны Git и Bun. Перед каждой сборкой создавайте канонический манифест из отслеживаемых Git исходников и файлов, определяющих контейнер, и не меняйте их до завершения сборки. Сгенерированный JSON не добавляйте в Git; `.git` исключён из контекста Docker. По умолчанию порт хоста привязан к `127.0.0.1`. Для удалённого доступа явно задайте `OPENCODEX_BIND_ADDRESS= docker compose up -d`; `0.0.0.0` открывает все интерфейсы. Защитите доступ брандмауэром и аутентифицированным TLS/tailnet-фронтендом. - -Манифест аутентифицирует `Dockerfile`, `compose.yaml`, `.dockerignore`, каждый отслеживаемый управляющий файл в `docker/`, исходники в `src/` и обязательные файлы пакета, включая `package.json`, `bun.lock` и `scripts/model-metadata.source.json`. Сборка сверяет каждый SHA-256 с контекстом и затем образом; отсутствующие или изменённые файлы, любой лишний исходник или управляющий файл Docker и символические ссылки запрещены. +При откате сохраняйте оба тома и их точки монтирования. Владельцы и права существующих томов не исправляются автоматически. Именованные тома вне Compose и отдельные пути состояния описаны в [основном руководстве](/guides/remote-hub/#docker-compose). + +Состояние хранится в двух отдельных томах: `ocx-state` для +`OPENCODEX_HOME=/home/bun/.opencodex` и `codex-state` для +`CODEX_HOME=/home/bun/.codex`. Форматы `auth.json` у двух продуктов несовместимы, +поэтому не объединяйте их домашние каталоги. Оба тома доступны для записи при +корневой файловой системе только для чтения. + +Каталог моделей автоматически не создаётся. Перед проверкой авторизованного +`/v1/catalog` создайте или импортируйте корректный файл +`/home/bun/.codex/opencodex-catalog.json`. Для пустого каталога состояния ответ +404 `catalog_not_found` ожидаем. Обновление сохраняет `ocx-state` и добавляет +`codex-state`, но не переносит файлы автоматически. Если обходное решение хранило +каталог моделей в `.opencodex`, сначала сделайте резервную копию, затем перенесите +только каталог моделей с доступом лишь для владельца. Не перезаписывайте один +`auth.json` другим. При переопределении `CODEX_HOME` монтируйте именно эту директорию +для записи и сохраняйте каталог по умолчанию в `${CODEX_HOME}/opencodex-catalog.json`. +Если `model_catalog_json` задаёт другой файл, его разрешённый путь также должен +храниться постоянно. До явного переноса сохраняйте прежнее соответствие переменных +окружения и томов. `docker compose down` сохраняет оба тома, а +`docker compose down --volumes` удаляет и `ocx-state`, и `codex-state`, включая +учётные данные, историю использования, ключ данных, состояние и каталог Codex. +Это разрушительная операция, а не способ обновления или перезапуска. + +Официального Docker-образа нет, но репозиторий содержит поддерживаемые `Dockerfile` и `compose.yaml` для локальной сборки Bun-образа, закреплённого по digest. Перед первым запуском один раз передайте ключ данных через stdin; он не выводится и сохраняется с доступом только для владельца в volume `ocx-state`. + +На хосте нужны Git и Bun. Перед каждой сборкой создавайте канонический манифест из отслеживаемых Git исходников и не меняйте их до завершения сборки. Сгенерированный JSON не добавляйте в Git; `.git` исключён из контекста Docker. По умолчанию порт хоста привязан к `127.0.0.1`. Для удалённого доступа явно задайте `OPENCODEX_BIND_ADDRESS= docker compose up -d`; `0.0.0.0` открывает все интерфейсы. Защитите доступ брандмауэром и аутентифицированным TLS/tailnet-фронтендом. + +Сборка отклоняет устаревший манифест, сверяя каждый SHA-256 с файлами контекста и затем образа. Отсутствующие или изменённые файлы, лишние исходники и символические ссылки запрещены. Обязательны `package.json`, `bun.lock` и единственный включаемый файл из `scripts/` — `scripts/model-metadata.source.json`. ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -77,40 +99,6 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -Для проверки локального HTTPS endpoint скопируйте только открытый сертификат: - -```bash -mkdir -p .tmp -docker compose cp hub:/home/bun/.opencodex/container-tls/cert.pem .tmp/opencodex-container-ca.pem -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/healthz -``` - -`OPENCODEX_PORT` одновременно задаёт опубликованный порт хоста и управляемое Compose значение `tls.publicOrigin`; внутренний listener остаётся на `10100`: - -```bash -OPENCODEX_PORT=10190 docker compose up -d -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10190/healthz -``` - -Сохранённый volume, созданный до появления TLS, мигрирует автоматически при запуске: для него создаётся отдельная TLS-идентификация, а HTTPS origin использует опубликованный порт. Пути к сертификатам, которыми управляет оператор, не изменяются. Созданный сертификат покрывает только `localhost` и `127.0.0.1`. Для прямой публикации по удалённому имени установите сертификат и ключ для этого имени, затем передайте точный HTTPS origin через `OPENCODEX_PUBLIC_ORIGIN` вместе с адресом публикации: - -```bash -OPENCODEX_PUBLIC_ORIGIN=https://hub-name.tailnet-name.ts.net \ -OPENCODEX_BIND_ADDRESS=100.64.0.10 \ -docker compose up -d -``` - -Внутренние проверки здоровья и готовности могут отключать проверку сертификата только при обращении к фиксированному loopback-адресу контейнера `https://127.0.0.1:10100`. Это исключение не подходит для внешней приёмки: при проверке развёртывания необходимо проверять точное имя хоста с помощью скопированного открытого сертификата или системного хранилища доверия. - -Используйте те же переменные при последующих вызовах Compose. Для возврата к старому HTTP-образу удалите настройку TLS, пока текущий образ ещё доступен, и только затем запустите старый образ. Файлы идентификации можно оставить в volume: - -```bash -docker compose down -docker compose run --rm hub bun run src/cli/index.ts config unset tls -# выбрать или собрать старый образ, затем заново создать hub -docker compose up -d -``` - Контейнер работает от непривилегированного пользователя `bun`, с корневой файловой системой только для чтения и публикует только `10100`. Не публикуйте `10101` и не помещайте секреты в `ARG`, `ENV`, `COPY`, Compose, историю образа или argv. После healthcheck отдельно проверьте readiness, аутентифицированный каталог и реальный запрос. `docker compose down` сохраняет volume; `docker compose down --volumes` удаляет также конфигурацию, учётные данные и ключ. - При недоступном hub можно отключиться офлайн, но отзыв ключа останется незавершённым. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 4652280be8..b619a7d3e3 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -48,6 +48,14 @@ bun run dev:gui | **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). На Windows с бэкендом планировщика заданий дашборд отказывает и просит выполнить `ocx stop`: обёртка может перезапустить прокси после завершения задачи, и проверить это окно перезапуска до восстановления клиентской конфигурации способен только stop, работающий вне прокси. При отказе ничего не изменяется. | +### Фильтрация запросов + +Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. + +Периоды 15 минут, час и сутки обновляются каждые 30 секунд на вкладке Logs даже при выключенном автообновлении. Скорость — выходные токены в секунду за полную длительность запроса: меньше 15, от 15 до менее 50, не менее 50; недоступные значения исключаются при активном фильтре скорости. Успех — 2xx, ошибки — 4xx/5xx. + +Счётчик показывает совпадения из загруженного общего числа; сброс возвращает все строки. Нет совпадений и пустой журнал различаются. Источник выбирается стрелками и Home/End. История вне загруженного журнала не запрашивается. + ### Ссылки на разделы Макет теперь один и адаптивный, поэтому переключать нечего. На компьютере основная навигация находится в боковой панели, а на узком экране те же ссылки открываются кнопкой **Открыть меню**. У разделов Dashboard есть собственные адреса: `#dashboard` открывает Overview, а `#dashboard/providers` и `#dashboard/models` — два других раздела. Перезагрузка, закладка и кнопка «Назад» сохраняют выбранный раздел. **Logs** работает так же — `#logs` и `#logs/debug`. Старая закладка `#providers/workspace` теперь ведёт на `#providers`. @@ -62,6 +70,25 @@ bun run dev:gui Переключатели **Models** показывают итоговую видимость в Codex. Маршрутизируемая модель включена, только если она входит в allowlist провайдера (или allowlist отсутствует) и не отключена. Включение атомарно согласует оба фильтра, а **Включить все** удаляет allowlist и включает новые модели. +### Управление моделями в рабочей области провайдера + +На вкладке **Модели** провайдера действие **Удалить** удаляет сохранённое пользовательское +определение. Исходная нативная или обнаруженная модель может появиться снова, поэтому счётчик +моделей может не измениться. **Скрыть** меняет только видимость в каталоге, не удаляя определение +и не меняя политику прямой маршрутизации. Кнопка **Управлять видимостью в разделе «Модели»** +открывает страницу **Модели**, где можно восстановить видимость, даже если вкладка провайдера пуста. + +**Добавить** сохраняет пользовательское определение, но не отменяет существующее скрытие или +правила выбора провайдера. Сохранённая модель может остаться скрытой. Если модель уже известна, +управляйте её видимостью в разделе **Модели**. Подтверждённое сохранение остаётся действительным +при сбое обновления каталога: следуйте сообщению об обновлении, не добавляя модель повторно. +Если изменение не подтверждено, обновите состояние моделей перед повторной попыткой. + +Счётчик провайдера показывает число уникальных неотключённых записей в текущем списке моделей, +полученном от сервера, до поиска и ограничения числа отображаемых строк. Это не размер списка +разрешений, не число моделей из живого обнаружения и не доказательство происхождения записи. +Метки выбора и сведения об обнаружении учитываются отдельно. + ## Селектор делегирования и маршрутизация порождений Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, @@ -161,7 +188,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Выбор аккаунта для следующего запроса и настройка маршрутизации пула. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Чтение эффективного аккаунта (включая признак закрепления `pinned` и закреплённый аккаунт `pinnedAccountId`) и установка порядка выбора для одного аккаунта. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Добавление аккаунта пула через вход в браузере. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. `limit`/`offset` листают назад от самой новой строки (`offset=0` — последняя страница). Ответ: `{ timeZone, total, logs }`, где `total` — число совпадений до пагинации. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Чтение метаданных недавних запросов с необязательными фильтрами tail, провайдера и точного/классового статуса. `limit`/`offset` листают назад от самой новой строки (`offset=0` — последняя страница). Ответ: `{ timeZone, generatedAt, total, logs }`, где `total` — число совпадений до пагинации. | | `GET` / `PUT /api/subagent-models` | Чтение или настройка пяти выделенных моделей переопределения `spawn_agent`. | | `POST /api/stop` | Остановка прокси/сервиса, восстановление нативного Codex и выход. Отклоняется с `respawnable_service` на бэкенде планировщика заданий Windows и с `service_state_unknown`, когда это состояние не удаётся прочитать; в обоих случаях ничего не изменяется. | diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 30b4e627a3..1ace7cc10f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -220,6 +220,12 @@ unit**, Windows **Task Scheduler**), которая автоматически перезапускается при crash. Запуски службы выставляют `OCX_SERVICE=1`, чтобы restart не дёргал конфиг Codex. +При установке через Windows Task Scheduler используется обычный приоритет процесса (`Priority=4`). +Прежний фоновый приоритет (`7`, также значение планировщика по умолчанию при отсутствии параметра) +при конкуренции за CPU может задерживать ответы проверки состояния: трей показывает Offline, хотя процесс работает. +После обновления выполните `ocx service repair`, чтобы изменить этот зарегистрированный приоритет и перезапустить службу. +Может потребоваться подтверждение UAC. Если уже задан обычный или высокий приоритет, сам приоритет не вызывает перерегистрацию. + | Подкоманда | Действие | | --- | --- | | none | Установить и запустить службу, если её нет; иначе обновить и перезапустить существующую службу. Исправная конфигурация Windows Task Scheduler используется повторно; устаревшая может быть перерегистрирована и потребовать повышения прав. | diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index f3f5098d77..07853dbbd3 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -187,12 +187,11 @@ quota-bar'ов дашборда. ### `ocx account auto-switch > [--json]` -Управляет только пулом аккаунтов Codex `openai`. `on` ставит 80%, `off` — 0%, `status` читает -текущее значение, а `threshold ` принимает целое число от 0 до 100. Для других провайдеров и -некорректных значений команда завершается кодом 1. `--json` возвращает: +Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Пороги общих пулов пока не применяются: сохранение не включает переключение по порогу, не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Для общего пула результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог не применяется; неизвестная возможность также не даёт `enabled: true`. Провайдеры с ключом API, Anthropic и неверные значения отклоняются. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index 9aca58ecdb..865fb3d358 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -84,7 +84,8 @@ cooldown либо уже достигли порога quota. Availability-сн native ChatGPT-target'ами и прямыми key-auth Responses-маршрутами, явно доверенными через `allowEncryptedV2AgentTasks: true`. Если ни один из них не может обработать encrypted payload, запрос завершается ошибкой вместо отправки нечитаемого ciphertext наружу. Combo по-прежнему -использует только канонические native-цели. +сначала выбирает доступную каноническую native-цель; если её нельзя выбрать и включён +`agentTaskRecovery`, encrypted `NEW_TASK` восстанавливается один раз перед routed combo dispatch. ```json { diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 0b51770ba9..269ff8abe6 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -34,8 +34,9 @@ ocx models provider openrouter on | `providers` | `Record` | — | Map вида provider name → provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Отмечает, что единая projection OpenAI с учётом режима уже завершена. | | `disabledModels?` | `string[]` | — | Модели, скрытые из каталога Codex и `/v1/models`, но не заблокированные для прямых вызовов прокси. Routed-id удаляются из списков. Account-qualified native-id скрывает только строку этого селектора; bare native GPT-id скрывает bare-строку и строки всех селекторов аккаунтов для этой модели. Страница Models показывает только bare native- и routed-строки; чтобы скрыть одну selector-qualified строку, задайте это поле конфигурации напрямую. | -| `providerContextCaps?` | `Record` | `{}` | Context cap'ы, видимые Codex, по каждому провайдеру. Cap может только понижать известное context window. | -| `contextCapValue?` | `number` | `350000` | Значение по умолчанию для элементов управления context-cap в дашборде. При изменении значение применяется ко всем маршрутизируемым провайдерам — включая провайдеров без существующей записи `providerContextCaps` — только при включённом переключателе «применить ко всем маршрутизируемым провайдерам»; в противном случае каждый провайдер сохраняет собственный лимит. | +| `providerContextCaps?` | `Record` | `{}` | Активные лимиты контекста по провайдерам. Обычные окна уменьшаются; нативные модели с поддержкой длинного контекста могут расширять окно только до собственного поддерживаемого предела. | +| `providerContextCapValues?` | `Record` | `{}` | Последние выбранные лимиты по провайдерам, сохраняемые после отключения. Эти значения сами по себе не включают ограничение. Активное значение имеет приоритет над сохранённым. | +| `contextCapValue?` | `number` | `350000` | Значение по умолчанию при первом включении. При повторном включении восстанавливается выбранное значение провайдера. Изменение глобального значения с `setAll: true` обновляет только активные лимиты; `setAll: true` без значения включает лимиты всех настроенных провайдеров с текущим глобальным значением. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | | `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Когда строки picker'а с указанием аккаунта включены, каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | @@ -101,7 +102,7 @@ cross-route credential fallback не существует. Строки API GPT- | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Header-style для ключа Anthropic. По умолчанию нативный `x-api-key`; допустим только для key-auth-провайдеров `anthropic`. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Пул из нескольких ключей. `apiKey` зеркалит активную запись; каждый элемент содержит `id`, `key`, необязательный `label` и необязательное числовое `addedAt`. | | `defaultModel?` | `string` | Модель, используемая когда этот провайдер выбран без явной модели. | -| `models?` | `string[]` | Seed/fallback-список моделей. При `liveModels: false` это и есть единственный список обнаруженных моделей. | +| `models?` | `string[]` | Начальный список моделей или список для резервного режима. При `liveModels: false` за непустым `models` следует `retainModels`; если `models` пуст или отсутствует, сначала берётся настроенный `defaultModel`, затем `retainModels`. Для каждого ID сохраняется только первое вхождение. | | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | | `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Карта содержит не более 2 000 записей, как и discovery. | @@ -449,8 +450,16 @@ Chat-запросов не добавляют поле `provider`, а Vercel AI ## Статические allowlist'ы моделей -Задайте `liveModels: false`, чтобы показывать только `models`. Если `models` пуст или отсутствует, -провайдер не будет показывать ни одной маршрутизируемой модели. Live-discovery отвергает ответы +При `liveModels: false`, если `models` пуст или отсутствует, начальный список содержит сначала +настроенный `defaultModel`, затем `retainModels`. Дубликаты удаляются с сохранением первого вхождения. +Если явно задан непустой `models`, за ним следует `retainModels`, а другой `defaultModel` автоматически +не добавляется. Его можно явно указать в `models` или `retainModels`. Если ни одно из этих полей +не содержит ID, начальный список пуст. Этот порядок не гарантирует итоговый порядок в селекторе. +Правила `selectedModels`, `disabledModels` и отключения провайдера продолжают действовать. +`authMode: "forward"` сохраняет отдельную ветвь и не использует этот статический список +маршрутизируемых моделей. Эти правила не меняют резервное поведение при сбое живого обнаружения. + +Live-discovery отвергает ответы размером более 4 MiB или более 2000 сырых model-row до кэширования; built-in preset'ы могут использовать меньшие лимиты и фильтровать список до chat-совместимых строк. Oversized или malformed-результаты откатываются к stale/configured fallback. Валидный результат с нулём diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 039d1a4e50..24a32d6a65 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -234,3 +234,7 @@ opencodex. Перед использованием прогоните soak-test `runtimeRole` по умолчанию равен `standalone`. Hub использует `hub.managementPublicOrigin`, loopback-only `hub.managementIngress` (`enabled:false`, если отсутствует) и точные `remoteGui.allowedTailscaleUsers` (пустой список, если отсутствует). Ключ клиента хранится в `service-api-token`, не в `config.json`; во время ротации может появиться `service-api-token.prev`. Статистика не зеркалируется. `remoteGui.allowInsecureHttp` — устаревший no-op, оставленный только для загрузки старых файлов со строгой схемой. Удалите его из конфигурации: pairing grants принимаются лишь через loopback или аутентифицированный HTTPS, а значение `true` не включает pairing по открытому HTTP. + +## Сетевая диагностика квоты Codex + +Поле `quotaRefresh` в строке основного аккаунта Codex описывает получение квоты, а не её остаток или право доступа к модели. Оно может отсутствовать при чтении кэша или если запрос не выполнялся. Используется окружение работающего прокси-сервиса, а не текущего терминала. Если `proxy` не задан, существующее окружение сохраняется; `"auto"` читает только статические настройки прокси Windows при запуске. PAC/WPAD, настройки только SOCKS и изменения во время работы автоматически не учитываются. Успех через TUN сам по себе не подтверждает исправность пути HTTP-прокси. См. [команды и состояния на английском](/reference/configuration/server/#codex-quota-network-diagnostics). diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index bc6f2e9c99..ede2870dec 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -166,12 +166,15 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `GET /api/models` | Вернуть model-row'ы для дашборда и CLI | `catalog_busy`, когда сборка перегружена | | `GET /api/client-config?client=...` | Собрать read-only client config для любой поддерживаемой файловой интеграции | 400 unsupported client; 503 catalog unavailable | | `PUT /api/disabled-models` | Полностью заменить общий список disabled-models | 400 invalid JSON | -| `PUT /api/model-visibility` | Атомарно изменить видимость на уровне провайдера или модели | 400 invalid provider, scope, target or body | +| `PUT /api/model-visibility` | Атомарно изменить видимость на уровне провайдера или модели | 400 invalid provider, scope, target or body; 409 `initial_model_selection_pending` (Обновите список моделей и повторите попытку.) | | `GET, POST /api/custom-models` | Показать список custom-моделей или добавить одну | 400 invalid fields; 404 provider missing; 409 duplicate model | | `PUT, DELETE /api/custom-models/{id}` | Изменить или удалить одну custom-модель | 400 invalid id/fields; 404 not found; 409 duplicate model | | `GET, PUT /api/selected-models` | Прочитать allowlist'ы и availability провайдеров либо заменить один allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Прочитать пресеты или выбрать режим preset/all/custom | 400 неверный режим или неподдерживаемый пресет; 404 неизвестный провайдер; PUT 409 `initial_model_selection_pending` | +Ручная модель заменяет строку панели Models с тем же провайдером и идентификатором модели. Для OpenAI ручная строка сохраняет `openai/` и поддерживает управление видимостью. При её удалении восстанавливается нативная строка без уточнения аккаунта. Нативные строки с указанием аккаунта остаются отдельными. Нативные маршруты и права аккаунта не меняются. Ненативная цель видимости OpenAI должна соответствовать настроенной ручной модели. + + Пока достоверный исходный список моделей не получен, корректные PUT-запросы к `/api/selected-models` и `/api/model-presets` возвращают HTTP 409 с кодом `initial_model_selection_pending`. Обновите список моделей, например через `GET /api/models`, и повторите запрос после успешного получения списка. ### OAuth-аккаунты, ключи провайдеров и ключи data plane @@ -211,6 +214,18 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `GET, PUT /api/provider-context-caps` | Прочитать или обновить context cap глобально, для всех провайдеров или для одного провайдера | 400 invalid request; 404 unknown provider | | `GET /api/provider-presets` | Вернуть GUI-presets провайдеров, выведенные из runtime registry | — | +Ответ API лимитов контекста содержит `caps` (активные лимиты) и `values` (последние выбранные +значения, сохраняемые после отключения). Включение лимита провайдера без `value` восстанавливает +его выбор, а при первом включении использует глобальное значение `contextCapValue`. +Это относится и к OpenAI: переключатель не выбирает специальный режим 922k. Активный лимит +ограничивает каждое нативное окно; модели с поддержкой длинного контекста могут расширять окно +только до собственного поддерживаемого предела. +`{ "value": 600000, "setAll": true }` меняет глобальное значение и только активные лимиты. +Провайдеры с отключённым лимитом сохраняют свой выбор для последующего включения. +`{ "setAll": true }` без `value` включает лимиты всех настроенных провайдеров с текущим глобальным +значением и заменяет сохранённый выбор. Отключение сохраняет выбор даже после перезагрузки, +но не применяет его как ограничение. + `provider_has_dependent_combos` — это safety-барьер: сначала удалите или отредактируйте зависящие combo, и лишь потом удаляйте их провайдера. diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 1b6fbc37ec..517d9c8de1 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -29,7 +29,7 @@ control и safety ответа всё равно происходят на гр | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE, заканчивающийся `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Подсчёт токенов Anthropic | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Не применяется | -| Обнаружение моделей | `GET /v1/models` | Один из трёх контрактов каталога | Не применяется | +| Обнаружение моделей | `GET /v1/models` | Каталог или явно запрошенный снимок Desktop | Не применяется | | Голос и Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | Ответ создания вызова после ретрансляции | Отдельный sideband WebSocket ретранслирует frame'ы в обе стороны | | Компактизация Responses | `POST /v1/responses/compact` | JSON истории-замены | Не применяется | @@ -216,10 +216,18 @@ passthrough. Native-eligible-запрос пересылается в count-endp { "input_tokens": 123 } ``` +Неразрешённый Desktop ID в формате даты может быть реальным нативным модельным ID, +отсутствующим в результатах обнаружения. Если имеющихся данных недостаточно для разрешения ID, +Messages и count-tokens возвращают HTTP 503 с фиксированной ошибкой `desktop_model_mapping_unavailable`; +это не доказывает недействительность модели. Неизвестные старые хеш-псевдонимы по-прежнему дают +HTTP 400. В обоих случаях дата не удаляется и другая маршрутизация не подставляется. Известные ID, +зарегистрированные сопоставления и точные записи `modelMap`, включая распознанные реальные +нативные ID, обрабатываются как прежде. Обновите обнаружение моделей или повторно примените +профиль подключённого хаба перед новой попыткой; один лишь повтор не гарантирует разрешения. + ## `GET /v1/models` -Один и тот же маршрут обслуживает три клиента, ожидающих несовместимые envelope'ы каталога. -Форма Anthropic имеет приоритет, если только одновременно не присутствует `client_version`. +Без `format=desktop-config` действуют следующие обычные контракты каталога: | Контракт | Триггер | Форма верхнего уровня | Поведение id модели | | --- | --- | --- | --- | @@ -227,6 +235,30 @@ passthrough. Native-eligible-запрос пересылается в count-endp | Каталог Codex | Query-параметр `client_version` | `{ "models": [...] }` | Нативные и маршрутизируемые записи несут более богатые поля каталога Codex: visibility, effort, WebSocket и multi-agent metadata | | Обычный список OpenAI | Ни один триггер не сработал | `{ "object": "list", "data": [...] }` | Видимые native-id идут без префикса; routed-id — как alias или `provider/model` | +### Снимок конфигурации Desktop + +`GET /v1/models?ids=desktop&format=desktop-config` явно выбирает снимок Desktop независимо +от user-agent. Ответ — `{ "version": 1, "models": [...] }` с `Cache-Control: no-store`. +Клиент отправляет `Accept: application/json`, `anthropic-version: 2023-06-01` и существующие +учётные данные для доступа к данным; администраторский токен и загрузка профиля не нужны. +Элементы — модели конфигурации Desktop, выданные хабом, а не строки каталога Codex. + +Этот формат вместе с `ids=cli` или любым `client_version` возвращает HTTP 400. Без выбора +формата обычные контракты выше не меняются. При выключенном Claude ответ имеет вид +`{ "version": 1, "models": [] }`: подключённый Desktop apply считает модели недоступными и +не записывает заменяющий профиль. Старые хабы с обычным каталогом вместо версии 1 не +поддерживаются; перехода к локально созданным ID нет. + +Снимок остаётся списком моделей только для чтения, а не API ротации или загрузки профиля. +Миграция ключа Desktop, восстановление и отключение используют существующий цикл подключения. +Ротация сохраняет модели и выбор; CLI-поле `rotation` различает `committed` и `rolled_back`. +Отключение восстанавливает управляемые настройки либо сообщает о стандартном fallback для +распознанного старого профиля, сохраняя пользовательские поля и более поздний действительный +выбор. Конфликты и неполное восстановление не считаются завершением. Перезапустите Desktop для +чтения изменений; отключение не отзывает ключ хаба автоматически. +См. [руководство Desktop](/ru/guides/claude-code/). Повторная передача thinking и кеш остаются +отдельно в [#3719](https://github.com/lidge-jun/opencodex/issues/3719). + ## `POST /v1/live` и Realtime sideband `POST /v1/live` принимает surface Frameless call-creation из ChatGPT/Codex App. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 0995716870..347602a9d8 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -161,6 +161,8 @@ ailedeki ilk kullanılabilir rota kullanılır. Aynı profili komut satırından da yönetebilirsiniz: +Aşağıdaki profil düzenleme yönergeleri yerel profil içindir. Bağlı uzak hub üzerinden uygulama aşağıda ayrıca açıklanır. + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] @@ -247,6 +249,63 @@ ile görünmediği anlamına gelir. `claudeCode.nativePassthrough: false` ile devre dışı bırakın; `claudeCode.anthropicBaseUrl` ile başka bir yeri işaret edin. +## Uzak hub'a bağlı Claude Desktop + +Bağlı makinede `ocx claude desktop apply` veya `ocx claude desktop`, hub'ın Desktop anlık +görüntüsünü alır ve hub origin'ini ve verdiği model kimliklerini yerel Desktop yapılandırmasına +aynen yazar. Yerel takma ad üretmez. static/hybrid model listesini de kopyalar; +discovery-only listeyi gömmeden hub origin'ini kullanır. + +Profil, aile atamaları ve varsayılanlar hub'da yönetilir. Hub'da değiştirin, istemcide yeniden +uygulayın ve Desktop'ta modeli yeniden seçin. Yalnızca istemcide oluşturulmuş eski takma adlar +için de yeniden uygulama/seçim gerekir. `show`, yerel düzenleme ve import/export yerel kalır. +Bağlıyken `ocx claude desktop import --apply` desteklenmez ve kaydetmeden reddedilir; +`--apply` olmadan import yerel bir işlemdir. + +Okuma, mevcut bağlantının veri erişim kimlik bilgilerini kullanır; yönetici belirteci veya profil +yüklemesi gerekmez. Eski hub desteği yoksa, yanıt geçersizse veya Desktop listesi boşsa uygulama +başarısız olur; yerel katalog ya da loopback adresi kullanılmaz. Hub'ı güncelleyin veya +yapılandırın, ardından yeniden uygulayın. + +Bu takma ad değişikliği, [#3719](https://github.com/lidge-jun/opencodex/issues/3719)'daki ayrı `thinking` / `redacted_thinking` yeniden gönderim ve +istem önbelleği talebini çözmez. Proxy erişimi tek başına yerel Anthropic geçişini etkinleştirmez; +çevrilen Anthropic rotaları yine de önbellek kullanabilir. Yeniden gönderim doğruluğu ve önbellek +isabetlerinin karşılaştırılması ayrı iş olarak kalır. + +### Anahtar döndürme, kurtarma ve bağlantıyı kesme + +Anahtar döndürme ve kurtarma, yerel bağlantı kimlik bilgileriyle birlikte bağlantının yönettiği +Desktop profilindeki anahtarı da günceller. Yalnızca anahtarı taşımak için elle apply gerekmez. +Model kimlikleri, aileler, varsayılanlar ve geçerli profil seçimi korunur; yönetilen profil tekrar +seçilmez veya kapalı entegrasyon açılmaz. CLI JSON'unda `rotation: "committed"` yeni anahtarın +etkin olduğunu, `rotation: "rolled_back"` önceki anahtarın korunduğunu ya da geri yüklendiğini +belirtir. Geri alma, yeni anahtarın kesinleştiği veya öncekinin iptal edildiği anlamına gelmez. +Belirsiz veya eksik kurtarma başarılı döndürme olarak bildirilmez. + +İlk bağlı uygulama, geri yüklemek için önceki yönetilen ayarları ve seçimi kaydeder. Tekrar +uygulama ve döndürme bu ilk kaydı değiştirmez. `ocx disconnect`, kullanıcı alanlarını ve diğer +profilleri koruyarak bağlantıya ait ayarları geri yükler. Önceki seçim yalnızca yönetilen profil +hâlâ seçiliyse geri gelir; kullanıcının sonradan seçtiği başka geçerli profil korunur. Yeni +oluşturulmuş profile kullanıcı eklemeleri yapılmışsa silinmez, okunabilir standart modda kalır. +`--keep-catalog`, Desktop bağlantı anahtarını değil kataloğu tutar. + +İlk ayar kaydı olmayan eski yönetilen profil, geçerli hub'a ve tanınan bağlantı anahtarına açıkça +aitse taşınabilir. Apply, döndürme/kurtarma veya doğrudan disconnect bunu yeni bayrak ya da önceden +apply gerektirmeden yapar. Önceki ayarlar kaydedilmediği için bağlantı kesildiğinde standart moda +geçileceği uyarısı gösterilir. Yalnızca bağlantıya ait ağ geçidi ayarları kaldırılır; kullanıcı +alanları ve ayrı geçerli seçim korunur. Sonuç özgün ayarların geri yüklenmesi değil standart +moda dönüş olarak bildirilir. + +Yönetilen ayar çatışmaları, tanınmayan kimlik bilgileri ve bozuk geri yükleme kayıtları korunup +bildirilir. Kesilen temizlik yalnızca aynı bağlantı için sürdürülür; yeni bağlantı silinmez ve +eksik geri yükleme tamamlanmış sayılmaz. Bağlantıyı kesmeden bekleyen döndürme kurtarmasını bitirin; +yeniden denerken katalog saklama tercihini değiştirmeyin. + +Uygulama, döndürme/kurtarma veya geri yükleme sonrası Claude Desktop'ı tamamen kapatıp yeniden +açın; disk değişikliği çalışan uygulamanın anahtarını değiştirmez. Uygulama otomatik yeniden +başlatılmaz. Yerel bağlantı kesme hub anahtarını iptal etmez veya dış kopyaları silmez; +gerekirse anahtarı hub'da ayrıca iptal edin. + ## /model seçici ("From gateway") Claude Code 2.1.129+, `GET /v1/models?limit=1000` aracılığıyla ağ geçidi @@ -303,6 +362,15 @@ slug'lar karma forma geri döner. çözülür → Desktop karma takma adı çözülür → `modelMap` tam eşleşmesi → tarih kaldırılmış eşleşme (`-20250514` kaldırılır) → doğrudan geçiş. +Çözümlenemeyen tarih biçimli bir Desktop kimliği, keşifte yer almayan gerçek bir yerel model +kimliği de olabilir. Mevcut bilgi kimliği çözmeye yetmiyorsa Messages ve count-tokens sabit +`desktop_model_mapping_unavailable` hatasıyla HTTP 503 döndürür; bu, modelin geçersiz olduğunu kanıtlamaz. +Bilinmeyen eski hash takma adları HTTP 400 ile reddedilmeye devam eder. Her iki durumda da tarih +kaldırılmaz ve başka rotaya geçilmez. Bilinen kimlikler, kayıtlı eşlemeler, tam `modelMap` +eşleşmeleri ve tanınan gerçek yerel kimlikler aynı şekilde işlenir. Yeniden denemeden önce model +keşfini yenileyin veya bağlı hub profilini yeniden uygulayın; yalnızca tekrar denemek çözümü +garanti etmez. + Her girdi, `gemini-3-pro (gemini)` gibi bir görünen adın yanı sıra resmi `ModelInfo` biçiminde tam model yeteneklerini (akıl yürütme çabası merdiveni, düşünme türleri) taşır. Gerçek Anthropic modelleri her iki yüzeyde de kurallı @@ -449,6 +517,15 @@ yeniden yazar: Arama sırası: keşif takma adı → tam kimlik → tarih soneki kaldırılmış kimlik (`-20250514` kaldırılır) → doğrudan geçiş. +Çözümlenemeyen tarih biçimli bir Desktop kimliği, keşifte yer almayan gerçek bir yerel model +kimliği de olabilir. Mevcut bilgi kimliği çözmeye yetmiyorsa Messages ve count-tokens sabit +`desktop_model_mapping_unavailable` hatasıyla HTTP 503 döndürür; bu, modelin geçersiz olduğunu kanıtlamaz. +Bilinmeyen eski hash takma adları HTTP 400 ile reddedilmeye devam eder. Her iki durumda da tarih +kaldırılmaz ve başka rotaya geçilmez. Bilinen kimlikler, kayıtlı eşlemeler, tam `modelMap` +eşleşmeleri ve tanınan gerçek yerel kimlikler aynı şekilde işlenir. Yeniden denemeden önce model +keşfini yenileyin veya bağlı hub profilini yeniden uygulayın; yalnızca tekrar denemek çözümü +garanti etmez. + ## Sidecar matrisi: web araması ve görsel anlama Yönlendirilen modellerin tümü aynı barındırılan araçlara veya görsel desteğine diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 7692980e91..02fae0f468 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -353,9 +353,14 @@ sırayla kontrol edin: 2. **`disabledModels`** (üst düzey) — modelleri hem katalogdan hem de `/v1/models` listesinden gizler ve yalın yerel GPT slug'larını `visibility: "hide"` olarak değiştirir. -3. **Boş `models` ile `liveModels: false`** — canlı keşif kapalı olduğunda ve - `models` boş veya atlandığında opencodex bu sağlayıcı için hiçbir - yönlendirilmiş model göstermez. +3. **`liveModels: false`** — `liveModels: false` iken `models` boşsa veya atlanmışsa başlangıç listesine önce yapılandırılmış + `defaultModel`, ardından `retainModels` eklenir. Yinelenen kimliklerde ilk geçen korunur. + Açıkça belirtilmiş, boş olmayan `models` listesini ise `retainModels` izler; farklı bir `defaultModel` + kendiliğinden eklenmez. Bu model yine de `models` veya `retainModels` içinde açıkça belirtilebilir. + Bu alanların hiçbiri kimlik sağlamıyorsa başlangıç listesi boştur. Bu sıra, son seçici sırasını + garanti etmez. `selectedModels`, `disabledModels` ve sağlayıcının devre dışı bırakılması kuralları + geçerliliğini korur. `authMode: "forward"` ayrı dalında kalır ve bu yönlendirilmiş statik listeyi + kullanmaz. Bu kurallar canlı keşif başarısızlığındaki geri dönüş davranışını değiştirmez. 4. **Cursor `GetUsableModels`** — Cursor adaptörü modelleri `/models` üzerinden değil, protobuf `GetUsableModels` RPC'si üzerinden keşfeder; bu nedenle Cursor tarafındaki bir değişiklik diğer sağlayıcılardan bağımsız olarak hangi diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 636e86af79..fea4b37dd4 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -136,6 +136,11 @@ değişen bir değer yazıp buna başarı demek yerine durur ve bunu söyler. Do adlandırıldığını ve diskte hiçbir şeyin taşınmadığını görürsünüz. Bu dosyayı elle düzenlemek hala çalışır; yalnızca otomatik yeniden yazmamız reddeder. +TOML tarih ve saat değerleri de otomatik yeniden yazmayı engeller: birleştirme adımı, +diziler ve satır içi tablolar dahil bu türlenmiş değerleri tırnaklı metne dönüştürür. +Zaten tırnak içinde yazılmış tarihler desteklenir. Tırnaksız tarih türünü korumak +için yapılandırmayı elle düzenleyin. + **Pi, Kimi Code, Gajae Code, MiniMax Code ve yönetilen DSH entegrasyonu yalnızca geri döngü (loopback) bağlantısına karşı çalışır.** İlk dördünün yapılandırmasında geri döngü olmayan bir bağlantının gerektirdiği `x-opencodex-api-key` başlığı için alan yoktur. DSH genel bir headers haritası sunar, ancak rc.6 diff --git a/docs-site/src/content/docs/tr/guides/model-ordering.md b/docs-site/src/content/docs/tr/guides/model-ordering.md index 54a19f22d4..ea42630154 100644 --- a/docs-site/src/content/docs/tr/guides/model-ordering.md +++ b/docs-site/src/content/docs/tr/guides/model-ordering.md @@ -28,6 +28,8 @@ görünen ilk beş satırı tanıtır. İlgili seçicisiz öncelikler şunlardır: +Aşağıdaki öncelik tabloları ve örnek, seçicinin tamamını sıralama modu kapalıyken geçerlidir. + | Katalog girdisi | Öncelik | Kaynak | | --- | ---: | --- | | `subagentModels[i]` | `i` (`0` - `4`) | `src/codex/catalog/sync.ts` içindeki öne çıkan sıra haritası | @@ -129,15 +131,61 @@ yeniden sıralamaktır. Kontrol panelinin **Alt Ajanlar** sayfası yalın yerel yönlendirilen kimlikleri yeniden sıralayabilir. Tam `/` seçimleri için `ocx agent subagents set` kullanın veya opencodex yapılandırmasını düzenleyin; kontrol paneli bu seçimleri -listelemez ve kadroyu kaydederse bunları atlar. En fazla beş yapılandırılmış +önceden kaydedilen kimlikleri, kullanılamasalar bile korur. En fazla beş yapılandırılmış kimlik kullanın. Hesap seçicileriyle tek bir yalın yerel seçenek birden çok seçici nitelikli katalog satırına genişleyebilir, bu nedenle yapılandırılmış seçimler ve tanıtılan satırlar birebir olmak zorunda değildir. -Şu anda `OcxConfig` içinde genel bir `modelOrder`, `providerOrder` veya öncelik -haritası ayarı yoktur. Desteklenen sıralama alanı `subagentModels`'dır; -`disabledModels` ve her sağlayıcının `selectedModels` alanı görünürlük -alanlarıdır. Kalan seçici sırasını değiştirmek bir yapılandırma düzenlemesinden -ziyade kod düzeyinde bir davranış değişikliği gerektirir. +`modelPickerOrder` yalnızca seçicideki görüntüleme sırasını belirler. Liste yalnızca yönlendirilmiş +`/` kimlikleri içeriyorsa, listelenen ve öne çıkarılmamış satırlar ayrı bir +görüntüleme aralığında (`1000 + i`) liste sırasıyla yer alır. Listelenmeyen yönlendirilmiş satırlar +normal önceliklerini korur ve bu aralıktan önce kalır. `subagentModels` içindeki satırlar öne çıkan +önceliklerini, yerel satırlar da normal konumlarını korur. Göreli sırasını belirlemek istediğiniz +tüm yönlendirilmiş satırları listeleyin. + +Seçicinin tamamını sıralamak için `gpt-5.6-sol` gibi `/` içermeyen en az bir yalın katalog kimliği +ekleyin. Boş veya yalnızca boşluk içeren girdiler bu modu etkinleştirmez. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Listelenen satırlar önce dizi sırasıyla, listelenmeyenler ise ardından doğal öncelik sırasıyla gelir. +Eşleştirme tam katalog kimliğini kullanır: `gpt-5.6-sol` ile `openai/gpt-5.6-sol` farklı satırlardır. +Aynı yönlendirilmiş kimliğin ham ve kodlanmış yazımları da kabul edilir; tam eşleşme, eşdeğer +eşleşmeden önceliklidir. Boş ve yalnızca boşluk içeren girdiler yok sayılır. Hesaba özel satırlar +için seçiciyi içeren tam kimliği yazın. + +### Geçiş uyarısı: mevcut listelerdeki yerel kimlikler + +Önceden `modelPickerOrder` içindeki yalın yerel kimlikler yok sayılıyordu. Mevcut bir listede böyle +bir kimlik bulunması artık öne çıkan satırlar dahil tüm seçicinin sıralanmasını etkinleştirir. +Eski, yalnızca yönlendirilmiş satırlara uygulanan davranışı korumak için yalın kimlikleri kaldırın. +Tanımlanmamış, boş, yalnızca boşluk girdileri içeren veya yalnızca yönlendirilmiş kimliklerden oluşan +listeler önceki davranışlarını korur. + +`modelPickerOrder`, OpenCodex'in alt ajan rehberliği için doğal önceliğe göre en fazla beş tercih +edilen adayı seçen hesaplamasını korur. Taşınan her satırın doğal önceliği, yerel `priority` değerinden +ayrı saklanır; yalnızca seçici sırasını değiştirmek bu hesaplamanın sonucunu değiştirmemelidir. +Tam model adıyla geçersiz kılma uygunluğunu da kısıtlamaz: tanıtılan liste bir izin listesi değildir. +Mevcut kimlik doğrulama, model, effort ve arka uç kısıtlamaları geçerliliğini korur. + +Yerel Codex, `spawn_agent` içinde tanıtılacak beş modeli yerel `priority` sırasındaki uygun ve +seçicide görünür modellerden seçer. Bu, V1 ve model geçersiz kılmalarının sunulduğu V2 için geçerlidir. +Dolayısıyla OpenCodex'in tercih edilen adayları değişmese bile, tanıtılan beş model seçici sırasıyla +birlikte değişebilir. V1'e OpenCodex tercih listesi enjekte edilmez. V2, istemci katalog durumu izin +verdiğinde ek olarak doğal önceliğe dayalı OpenCodex rehberliği alabilir; bu rehberlik yerel aracın +tanıttığı listeyi yeniden sıralamaz. + +`disabledModels` ve her sağlayıcının `selectedModels` alanı +görünürlüğü denetler. Ayrı bir `modelOrder`, `providerOrder` veya öncelik haritası ayarı yoktur. + +## Kontrol paneli sıra önayarları + +**Models** sayfasında Varsayılan, Model adına göre A–Z, Sağlayıcıya göre veya Kullanım anlık görüntüsünü seçip sırayı uygulayın. Kullanılabilir yönlendirilmiş kimlikler ve `modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`) kaydedilir. Saklanan tüm kullanım yalnızca uygulamada bir kez okunur; yeniden yükleme veya model değişiklikleri yeniden hesaplamaz. Özel ve tam yerel sıra açıkça değiştirilene kadar korunur. Varsayılan, kullanılabilir model yokken bile iki alanı temizler. +`GET/PUT /api/subagent-models`, devre dışı veya eksik kayıtlı seçimleri `chosen` ve `available` içinde korur; `pickerAvailable` uygun yönlendirilmiş kimliklerdir. Models yalnızca `pickerOrder` ve `pickerOrderMode` gönderir, `models` göndermez. Yalnız kadroyu kaydetmek sırayı değiştirmez. Geçersiz giriş veya kayıt hatası önceki durumu korur. +Öne çıkan ve yerel öncelik aralıkları korunur. Sıra Codex kataloğu ve Claude keşfinin yönlendirilmiş gruplarına uygulanır; Claude yerel öneki, açık Desktop profilleri ve alias sahipliği korunur. OpenCodex rehberlik sıraları ve fallback ayarları değişmez; yerel Codex aracının sunduğu ilk beş seçenek ve önerilen varsayılan değişebilir. Kayıt istemcileri yeniden başlatmaz; katalog yenilemesi bekleyebilir ve eski kataloğu gösteren istemcinin yeniden açılması gerekebilir. diff --git a/docs-site/src/content/docs/tr/guides/model-routing.md b/docs-site/src/content/docs/tr/guides/model-routing.md index 50f3ca4ccc..c3183bdcb6 100644 --- a/docs-site/src/content/docs/tr/guides/model-routing.md +++ b/docs-site/src/content/docs/tr/guides/model-routing.md @@ -102,15 +102,16 @@ Yönlendirme ve katalog görünürlüğü ayrı kontrollerdir: - `provider.disabled: true`, bu sağlayıcıyı katalog keşfinden kaldırır. Açık `sağlayıcı/model` istekleri başarısız olur ve `defaultModel` / `models[]` taramaları bunu atlar. -- `providerContextCaps`, sağlayıcı başına Codex tarafından görülebilen bağlam - sınırlarını uygular. `contextCapValue` kontrol paneli varsayılanıdır - (varsayılan olarak 350.000), ancak bir sağlayıcı `providerContextCaps` içinde - yer alana kadar tek başına hiçbir şey yapmaz. Kontrol paneli değerini - değiştirmek, yalnızca "tüm yönlendirilen sağlayıcılara uygula" açık olduğunda - etkinleştirilmiş her sağlayıcıyı yeniden yönlendirir; aksi takdirde her - sağlayıcı kendi sınırını korur. Sınırlar yalnızca bilinen bir bağlam - penceresini düşürür; asla bir pencereyi yükseltmez veya yukarı akış modelinin - gerçek sınırını değiştirmez. +- `providerContextCaps`, sağlayıcı başına Codex tarafından görülebilen bağlam sınırlarını belirler. + `contextCapValue`, kontrol panelinin varsayılan değeridir (350.000); sağlayıcı `providerContextCaps` + içinde bulunmadıkça tek başına sınır uygulamaz. Kontrol paneli değerini değiştirmek, yalnızca + "tüm yönlendirilen sağlayıcılara uygula" açıkken etkin sınırları günceller; aksi halde her sağlayıcı + kendi sınırını korur. Bilinen normal pencereler yalnızca küçültülebilir; uzun pencereyi destekleyen + yerel modeller kendi desteklenen üst sınırlarına kadar genişletilebilir. Yukarı akış modelinin + gerçek sınırı değişmez. Sınır kapatıldığında seçim `providerContextCapValues` içinde saklanır + ve yeniden yüklemeden sonra da korunur. Yeniden açıldığında bu seçim geri yüklenir; kapalıyken + saklanan değer bir sınır uygulamaz. `value` olmadan `{ "setAll": true }`, yapılandırılmış tüm + sağlayıcıların sınırlarını geçerli genel değerle etkinleştirir ve saklanan seçimlerini değiştirir. ```json { diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index e6a6dd5f1f..f5fe66269d 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -138,6 +138,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | +Google Antigravity hesap ve sağlayıcı kota sorguları, model listesine geri dönüş dahil sabit Google uç noktalarını kullanır. Bu hedefler için şeffaf Fake-IP DNS desteklenirken TLS doğrulaması, yönlendirme reddi ve özel adres kontrolleri korunur. Özel base URL yalnızca model isteklerini değiştirir; `NO_PROXY` doğrudan bağlantı politikasını korur. + + Uç bir Nous yenileme hatasından sonra yeniden kimlik doğrulamak için `ocx login nous` çalıştırın. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index eb959bfe16..6499325908 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -60,11 +60,35 @@ Döndürme sırasında eski ve yeni anahtar aynı `apiKeyId` altında en fazla o ## Docker ve sorun giderme -Resmî Docker imajı yoktur; ancak depo, digest ile sabitlenmiş Bun imajını yerelde oluşturmak için bakımı yapılan bir `Dockerfile` ve `compose.yaml` sağlar. İlk normal başlatma, volume başına kendinden imzalı bir TLS kimliği oluşturur; herkese açık sertifika `/home/bun/.opencodex/container-tls/cert.pem`, özel anahtar ise aynı dizindedir ve yalnızca sahibi tarafından okunabilir. Veri anahtarını ilk başlatmadan önce stdin üzerinden bir kez başlatın. Yardımcı en fazla 512 baytlık tek satır kabul eder, anahtarı yazdırmaz ve `ocx-state` volume içindeki owner-only `service-api-token` dosyasını değiştirmeyi reddeder. - -Host üzerinde Git ve Bun gereklidir. Her imaj derlemesinden önce Git tarafından izlenen kaynaklardan kanonik manifesti üretin ve derleme bitene kadar kaynakları değiştirmeyin. Üretilen JSON dosyasını Git'e eklemeyin; `.git` Docker bağlamının dışında kalır. HTTPS host portu varsayılan olarak `127.0.0.1:10100` adresine bağlanır. `OPENCODEX_PORT=10190` hem yayınlanan host portunu hem de yönetilen `tls.publicOrigin` değerini `https://localhost:10190` yapar; konteyner içindeki port yine `10100` kalır. - -Manifest; `Dockerfile`, `compose.yaml`, `.dockerignore`, Git tarafından izlenen tüm `docker/` yetki dosyaları, `src/`, `package.json`, `bun.lock` ve `scripts/model-metadata.source.json` dosyalarını doğrular. Derleme her SHA-256 değerini önce bağlamdaki, ardından kopyalanan dosyalardaki baytlarla karşılaştırır; eksik veya uyuşmayan dosyaları, sembolik bağlantıları ve manifestte bulunmayan ek `src/` ya da `docker/` yetki dosyalarını reddeder. +Geri alırken iki volume'u ve bağlama yollarını koruyun. Mevcut volume sahipliği ve izinleri otomatik düzeltilmez. Compose dışındaki adlandırılmış bağlamalar ve özel durum yolları için [ana kılavuza](/guides/remote-hub/#docker-compose) bakın. + +Durum iki ayrı kalıcı volume'da tutulur: `ocx-state`, +`OPENCODEX_HOME=/home/bun/.opencodex` yoluna; `codex-state` ise +`CODEX_HOME=/home/bun/.codex` yoluna bağlanır. İki ürünün `auth.json` biçimleri +uyumsuzdur; bu dizinleri birleştirmeyin. Kök dosya sistemi salt okunur olsa da +bu iki volume yazılabilir durumda kalır. + +Katalog otomatik oluşturulmaz. Kimlik doğrulamalı `/v1/catalog` kontrolünden önce +`/home/bun/.codex/opencodex-catalog.json` konumunda geçerli bir katalog oluşturun +veya içe aktarın. Boş dizinde 404 `catalog_not_found` beklenen sonuçtur. Güncelleme +mevcut `ocx-state` volume'unu korur ve `codex-state` ekler; dosyaları otomatik taşımaz. +Önceden `.opencodex` içine konmuş kataloğu yedekleyin ve yalnızca katalog dosyasını, +sadece sahibine erişim veren izinlerle taşıyın. Bir ürünün `auth.json` dosyasını +diğerininkiyle değiştirmeyin. `CODEX_HOME` özelleştirilirse bu dizinin tam yolunu +yazılabilir bir volume'a bağlayın ve varsayılan kataloğu +`${CODEX_HOME}/opencodex-catalog.json` konumuna koyun. `model_catalog_json` başka +bir dosya seçiyorsa çözümlenen yol da kalıcı olmalıdır. Açık bir taşıma tamamlanana +kadar mevcut özel ortam ve volume eşlemesini koruyun. +`docker compose down` iki volume'u da korur; `docker compose down --volumes` hem +`ocx-state` hem `codex-state` ile birlikte kimlik bilgilerini, kullanım geçmişini, +veri anahtarını ve Codex durumunu/kataloğunu siler. Güncelleme veya yeniden başlatma +yerine kullanılmamalıdır. + +Resmî Docker imajı yoktur; ancak depo, digest ile sabitlenmiş Bun imajını yerelde oluşturmak için bakımı yapılan bir `Dockerfile` ve `compose.yaml` sağlar. İlk başlatmadan önce veri anahtarını stdin üzerinden bir kez başlatın; anahtar yazdırılmaz ve `ocx-state` volume içinde yalnızca sahibinin okuyabileceği izinlerle saklanır. + +Host üzerinde Git ve Bun gereklidir. Her imaj derlemesinden önce Git tarafından izlenen kaynaklardan kanonik manifesti üretin ve derleme bitene kadar kaynakları değiştirmeyin. Üretilen JSON dosyasını Git'e eklemeyin; `.git` Docker bağlamının dışında kalır. Host portu varsayılan olarak `127.0.0.1` adresine bağlanır. Uzak erişim için açıkça `OPENCODEX_BIND_ADDRESS= docker compose up -d` kullanın; `0.0.0.0` tüm arayüzleri açar. Erişimi güvenlik duvarı ve kimlik doğrulamalı TLS/tailnet ön ucu ile koruyun. + +Derleme, her SHA-256 değerini önce bağlamdaki, ardından kopyalanan dosyalardaki baytlarla karşılaştırarak eski manifestleri reddeder. Eksik veya uyuşmayan dosyalar, fazladan kaynak dosyaları ve sembolik bağlantılar reddedilir. `package.json`, `bun.lock` ve `scripts/` içinden yalnızca dahil edilen `scripts/model-metadata.source.json` zorunludur. ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -75,28 +99,7 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -Varsayılan HTTPS uç noktasını doğrulamak için yalnızca açık sertifikayı dışarı kopyalayın ve CA olarak kullanın: - -```bash -mkdir -p .tmp -docker compose cp hub:/home/bun/.opencodex/container-tls/cert.pem .tmp/opencodex-container-ca.pem -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/healthz -``` - -Konteyner içi liveness/readiness kontrolleri sertifika doğrulamasını yalnızca sabit konteyner loopback hedefinde devre dışı bırakabilir. Harici kabul testleri ise tam host adını kopyalanmış açık sertifika veya sistem güven deposuyla doğrulamalıdır. - -Uzak erişim açıkça etkinleştirilmelidir: `OPENCODEX_BIND_ADDRESS= docker compose up -d`; `0.0.0.0` tüm arayüzleri açar. Üretilen sertifika yalnızca `localhost` ve `127.0.0.1` için geçerlidir. Yerel yayını koruyup kimlik doğrulamalı bir TLS/tailnet ön ucu kullanın veya uzak ad için özel sertifika/anahtar yollarını ve tam HTTPS `tls.publicOrigin` değerini yayına açmadan önce ayarlayın. Her iki durumda da güvenlik duvarı kullanın. - -Saklanan TLS öncesi bir volume, sonraki `docker compose up -d` sırasında volume kimliği ve `OPENCODEX_PORT` tabanlı HTTPS origin eklenerek otomatik taşınır; operatörün özel sertifika yolları korunur. Eski, yalnızca HTTP imajına geri dönmeden önce mevcut imajla yalnızca TLS ayarını kaldırın; kimlik dosyaları volume içinde kalabilir: - -```bash -docker compose down -docker compose run --rm hub bun run src/cli/index.ts config unset tls -# eski imajı seçin/derleyin, sonra hub'ı yeniden oluşturun -docker compose up -d -``` - -Konteyner root olmayan `bun` kullanıcısıyla, salt okunur kök dosya sistemiyle çalışır ve yalnızca `10100` portunu yayımlar. `10101` portunu yayımlamayın ve sırları `ARG`, `ENV`, `COPY`, Compose, imaj geçmişi veya argv içine koymayın. Healthcheck sonrasında HTTPS `/readyz`, kimlik doğrulamalı katalog ve gerçek yanıtı ayrıca doğrulayın. `docker compose down` volume'u korur; `docker compose down --volumes` yapılandırmayı, TLS kimliğini, kimlik bilgilerini ve veri anahtarını da siler. +Konteyner root olmayan `bun` kullanıcısıyla, salt okunur kök dosya sistemiyle çalışır ve yalnızca `10100` portunu yayımlar. `10101` portunu yayımlamayın ve sırları `ARG`, `ENV`, `COPY`, Compose, imaj geçmişi veya argv içine koymayın. Healthcheck sonrasında readiness, kimlik doğrulamalı katalog ve gerçek yanıtı ayrıca doğrulayın. `docker compose down` volume'u korur; `docker compose down --volumes` yapılandırmayı, kimlik bilgilerini ve anahtarı da siler. - Hub kapalıysa yerel geri dönüş yapılabilir; uzaktaki anahtarın iptali bekler. - Geçici arızada doğrulanmış LKG korunur; auth, şema, boyut veya protokol hatasında yerel fallback yoktur. diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md index dd7aa50d72..74ab8a7bdc 100644 --- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md @@ -52,6 +52,14 @@ ayrıdır. ## Kaydedilmiş bir profilde deneme çalıştırması (dry-run) yapma +Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlayıcı +yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) +bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, +adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. +Çözümlenemeyen geçersiz sağlayıcı yapılandırmaları, bilinmeyen yeteneklere izin +verilse bile `route-unavailable` ile her zaman dışlanır. +Eksik veya devre dışı sağlayıcılar da puanlama öncesinde `route-unavailable` ile dışlanır. + Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme çalıştırması değerlendirmesi (Dry-run evaluation)**'ı kullanın. Deneme @@ -99,5 +107,3 @@ Düzenleyici şu uç noktaları kullanır: } } ``` - - diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index d6163a4210..eb4634ab3e 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -60,6 +60,14 @@ kararıdır. | **Depolama** | Salt okunur CODEX_HOME disk dökümü (oturumlar, arşivler, DB'ler, ekler). İsteğe bağlı arşivlenmiş temizleme: en eski %N'yi önizleyin, ardından `CODEX_HOME/.trash` konumuna karantinaya alın (varsayılan) veya açık bir onay kutusu arkasında kalıcı olarak silin. **Otomatik temizleme politikası** isteğe bağlıdır ve **varsayılan olarak KAPALIDIR** (`storageCleanupPolicy.enabled`); Depolama sayfasında eşik/hedef/zamanlama/mod yapılandırın veya **Şimdi çalıştır (Run now)**'ı tetikleyin. Karantinaya alınan girdiler Depolama sayfasından geri yüklenebilir (JSONL + iş parçacıkları). Aktif oturumlar salt okunur kalır. Codex en yeni/aktif `state_*.sqlite` dosyasını kilitli tuttuğu sürece temizleme ve geri yükleme reddedilir. | | **Durdur** | Proxy'yi ve kurulu arka plan servisini zarif bir şekilde durdurun, yerel Codex'i geri yükleyin ve çıkın (`POST /api/stop`). Windows'ta Görev Zamanlayıcı arka ucunda panel reddeder ve `ocx stop` çalıştırmanızı ister: görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir ve bu yeniden başlatma penceresini istemci yapılandırmanız geri yüklenmeden önce yalnızca proxy dışında çalışan bir stop doğrulayabilir. Reddedildiğinde hiçbir şey değiştirilmez. | +### İstek günlüklerini filtreleme + +Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. + +Son 15 dakika, saat ve gün pencereleri Logs sekmesinde otomatik yenileme kapalıyken de 30 saniyede bir güncellenir. Hız, tam istek süresindeki saniyelik çıktı jetonudur: 15 altı, 15 dahil 50 altı, en az 50; hız filtresi açıkken ölçülemeyenler dışlanır. Başarı 2xx, hata 4xx/5xx anlamındadır. + +Sayaç eşleşen ve yüklü toplam sayıları gösterir; sıfırlama tüm satırları geri getirir. Eşleşme olmaması boş günlükten ayrılır. Yüzey seçimi oklar ve Home/End ile çalışır. Yüklü günlüğün dışındaki geçmiş sorgulanmaz. + ### Bir bölüme bağlantı verme Tek bir duyarlı düzen vardır, bu nedenle yapılandırılacak bir düzen anahtarı yoktur. Masaüstünde ana @@ -91,6 +99,25 @@ ayarlanmadığında) ve devre dışı bırakılmadığında açıktır. Bir mode iki filtreyi de atomik olarak uzlaştırır; **Tümünü aç (All on)** sağlayıcı izin listesini temizler, böylece yeni keşfedilen modeller de açık olur. +### Sağlayıcı çalışma alanında modelleri yönetme + +Sağlayıcının **Modeller** sekmesinde **Sil**, kayıtlı özel tanımı kaldırır. Alttaki yerel veya canlı +keşfedilmiş model yeniden görünebilir; bu nedenle model sayısı aynı kalabilir. **Gizle** yalnızca +katalog görünürlüğünü değiştirir; tanımı silmez veya doğrudan yönlendirme ilkesini değiştirmez. +**Modeller bölümünde görünürlüğü yönet**, görünürlüğü geri yükleyebileceğiniz **Modeller** sayfasını +açar. Sağlayıcı sekmesinde hiç satır kalmasa da bu bağlantı kullanılabilir. + +**Ekle**, özel tanımı kaydeder; mevcut gizleme durumunu veya sağlayıcı seçim kurallarını kaldırmaz. +Kaydedilen model gizli kalabilir. Model zaten biliniyorsa görünürlüğünü **Modeller** bölümünden +yönetin. Kayıt doğrulandıysa katalog yenilemesi başarısız olsa bile tanım kaydedilmiştir. Yeniden +eklemek yerine yenileme mesajını izleyin. Değişiklik doğrulanamıyorsa tekrar denemeden önce +modellerin durumunu yenileyin. + +Sağlayıcının model sayısı, sunucunun döndürdüğü güncel model envanterindeki devre dışı olmayan +benzersiz girdileri, arama ve görüntüleme sınırı uygulanmadan önce sayar. Bu sayı izin listesinin +boyutu veya canlı keşif sayısı değildir; girdinin sağlayıcıdan keşfedildiğini de kanıtlamaz. +Seçim rozetleri ve keşif bilgileri bu sayıdan ayrıdır. + ## Yetkilendirme seçicisi ve spawn yönlendirmesi Kontrol Panelinin **Alt ajan yetkilendirmesi** seçicisi `injectionModel`'i ve @@ -256,7 +283,7 @@ noktalar şunları içerir: | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Bir sonraki istek için hesabı seçin ve havuz yönlendirmesini yapılandırın. | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | Geçerli hesabı okuyun (`pinned` ve hangi hesabın `pinnedAccountId` olduğu dahil) ve bir hesabın seçim sırasını ayarlayın. | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Tarayıcı girişi aracılığıyla bir havuz hesabı ekleyin. | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | İsteğe bağlı kuyruk, sağlayıcı ve tam/sınıf durum filtreleriyle son istek meta verilerini okuyun. `limit`/`offset` ile sayfalama en yeni satırdan geriye doğru ilerler (`offset=0` en son sayfayı döndürür). Yanıt şekli: `{ timeZone, total, logs }` burada `total`, sayfalamadan önceki filtrelenmiş satır sayısıdır. | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | İsteğe bağlı kuyruk, sağlayıcı ve tam/sınıf durum filtreleriyle son istek meta verilerini okuyun. `limit`/`offset` ile sayfalama en yeni satırdan geriye doğru ilerler (`offset=0` en son sayfayı döndürür). Yanıt şekli: `{ timeZone, generatedAt, total, logs }` burada `total`, sayfalamadan önceki filtrelenmiş satır sayısıdır. | | `GET` / `PUT /api/subagent-models` | Öne çıkan beş `spawn_agent` geçersiz kılma modelini okuyun veya ayarlayın. | | `POST /api/stop` | Proxy'yi/servisi durdurun, yerel Codex'i geri yükleyin ve çıkın. Windows Görev Zamanlayıcı arka ucunda `respawnable_service`, bu durum okunamadığında `service_state_unknown` ile reddedilir; her iki durumda da hiçbir şey değiştirilmez. | diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index e26f4c8662..6a7a565139 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -244,6 +244,12 @@ kullanıcı birimi**, Windows **Görev Zamanlayıcı**) olarak çalıştırın. çalıştırmaları `OCX_SERVICE=1` ayarlar, böylece bir yeniden başlatma Codex yapılandırmasını dalgalandırmaz. +Windows Görev Zamanlayıcı kurulumları normal işlem önceliğini (`Priority=4`) kullanır. Eski arka plan +önceliği (`7`; değer belirtilmediğinde de zamanlayıcının varsayılanı `7` olur), CPU çekişmesi sırasında +sağlık denetimi yanıtlarını geciktirebilir ve işlem çalışırken bile sistem tepsisinde Offline görünmesine neden olabilir. +Güncellemeden sonra kayıtlı bu önceliği değiştirmek ve servisi yeniden başlatmak için `ocx service repair` komutunu çalıştırın. +UAC onayı gerekebilir. Zaten normal veya yüksek öncelik ayarlanmışsa yalnızca öncelik nedeniyle yeniden kayıt yapılmaz. + | Alt komut | Eylem | | --- | --- | | none | Servis yoksa kurup başlatın; varsa yenileyip yeniden başlatın. Sağlıklı bir Windows Task Scheduler tanımı yeniden kullanılır; eski bir tanım yeniden kaydedilebilir ve yükseltme gerektirebilir. | diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 01a844606a..d854566d10 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -229,13 +229,11 @@ eşleşen null veya eski bir rapora düşer (çıkış 0). ### `ocx account auto-switch > [--json]` -Yalnızca `openai` Codex hesap havuzunu denetler. `on` %80'i ayarlar, `off` %0'ı -ayarlar, `status` geçerli değeri okur ve `threshold ` 0 ile 100 arasında bir -tamsayı kabul eder. Diğer sağlayıcılar ve geçersiz değerler 1 ile çıkar. -`--json` şunu döndürür: +`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşikleri şu anda uygulanmaz: kayıt işlemi eşik tabanlı geçişi, sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Genel havuz çıktısı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true`, eşiğin uygulanmadığını belirtir; yetenek bilinmiyorsa `enabled: true` bildirilmez. API anahtarlı sağlayıcılar, Anthropic ve geçersiz değerler reddedilir. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/tr/reference/configuration/agents.md b/docs-site/src/content/docs/tr/reference/configuration/agents.md index 251f46c9c9..b0bce7a166 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/tr/reference/configuration/agents.md @@ -122,7 +122,9 @@ görevlerinde zincir, kurallı yerel ChatGPT hedefleriyle ve `allowEncryptedV2AgentTasks: true` kullanılarak açıkça güvenilen doğrudan anahtar kimlik doğrulamalı Responses rotalarıyla sınırlıdır. Hiçbiri şifrelenmiş yükü işleyemezse istek, okunamayan şifreli metni başka bir yere yönlendirmek yerine -başarısız olur. Kombolar yalnızca kurallı yerel hedefleri kullanmaya devam eder. +başarısız olur. Kombo önce kullanılabilir kurallı yerel hedefi dener; seçilebilir +yerel hedef kalmazsa ve `agentTaskRecovery` etkinse, şifrelenmiş `NEW_TASK` yönlendirilen +kombo gönderiminden önce bir kez kurtarılır. ```json { @@ -226,10 +228,13 @@ sınırı ve özel arka uç bağımlılığı kabul edilebilir olduğunda etkinl Olmadıklarında yerel bir ChatGPT çocuğunu veya v1 heterojen yetkilendirmesini tercih edin. -Bu kurtarma yolu doğrudan yönlendirilen çocuklara uygulanır. Aynı anda en fazla -32 kurtarma isteği etkin olabilir; ek ıskalamalar kapalı olarak başarısız olur. -Kombo yönlendirmesi şifrelenmiş görevler için mevcut yalnızca yerel filtresini -korur ve kurtarmayı çağırmaz. +Bu kurtarma yolu doğrudan yönlendirilen çocuklara ve bir kombodaki şifrelenmiş +`NEW_TASK` oluşturma isteklerine uygulanır. Aynı anda en fazla 32 kurtarma isteği +etkin olabilir; ek ıskalamalar kapalı olarak başarısız olur. Kullanılabilir kanonik +yerel hedefi olan bir kombo şifreli metni yine doğrudan gönderir; kurtarma yalnızca +seçilebilir yerel hedef kalmadığında çalışır. Kurtarma hatası, tükenen hedefler veya +kullanılamayan hedefler, şifreli metin yönlendirilen sağlayıcıya gönderilmeden yine +kapalı biçimde başarısız olur. ## Çaba sınırları @@ -248,4 +253,3 @@ ile `xhigh` arasını sunar. v1, varsayılan ve v2 davranışının yeni başlayanlara yönelik açıklaması için [Alt ajan yüzeyleri](/tr/guides/sub-agent-surface/) sayfasına bakın. - diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 43b74e687f..274664c323 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -34,8 +34,9 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `providers` | `Record` | — | Sağlayıcı adından sağlayıcı yapılandırmasına eşleme haritası. | | `openaiProviderTierVersion?` | `2` | geçiş tarafından ayarlanır | Tek seçenek duyarlı OpenAI projeksiyonunu tamamlandı olarak işaretler. | | `disabledModels?` | `string[]` | — | Codex kataloğundan ve `/v1/models` listesinden gizlenen, ancak doğrudan proxy çağrılarından engellenmeyen modeller. Yönlendirilen bir kimlik listelerden kaldırılır. Hesap nitelikli bir yerel kimlik yalnızca o seçici satırını gizler; yalın bir yerel GPT kimliği, yalın satırı ve o model için her hesap seçici satırını gizler. Kontrol paneli Modeller sayfası yalnızca yönlendirilen ve yalın yerel satırları gösterir; seçici nitelikli bir satırı gizlemek için doğrudan bu yapılandırma alanını kullanın. | -| `providerContextCaps?` | `Record` | `{}` | Sağlayıcı başına Codex tarafından görülebilen bağlam sınırları. Bir sınır yalnızca bilinen bir bağlam penceresini düşürür. | -| `contextCapValue?` | `number` | `350000` | Kontrol paneli bağlam sınırı kontrolleri tarafından kullanılan varsayılan değer. Değiştirilmesi, yalnızca "tüm yönlendirilen sağlayıcılara uygula" açık olduğunda değeri mevcut bir `providerContextCaps` girdisi olmayan sağlayıcılar da dahil olmak üzere yönlendirilen her sağlayıcıya uygular; aksi takdirde her sağlayıcı kendi sınırını korur. | +| `providerContextCaps?` | `Record` | `{}` | Sağlayıcı başına etkin bağlam sınırları. Normal pencereler küçültülür; uzun pencereyi destekleyen yerel modeller yalnızca kendi desteklenen üst sınırlarına kadar genişletilebilir. | +| `providerContextCapValues?` | `Record` | `{}` | Sağlayıcı başına son seçilen sınırlar; devre dışı bırakıldığında da saklanır. Bu değerler tek başına sınırı etkinleştirmez. Etkin değer, saklanan değerden önceliklidir. | +| `contextCapValue?` | `number` | `350000` | İlk etkinleştirmede kullanılan varsayılan değer. Sonraki etkinleştirmelerde sağlayıcının seçimi geri yüklenir. Genel değeri `setAll: true` ile güncellemek yalnızca etkin sınırları değiştirir; değer olmadan `setAll: true`, yapılandırılmış tüm sağlayıcıların sınırlarını geçerli genel değerle etkinleştirir. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth tarafından yönetilen ChatGPT/Codex havuz hesabı meta verileri. Sırlar ayrı olarak `codex-accounts.json` içinde yer alır. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Duraklatıldığında ana `__main__` hesabı da dahil olmak üzere, devam ettirilene kadar Havuz seçiminden hariç tutulan hesaplar. | | `codexAccountNamespaces?` | `Record` | — | İsteğe bağlı olarak rastgele bir genel model seçiciden saklanan bir Codex hesap hedefine eşleme. Hesap nitelikli seçici satırları etkinleştirildiğinde, hedefi mevcut olan her seçici, Codex seçicisine ayrı `/` satırları ekler; her satır yalnızca o hesabı kullanır. Herhangi bir seçici etkinken, yalın yerel satırlar seçicide gizlenir, ancak açıkça devre dışı bırakılmadıkça kimlikleri yönlendirilebilir kalır ve ham `/v1/models` tarafından listelenir. | @@ -108,7 +109,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic anahtar başlığı stili. Varsayılan olarak yerel `x-api-key`; yalnızca anahtar kimlik doğrulamalı `anthropic` sağlayıcıları için geçerlidir. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Çoklu anahtar havuzu. `apiKey` aktif girdiyi yansıtır; her öğe `id`, `key`, isteğe bağlı `label` ve isteğe bağlı sayısal `addedAt` değerine sahiptir. | | `defaultModel?` | `string` | Bu sağlayıcı açık bir model olmadan seçildiğinde kullanılan model. | -| `models?` | `string[]` | Tohum/geri dönüş model listesi. `liveModels: false` olduğunda bunlar keşfedilen tek modellerdir. | +| `models?` | `string[]` | Başlangıç/geri dönüş model listesi. `liveModels: false` iken boş olmayan `models` listesini `retainModels` izler; `models` boşsa veya atlanmışsa önce yapılandırılmış `defaultModel`, sonra `retainModels` kullanılır. Yinelenen kimliklerin yalnızca ilk geçtiği konum korunur. | | `liveModels?` | `boolean` | Başlatmada/senkronizasyonda canlı kataloğu getirin (varsayılan `true`). Özel sağlayıcılar `${baseUrl}/models` kullanır; yerleşikler bir kayıt defteri URL'si ve filtresi kullanabilir. | | `selectedModels?` | `string[]` | Keşiften sonra katalog izin listesi. Boş olmaması yalnızca bu kimlikleri gösterir; boş veya atlanmış olması keşfedilen tüm modelleri gösterir. | | `contextWindow?` | `number` | Yukarı akış meta verileri olmadığında sağlayıcı genelinde bağlam geri dönüşü; aksi takdirde daha küçük canlı meta verileri koruyan bir sınır. Modeller kontrol paneli bunu `providerContextCaps` alanından ayrı olarak gösterir. | @@ -488,8 +489,16 @@ uygulamadan önce yerel `zai/glm-5.2` kimliğini geri yükler. Aynı eşleme yer ## Statik model izin listeleri -Yalnızca `models`'ı göstermek için `liveModels: false` ayarlayın. `models` boşsa -veya atlanırsa sağlayıcı yönlendirilen hiçbir modeli göstermez. Canlı keşif, +`liveModels: false` iken `models` boşsa veya atlanmışsa başlangıç listesine önce yapılandırılmış +`defaultModel`, ardından `retainModels` eklenir. Yinelenen kimliklerde ilk geçen korunur. +Açıkça belirtilmiş, boş olmayan `models` listesini ise `retainModels` izler; farklı bir `defaultModel` +kendiliğinden eklenmez. Bu model yine de `models` veya `retainModels` içinde açıkça belirtilebilir. +Bu alanların hiçbiri kimlik sağlamıyorsa başlangıç listesi boştur. Bu sıra, son seçici sırasını +garanti etmez. `selectedModels`, `disabledModels` ve sağlayıcının devre dışı bırakılması kuralları +geçerliliğini korur. `authMode: "forward"` ayrı dalında kalır ve bu yönlendirilmiş statik listeyi +kullanmaz. Bu kurallar canlı keşif başarısızlığındaki geri dönüş davranışını değiştirmez. + +Canlı keşif, önbelleğe almadan önce 4 MiB'den veya 2.000 ham model satırından fazlasını reddeder; yerleşik önayarlar daha düşük sınırlar kullanabilir ve sohbete uygun satırlara filtre uygulayabilir. Büyük boyutlu veya hatalı biçimlendirilmiş diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 5d132047a9..6c67ab3884 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -307,3 +307,7 @@ yeniden kullanır. Hedeflenen hesap ve iş yükünü kapsamlı bir şekilde test `runtimeRole` varsayılan olarak `standalone` değerindedir. Hub; `hub.managementPublicOrigin`, yalnız loopback `hub.managementIngress` (yokken `enabled:false`) ve tam `remoteGui.allowedTailscaleUsers` (yokken boş) kullanır. İstemci anahtarı `config.json` yerine `service-api-token` içinde kalır; döndürme sırasında `service-api-token.prev` geçici olarak bulunabilir. Kullanım kayıtları yansıtılmaz. `remoteGui.allowInsecureHttp`, yalnızca eski strict-schema yapılandırmalarının yüklenebilmesi için tutulan, kullanımdan kaldırılmış bir no-op'tur. Yapılandırmadan silin: pairing grant'leri yalnız loopback veya kimliği doğrulanmış HTTPS üzerinden kabul edilir ve `true` değeri düz HTTP pairing'i yeniden açmaz. + +## Codex kota ağı tanılaması + +Ana Codex hesabının satırındaki `quotaRefresh`, kalan kotayı veya model erişim yetkisini değil, kota sorgusunun sonucunu açıklar. Önbellek kullanıldığında ya da sorgu yapılmadığında alan bulunmayabilir. Sorgu, etkileşimli terminalin değil çalışan proxy servisinin ortamını kullanır. `proxy` ayarlanmazsa mevcut ortam korunur; `"auto"` yalnızca başlangıçta Windows’un statik proxy ayarlarını okur. PAC/WPAD, yalnızca SOCKS ayarları ve çalışma sırasındaki değişiklikler otomatik uygulanmaz. TUN ile başarı, HTTP proxy yolunun da çalıştığını tek başına göstermez. [Komutlar ve durumlar için İngilizce bölüme](/reference/configuration/server/#codex-quota-network-diagnostics) bakın. diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index bce183ae81..e8cdb58888 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -184,12 +184,15 @@ gönderin. Kurtarma gerekebileceğinde karantinayı tercih edin. | `GET /api/models` | Kontrol paneli/CLI model satırlarını döndürün | Toplama doyduğunda `catalog_busy` | | `GET /api/client-config?client=...` | Desteklenen herhangi bir dosya entegrasyonu için salt okunur bir istemci yapılandırması oluşturun | 400 desteklenmeyen istemci; 503 katalog kullanılamıyor | | `PUT /api/disabled-models` | Paylaşılan devre dışı model listesini değiştirin | 400 geçersiz JSON | -| `PUT /api/model-visibility` | Sağlayıcı veya model düzeyindeki görünürlüğü atomik olarak değiştirin | 400 geçersiz sağlayıcı, kapsam, hedef veya gövde | +| `PUT /api/model-visibility` | Sağlayıcı veya model düzeyindeki görünürlüğü atomik olarak değiştirin | 400 geçersiz sağlayıcı, kapsam, hedef veya gövde; 409 `initial_model_selection_pending` (Model listesini yenileyip tekrar deneyin.) | | `GET, POST /api/custom-models` | Özel modelleri listeleyin veya bir tane ekleyin | 400 geçersiz alanlar; 404 sağlayıcı eksik; 409 yinelenen model | | `PUT, DELETE /api/custom-models/{id}` | Bir özel modeli düzenleyin veya silin | 400 geçersiz kimlik/alanlar; 404 bulunamadı; 409 yinelenen model | | `GET, PUT /api/selected-models` | Sağlayıcı izin listelerini ve kullanılabilirliğini okuyun veya bir izin listesini değiştirin | 400 eksik sağlayıcı/gövde; 404 bilinmeyen sağlayıcı; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Ön ayarları okuyun veya preset/all/custom modunu seçin | 400 geçersiz mod veya desteklenmeyen ön ayar; 404 bilinmeyen sağlayıcı; PUT 409 `initial_model_selection_pending` | +Manuel model, Models panosunda aynı sağlayıcı ve model kimliğine sahip satırın yerini alır. OpenAI manuel satırı `openai/` kimliğini ve görünürlük kontrollerini korur. Silindiğinde hesap niteleyicisi olmayan yerel satır geri gelir. Hesapla nitelenen yerel satırlar ayrı kalır. Yerel rotalar ve hesap yetkileri değişmez. Yerel olmayan OpenAI görünürlük hedefi, yapılandırılmış bir manuel modelle eşleşmelidir. + + Güvenilir ilk model listesi hazır olana kadar `/api/selected-models` ve `/api/model-presets` için geçerli PUT istekleri de HTTP 409 ve `initial_model_selection_pending` kodunu döndürür. Model keşfini örneğin `GET /api/models` ile yenileyin ve başarılı olduktan sonra yeniden deneyin. ### OAuth hesapları, sağlayıcı anahtarları ve veri düzlemi anahtarları @@ -230,6 +233,17 @@ döndürülmez. | `GET, PUT /api/provider-context-caps` | Küresel, tüm sağlayıcılar veya tek sağlayıcı bağlam sınırlarını okuyun veya güncelleyin | 400 geçersiz istek; 404 bilinmeyen sağlayıcı | | `GET /api/provider-presets` | Çalışma zamanı kayıt defterinden türetilen GUI sağlayıcı önayarlarını döndürün | — | +Bağlam sınırı yanıtı `caps` (etkin sınırlar) ve `values` (devre dışıyken de saklanan son seçimler) +alanlarını içerir. Sağlayıcının sınırını `value` olmadan etkinleştirmek seçimini geri yükler; +ilk etkinleştirmede genel `contextCapValue` kullanılır. Bu kural OpenAI için de geçerlidir: +anahtar özel bir 922k modu seçmez. Etkin sınır tüm yerel pencereleri sınırlar; uzun bağlamı +destekleyen modeller yalnızca kendi desteklenen üst sınırlarına kadar genişletilebilir. +`{ "value": 600000, "setAll": true }`, genel değeri ve yalnızca etkin sınırları günceller. +Sınırı kapalı olan sağlayıcılar, daha sonra yeniden etkinleştirildiğinde kullanılacak seçimlerini korur. +`value` olmadan `{ "setAll": true }`, yapılandırılmış tüm sağlayıcıların sınırlarını geçerli genel +değerle etkinleştirir ve saklanan seçimlerini değiştirir. Devre dışı bırakmak seçimi silmez; +yeniden yüklemeden sonra da saklar, ancak bir sınır olarak uygulamaz. + `provider_has_dependent_combos` bir güvenlik engelidir: sağlayıcılarını silmeden önce bağımlı komboları kaldırın veya düzenleyin. diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 185d0de45c..9625887762 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -31,7 +31,7 @@ genel model kimliği birkaç hedef arasından seçim yapması gerektiğinde | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `[DONE]` ile biten `chat.completion.chunk` SSE | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic belirteç sayısı | `POST /v1/messages/count_tokens` | `{ "input_tokens": sayi }` | Geçerli değil | -| Model keşfi | `GET /v1/models` | Üç katalog sözleşmesinden biri | Geçerli değil | +| Model keşfi | `GET /v1/models` | Katalog veya açıkça istenen Desktop anlık görüntüsü | Geçerli değil | | Ses ve Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | İletilen çağrı oluşturma yanıtı | Ayrı bir yan bant WebSocket her iki yönde de çerçeveleri iletir | | Responses sıkıştırması | `POST /v1/responses/compact` | Değiştirme geçmişi JSON'ı | Geçerli değil | @@ -235,10 +235,18 @@ yerel belgelenmiş tahmini kullanır ve şunu döndürür: { "input_tokens": 123 } ``` +Çözümlenemeyen tarih biçimli bir Desktop kimliği, keşifte yer almayan gerçek bir yerel model +kimliği de olabilir. Mevcut bilgi kimliği çözmeye yetmiyorsa Messages ve count-tokens sabit +`desktop_model_mapping_unavailable` hatasıyla HTTP 503 döndürür; bu, modelin geçersiz olduğunu kanıtlamaz. +Bilinmeyen eski hash takma adları HTTP 400 ile reddedilmeye devam eder. Her iki durumda da tarih +kaldırılmaz ve başka rotaya geçilmez. Bilinen kimlikler, kayıtlı eşlemeler, tam `modelMap` +eşleşmeleri ve tanınan gerçek yerel kimlikler aynı şekilde işlenir. Yeniden denemeden önce model +keşfini yenileyin veya bağlı hub profilini yeniden uygulayın; yalnızca tekrar denemek çözümü +garanti etmez. + ## `GET /v1/models` -Aynı rota uyumsuz katalog zarfları bekleyen üç istemciye hizmet verir. -`client_version` da mevcut olmadıkça Anthropic türü kazanır. +`format=desktop-config` belirtilmezse aşağıdaki olağan katalog sözleşmeleri kullanılır: | Sözleşme | Tetikleyici | Üst düzey şekil | Model kimliği davranışı | | --- | --- | --- | --- | @@ -246,6 +254,29 @@ Aynı rota uyumsuz katalog zarfları bekleyen üç istemciye hizmet verir. | Codex kataloğu | `client_version` sorgu parametresi | `{ "models": [...] }` | Yerel ve yönlendirilen girdiler daha zengin Codex katalog alanlarını, görünürlüğü, çabayı, WebSocket ve çoklu ajan meta verilerini taşır | | Düz OpenAI listesi | Hiçbir tetikleyici yok | `{ "object": "list", "data": [...] }` | Görünür yerel kimlikler yalındır; yönlendirilen kimlikler takma adlar veya `sağlayıcı/model`'dir | +### Desktop yapılandırma anlık görüntüsü + +`GET /v1/models?ids=desktop&format=desktop-config`, user-agent'tan bağımsız olarak Desktop +anlık görüntüsünü seçer. Yanıt `{ "version": 1, "models": [...] }` ve `Cache-Control: no-store` +başlığıdır. İstemci `Accept: application/json`, `anthropic-version: 2023-06-01` ve mevcut veri +erişim kimlik bilgilerini gönderir; yönetici belirteci veya profil yüklemesi gerekmez. +Girdiler Codex katalog satırları değil, hub'ın verdiği Desktop yapılandırma modelleridir. + +Bu biçim `ids=cli` veya herhangi bir `client_version` ile kullanılırsa HTTP 400 döner. Biçim +seçicisi yoksa yukarıdaki olağan sözleşmeler değişmez. Claude kapalıysa +`{ "version": 1, "models": [] }` döner; bağlı Desktop apply bunu kullanılamaz sayar ve yeni +profil yazmaz. Sürüm 1 yerine olağan katalog döndüren eski hub'lar desteklenmez; yerel üretilmiş +kimliklere geçilmez. + +Anlık görüntü salt okunur model listesidir; anahtar döndürme veya profil yükleme API'si değildir. +Desktop anahtar taşıma, kurtarma ve bağlantıyı kesme mevcut istemci yaşam döngüsünü kullanır. +Döndürme modelleri ve seçimi korur; CLI `rotation` alanı `committed` ile `rolled_back` sonucunu +ayırır. Bağlantıyı kesme yönetilen ayarları geri yükler veya tanınan eski profil için standart +moda dönüşü bildirir; kullanıcı alanları ve sonraki geçerli seçimler korunur. Çatışma veya eksik +kurtarma tamamlanmış sayılmaz. Disk değişiklikleri için Desktop'ı yeniden başlatın; bağlantıyı +kesmek hub anahtarını otomatik iptal etmez. [Desktop kılavuzuna](/tr/guides/claude-code/) bakın. +Thinking yeniden gönderimi ve önbellek, ayrı [#3719](https://github.com/lidge-jun/opencodex/issues/3719) işidir. + ## `POST /v1/live` ve Realtime yan bandı `POST /v1/live`, ChatGPT/Codex App Frameless çağrı oluşturma yüzeyini kabul diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index cea5b3c233..10bc3799cc 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -57,7 +57,22 @@ runtime the leak itself remains an upstream problem: time show whether failures are accumulating or recovering in the same process. The last failure is a fixed privacy-safe class such as `EACCES`, `ENOSPC`, `ETIMEDOUT`, or `EACLRETRYEXHAUSTED`; raw error messages and filesystem paths - are never returned. These diagnostics stay on the authenticated management + are never returned. `spillLastWriteFailureOrigin` adds a fixed origin or null: + `retry_returned_timeout` means the existing second spill attempt returned a + timeout; `timeout_memo_refusal` means the ACL helper refused through its + remembered timeout state. Other failures use null. The cumulative + `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal + failed publications, not individual ACL commands or transient first attempts. + Success clears the failure streak but retains the last failure fields and + cumulative counts; a later unrelated failure sets the last origin to null. + These values are process-local, so compare snapshots from the same process. + Neither origin identifies an OS command: the attempt budget can expire before + a command starts, and an optional compliance inspection can run before a memo + refusal. A separate process succeeding does not prove that the live process's + memo recovered. These observations do not add retries, clear memos, weaken + required ACLs, or automatically restart the service. + + These diagnostics stay on the authenticated management endpoint and are intentionally absent from `/healthz`, which remains a liveness signal. The dashboard's **Memory observability** card renders the memory and continuation-size fields from this endpoint and offers a confirm-gated diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index bdaf4364f6..cc60e363ca 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -88,6 +88,52 @@ Anthropic。若任一提供方请求头包含代理准入密钥,该密钥会 可以设置 `claudeCode.nativePassthrough: false` 来禁用;也可以通过 `claudeCode.anthropicBaseUrl` 指向其他位置。 +## 连接远程 hub 的 Claude Desktop + +已连接的机器运行 `ocx claude desktop apply` 或 `ocx claude desktop` 时,会读取 hub 的 +Desktop 快照,将 hub origin 和 hub 发放的完整模型 ID 原样写入本机 Desktop 配置,不再本地 +生成别名。static/hybrid 模式也复制模型列表;discovery-only 模式使用 hub origin,不嵌入列表。 + +Desktop 配置、模型家族分组及默认值由 hub 管理。在 hub 上修改后,请在客户端重新应用, +并在 Desktop 中重新选择模型。以前只在客户端生成的别名也需要重新应用、重新选择,不会自动 +迁移。`show`、本地编辑和 import/export 仍只操作本地配置。连接期间不支持 +`ocx claude desktop import --apply`,会在保存前拒绝;不带 `--apply` 的 import 仍是本地操作。 + +读取使用现有连接的数据访问凭证,不需要管理员令牌,也不上传配置。旧版 hub 不支持快照、 +响应无效或 Desktop 列表为空时,应用会失败,不会改用本地目录或回环地址。 +请更新或配置 hub 后重新应用。 + +本次别名修改不解决 [#3719](https://github.com/lidge-jun/opencodex/issues/3719) 中独立的 `thinking` / `redacted_thinking` 重放与提示缓存请求。 +只有代理接入凭证不会启用原生 Anthropic 透传,但经过转换的 Anthropic 路由仍可使用提示缓存。 +重放保真和缓存命中率对比仍是独立工作。 + +### 密钥轮换、恢复与断开连接 + +密钥轮换和恢复会同步更新本地连接凭证与该连接管理的 Desktop 配置中的密钥,无需为了迁移 +密钥而手动重新 apply。模型 ID、家族分组、默认值及当前配置选择都会保留;轮换不会重新选中 +管理配置,也不会启用已关闭的集成。CLI JSON 的 `rotation: "committed"` 表示新密钥已生效, +`rotation: "rolled_back"` 表示保留或恢复了旧密钥,不代表新密钥已提交或旧密钥已撤销。 +结果不确定或恢复未完成时,不会报告轮换成功。 + +首次连接应用会保存原先的管理设置和选择,用于恢复;后续 apply 和轮换不会覆盖这份初始记录。 +`ocx disconnect` 恢复连接管理的设置,同时保留用户新增字段和其他配置。只有管理配置仍被选中 +时才恢复之前的选择;用户后来选择的其他有效配置保持不变。新建配置若已包含用户新增内容, +会保留为可读取的标准模式,而不是删除这些内容。`--keep-catalog` 保留的是目录,不是 Desktop +连接密钥。 + +没有原始记录的旧管理配置,只要能明确确认属于当前 hub 和已识别的连接密钥,就能迁移。 +apply、轮换/恢复或直接 disconnect 均可处理,无需新参数或事先重新 apply。系统会警告: +之前的设置未记录,断开连接时将使用标准模式。只移除连接拥有的网关设置,保留用户字段和 +另行选择的有效配置;结果标为标准回退,而非恢复原始设置。 + +管理字段冲突、无法识别的凭证或损坏的恢复记录会保留并报告,不会覆盖。中断的清理仅针对 +同一连接继续,不会删除新连接,也不会在恢复未完成时声称完成。断开前先完成待处理的密钥 +轮换恢复;重试断开时保持原来的目录保留选项。 + +应用、轮换/恢复或恢复设置后,请完全退出并重新打开 Claude Desktop。修改磁盘文件不会替换 +运行中应用持有的密钥,也不会自动退出或重启应用。断开在本地完成,不会自动撤销 hub 密钥或 +删除外部副本;如有需要,请另行在 hub 撤销。 + ## /model 选择器(“From gateway”) 每个条目带有诚实的显示名(如 `gemini-3-pro (gemini)`),并以官方 ModelInfo 形态附带模型能力 信息(推理强度梯度、thinking 类型),使 Claude Desktop 的第三方网关模式能够启用推理强度选择 @@ -126,6 +172,14 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / **模型解析顺序:**移除 `[1m]` 标记 → 解码易读别名 → 解码 Desktop 哈希别名 → `modelMap` 精确匹配 → 移除日期后的匹配(移除 `-20250514`)→ 透传。 + + +无法解析的日期型 Desktop ID 也可能是发现结果中缺失的真实原生模型 ID。现有信息不足以 +解析该 ID 时,Messages 和 count-tokens 返回 HTTP 503 及固定错误 `desktop_model_mapping_unavailable`;这并不证明 +模型无效。未知的旧版哈希别名仍返回 HTTP 400。两种情况都不会去除日期或回退到其他路由。 +已知 ID、已注册映射、精确 `modelMap` 匹配及已识别的真实原生 ID 保持原有处理方式。 +请刷新模型发现或重新应用已连接 hub 的配置后再试;仅重试本身不能保证解决。 + 每个条目都带有类似 `gemini-3-pro (gemini)` 的显示名称,以及官方 `ModelInfo` 结构中的完整 模型能力(推理强度阶梯、思考类型)。真正的 Anthropic 模型在两个界面上都保留其规范 ID。 @@ -225,6 +279,8 @@ opencodex 会在**已路由**请求中将该技能内容替换为一个短占位 查找顺序:发现别名 → 精确 ID → 移除日期后缀的 ID(`-20250514`)→ 透传。 +拒绝规则见 [Desktop 别名解析](#desktop-alias-resolution)。 + ## Sidecar 矩阵:Web Search 与图像理解 不同路由模型拥有的托管工具和图像能力并不相同。opencodex 会在主模型回答前补齐这些能力: diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index e2a5601d62..552c0c3f53 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -260,8 +260,12 @@ provider 形式一样,从 `OPENCODEX_API_AUTH_TOKEN` 传入 `x-opencodex-api-k 所有发现到的模型。一个不在 allowlist 里的 id 永远不会进入 catalog。 2. **`disabledModels`**(顶层) - 会同时隐藏 catalog 和 `/v1/models` 中的模型,并把裸原生 GPT slug 切成 `visibility: "hide"`。 -3. **`liveModels: false` 且 `models` 为空** - 当 live discovery 关闭而 `models` 为空或省略时,opencodex - 不会为那个 provider 暴露任何路由模型。 +3. **`liveModels: false`** — `liveModels: false` 时,若 `models` 为空或省略,初始列表先加入已配置的 `defaultModel`, + 再加入 `retainModels`,重复 ID 仅保留首次出现的位置。若显式设置了非空 `models`,则按 + `models`、`retainModels` 顺序构建,不会自动加入另一个 `defaultModel`;仍可将该模型明确写入 + `models` 或 `retainModels`。这些字段均未提供 ID 时,初始列表为空。此顺序不保证最终选择器的显示顺序。 + `selectedModels`、`disabledModels` 和提供商禁用策略仍然适用。`authMode: "forward"` 保留原有独立分支, + 不使用此静态路由列表。这些规则不改变实时发现失败时的回退行为。 4. **Cursor `GetUsableModels`** - Cursor adapter 通过它的 protobuf `GetUsableModels` RPC 发现模型,而不是 `/models`,所以 Cursor 侧的变动会独立于其他 provider 改变哪些 id 可见。 5. **缓存和 `ocx sync`** - live catalog 的缓存时间大约是五分钟(`modelCacheTtlMs`,默认 `300000`)。 diff --git a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md index 2f229176cd..ffe4ffda87 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md @@ -20,6 +20,8 @@ priority 为 `i * N + j` 的 selector 行,其中 `j` 是从 0 开始的 select 没有 selector 时的相关 priority 如下: +以下优先级表和示例适用于未开启完整选择器排序的情况。 + | 目录条目 | Priority | 来源 | | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | @@ -102,10 +104,52 @@ subagentModels = [ 自定义开头模型顺序的受支持方式是重新排列 `subagentModels`。仪表盘的 **Sub-agents** 页面可以调整 裸原生和路由 id 的顺序。配置和 `ocx agent subagents set` 也接受精确的账户限定 -`/` id,但仪表盘不会提供这些 id,保存列表时也不会保留它们。配置的 +`/` id,仪表盘会保留已保存的 id,即使当前不可用。配置的 id 请勿超过五个。存在账户 selector 时,一个裸原生选项可能展开为多个 selector-qualified 行,因此 已配置的选项与公布的行不一定一一对应。 -目前 `OcxConfig` 中没有通用的 `modelOrder`、`providerOrder` 或 priority map 设置。受支持的排序 -字段是 `subagentModels`;`disabledModels` 和各 provider 的 `selectedModels` 都是可见性字段。 -因此,要更改选择器其余部分的顺序,需要修改代码行为,而不是调整配置。 +`modelPickerOrder` 只控制选择器的显示顺序。如果列表只有路由 ID `/`, +其中未置顶的行会按列表顺序进入独立的显示区间(`1000 + i`)。未列出的路由行保留原有优先级, +因此仍排在该区间之前。同时列在 `subagentModels` 中的行保留置顶优先级,原生行也保持原有位置。 +需要控制相对顺序的路由行都应列入列表。 + +要对整个选择器排序,请加入至少一个不含 `/` 的裸目录 ID,例如 `gpt-5.6-sol`。 +空字符串或只有空白的条目不会启用此模式。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +列出的行按数组顺序排在最前面,未列出的行随后按原有优先级排列。匹配使用精确的目录 ID: +`gpt-5.6-sol` 和 `openai/gpt-5.6-sol` 是不同的行。同一路由 ID 的原始写法和编码写法也可匹配, +但精确匹配优先于等价匹配。空条目和只有空白的条目会被忽略。账户限定行必须使用包含 selector 的完整 ID。 + +### 迁移提醒:现有列表中的原生 ID + +以前 `modelPickerOrder` 中的裸原生 ID 会被忽略。现在,现有列表只要包含这样的 ID,就会启用 +整个选择器的排序,包括置顶行。要保持以前只调整路由行的行为,请移除裸 ID。 +未设置、空列表、只有空白条目的列表以及只有路由 ID 的列表都保留原有行为。 + +`modelPickerOrder` 保留 OpenCodex 按原有优先级计算最多五个首选候选项的规则,供子代理指导使用。 +每个移动行的原有优先级与原生 `priority` 分开保存;仅改变选择器顺序不得改变这一计算结果。 +它也不会限制通过精确模型名称指定 override 的资格:公布的列表不是允许列表,现有的认证、模型、 +effort 和后端限制仍然适用。 + +原生 Codex 按原生 `priority` 排序,从符合条件且在选择器中可见的模型中取前五个,公布在 +`spawn_agent` 中。这适用于 V1,以及公开模型 override 的 V2。因此,即使 OpenCodex 的首选候选项 +不变,原生公布的五个模型仍可能随选择器顺序改变。V1 不接收 OpenCodex 注入的首选模型列表。 +V2 在客户端目录状态允许时,可以额外接收基于原有优先级的 OpenCodex 指导;这些指导不会重排 +原生工具公布的列表。 + +`disabledModels` 和各提供商的 `selectedModels` 仍是可见性字段。没有独立的 `modelOrder`、 +`providerOrder` 或优先级映射设置。 + +## 仪表盘排序预设 + +在 **Models** 中选择默认、按模型名 A–Z、按提供商或使用量快照,再应用顺序。保存当前可用的路由 ID 和 `modelPickerOrderMode`(`alphabetical`、`provider`、`most-used`)。使用量排序仅在应用时读取一次保留的全部历史;重新打开或模型增减不会重新计算。已有自定义、原生完整顺序会保留,直到明确应用替换。即使没有可用模型,默认也能清除两个字段。 + +`GET/PUT /api/subagent-models` 的 `chosen`、`available` 保留禁用或缺失的已存 roster;`pickerAvailable` 只包含可选路由 ID。Models 只发送 `pickerOrder`、`pickerOrderMode`,不发送 `models`。只保存 roster 不影响排序,非法输入或保存失败会保留原状态。 + +预设保留精选、原生优先级区间,应用于 Codex 目录与 Claude 发现列表的路由分组。Claude 原生前缀、明确的 Desktop 配置及 alias 归属不变。OpenCodex 指导排序与 fallback 设置不变,但原生 Codex 工具显示的前五候选及推荐默认模型可能改变。保存不会重启客户端;目录刷新可能尚未完成,旧列表可能需要重新打开客户端。 diff --git a/docs-site/src/content/docs/zh-cn/guides/model-routing.md b/docs-site/src/content/docs/zh-cn/guides/model-routing.md index 70f798b9d0..5bfe9d28bc 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-routing.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-routing.md @@ -75,9 +75,12 @@ transport;这些凭证路径互不 fallback。 - `provider.disabled: true` 会把该提供商排除在目录发现之外。显式 `provider/model` 请求会失败, `defaultModel` / `models[]` 扫描也会跳过它。 - `providerContextCaps` 为各提供商设置 Codex 可见的上下文上限。`contextCapValue` 是仪表盘的默认值, - 默认为 350,000;但只有 `providerContextCaps` 中列出了提供商时才会生效。仅当勾选“应用到所有已路由的 - 提供方”时,修改仪表盘值才会重新指向所有已启用提供商;否则每个提供商保留自己的上限。上限只能降低 - 已知上下文,不会把它调高,也不会改变上游模型的实际限制。 + 默认为 350,000;仅设置这个值不会应用上限,提供商必须出现在 `providerContextCaps` 中才会生效。 + 勾选“应用到所有已路由的提供方”后,修改仪表盘值只会更新已开启的上限;未勾选时,各提供商保留自己的上限。 + 普通的已知窗口只能缩小;支持长窗口的原生模型可以扩展到该模型支持的上限,但不会改变上游模型的实际限制。 + 关闭上限后,选择值保存在 `providerContextCapValues` 中,重新加载后仍保留;再次开启时恢复该选择值。 + 关闭期间不会把保存的值作为限制应用。不带 `value` 的 `{ "setAll": true }` 会按当前全局值开启所有 + 已配置提供商的上限,并替换它们保存的选择值。 ```json { diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 290757bab7..4d6b837842 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -104,6 +104,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | +Google Antigravity 账户和提供方的配额查询(包括模型列表回退)使用固定的 Google 计量端点。这些目标支持透明 Fake-IP DNS,同时保留 TLS 验证、重定向拒绝和私有地址检查。自定义 base URL 仅改变模型请求,不改变配额目标;`NO_PROXY` 仍使用直连策略。 + + Nous refresh 发生终止性失败后,请运行 `ocx login nous` 重新认证。 对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 818093c4c0..6caa44fc23 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -60,11 +60,30 @@ ocx connect rotate --admin-token-stdin ## Docker、回滚与排障 -opencodex 不发布官方 Docker 镜像,但仓库提供维护的 `Dockerfile` 和 `compose.yaml`,用于在本地构建按 digest 固定的 Bun 镜像。首次正常启动会在 `ocx-state` 卷的 `/home/bun/.opencodex/container-tls/cert.pem` 和 `/home/bun/.opencodex/container-tls/key.pem` 生成并保存一套自签名 TLS 身份;私钥仅所有者可读。后续启动会验证并复用它,因此数据端点使用 HTTPS。首次正常启动前,通过 stdin 初始化一次数据密钥;引导程序最多接受一行 512 字节的内容,不会输出密钥,并以仅所有者可读的权限保存规范的 `service-api-token`。 +回滚时也要保留两个卷及其挂载路径。已有卷的所有权和权限不会自动修复。有关不使用 Compose 时的命名卷挂载及单独的状态路径,请参阅[英文基准指南](/guides/remote-hub/#docker-compose)。 + +部署使用两个独立持久卷:`ocx-state` 对应 +`OPENCODEX_HOME=/home/bun/.opencodex`,`codex-state` 对应 +`CODEX_HOME=/home/bun/.codex`。两个产品的 `auth.json` 格式不同,不能合并到同一个 +主目录。即使根文件系统只读,这两个目录也可通过各自的卷写入。 + +此设置不会自动生成模型目录。在检查认证后的 `/v1/catalog` 前,必须生成或导入有效的 +`/home/bun/.codex/opencodex-catalog.json`;空目录返回 `catalog_not_found` 404 属于正常行为。 +升级会保留现有 `ocx-state` 并新增 `codex-state`,但不会自动迁移文件。若之前的临时方案 +将模型目录放在 `.opencodex` 下,请先备份,再仅迁移模型目录文件,并保留仅所有者可访问的权限。 +不要用一个产品的 `auth.json` 覆盖另一个。自定义 `CODEX_HOME` 时,必须将该确切目录挂载为 +可写持久卷,并在 `${CODEX_HOME}/opencodex-catalog.json` 准备默认目录文件。 +若 `model_catalog_json` 指向其他文件,也必须持久保存其解析后的路径。 +在明确完成迁移前,请保留已有的环境变量与卷路径映射。 +`docker compose down` 保留两个卷;`docker compose down --volumes` 则会删除 +`ocx-state` 和 `codex-state`,包括配置、凭据、用量记录、数据密钥及 Codex 状态和模型目录。 +这是破坏性操作,不能当作升级或重启命令使用。 + +opencodex 不发布官方 Docker 镜像,但仓库提供维护的 `Dockerfile` 和 `compose.yaml`,用于在本地构建按 digest 固定的 Bun 镜像。首次启动前,通过 stdin 初始化一次数据密钥;密钥不会输出,并以仅所有者可读的权限保存在 `ocx-state` 卷中。 宿主机需要安装 Git 和 Bun。每次构建镜像前,都应从 Git 跟踪的源码生成规范兼容性清单,生成后到构建完成前不要修改源码。生成的 JSON 不加入 Git;`.git` 不进入 Docker 构建上下文。宿主机端口默认绑定 `127.0.0.1`。远程访问须显式使用 `OPENCODEX_BIND_ADDRESS= docker compose up -d`;`0.0.0.0` 会公开所有接口。请使用防火墙和经过身份验证的 TLS/tailnet 前端保护访问。 -构建会拒绝过期清单,并将每个 SHA-256 分别与构建上下文及复制后的文件进行核对。清单对 `Dockerfile`、`compose.yaml`、`.dockerignore`、所有 Git 跟踪的 Docker 权威文件(引导、配置和探针)、`src/`,以及必需的 `package.json`、`bun.lock` 和 `scripts/model-metadata.source.json` 进行完整性验证。缺失或不匹配的文件、清单之外的源码或 Docker 权威文件,以及符号链接都会导致失败;`scripts/` 中仅纳入上述模型元数据文件。 +构建会拒绝过期清单,并将每个 SHA-256 分别与构建上下文及复制后的文件进行核对。缺失或不匹配的文件、清单之外的源码和符号链接都会导致失败。必须包含 `package.json`、`bun.lock`,以及 `scripts/` 中唯一纳入的 `scripts/model-metadata.source.json`。 ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -75,33 +94,7 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -容器以非 root 的 `bun` 用户运行,根文件系统只读,并且只发布 `10100`。复制公有证书(绝不要复制私钥),将其用作本地 CA 来验证默认的回环发布: - -```bash -mkdir -p .tmp -docker compose cp hub:/home/bun/.opencodex/container-tls/cert.pem .tmp/opencodex-container-ca.pem -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10100/healthz -``` - -`OPENCODEX_PORT` 会同时控制宿主机发布端口和自动管理的 `tls.publicOrigin`,而容器内监听端口始终是 `10100`: - -```bash -OPENCODEX_PORT=10190 docker compose up -d -curl --cacert .tmp/opencodex-container-ca.pem --fail --silent https://localhost:10190/healthz -``` - -远程发布必须显式设置 `OPENCODEX_BIND_ADDRESS`。自动生成的证书只覆盖 `localhost` 和 `127.0.0.1`;直接远程发布前,请用与准确远程名称匹配的证书和私钥替换卷中的身份,并设置 `OPENCODEX_PUBLIC_ORIGIN=https://准确的主机名和端口`。该值必须是准确的 HTTPS origin,不能包含路径、查询参数或片段。无论哪种方式都应使用防火墙;更推荐保留默认回环发布,并由经过身份验证的 TLS/tailnet 前端代理。 - -升级时,保留的旧版无 TLS 卷会在启动时迁移为每卷 TLS 身份,并根据已发布的宿主机端口写入 HTTPS origin;自定义证书路径会保留。若要回滚到旧版仅 HTTP 镜像,必须在当前镜像仍可用时先停止 hub 并仅移除 TLS 设置,然后再启动旧镜像;身份文件可以留在卷中: - -```bash -docker compose down -docker compose run --rm hub bun run src/cli/index.ts config unset tls -# 选择或构建旧镜像,然后重新创建 hub -docker compose up -d -``` - -不要发布 `10101`,也不要把密钥放入 `ARG`、`ENV`、`COPY`、Compose、镜像历史或 argv。仅限容器内固定的 `https://127.0.0.1:10100` 回环地址,内部 health/readiness 探针可以使用 `rejectUnauthorized:false` 跳过证书身份验证;这只证明本地监听器和路由正常,不能作为外部验收。外部验收必须使用复制出的公有证书或系统信任库,并通过证书对应的准确主机名验证 HTTPS。healthcheck 后仍需单独验证 readiness、已认证目录和真实请求。`docker compose down` 会保留卷;`docker compose down --volumes` 还会删除配置、凭据、TLS 身份和数据密钥。 +容器以非 root 的 `bun` 用户运行,根文件系统只读,并且只发布 `10100`。不要发布 `10101`,也不要把密钥放入 `ARG`、`ENV`、`COPY`、Compose、镜像历史或 argv。healthcheck 后仍需单独验证 readiness、认证目录和真实请求。`docker compose down` 会保留卷;`docker compose down --volumes` 还会删除配置、凭据和数据密钥。 - hub 宕机:可以离线断开,但远程密钥仍待吊销。 - 目录过期:仅在临时故障时保留已验证的 LKG;认证、架构、大小或协议错误不会回退到本地提供商。 diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 8d32435d0e..7275daf856 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -47,6 +47,14 @@ bun run dev:gui | **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。在使用任务计划程序后端的 Windows 上,仪表板会拒绝并提示改用 `ocx stop`:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口。被拒绝时不会做任何更改。 | +### 筛选请求日志 + +Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。 + +时间范围为最近 15 分钟、1 小时或 1 天;Logs 标签页每 30 秒更新一次,即使关闭自动刷新也会更新。速度按完整请求耗时计算每秒输出 token,分为小于 15、15 至小于 50、至少 50;启用速度筛选时排除无测量值的请求。成功为 2xx,错误为 4xx/5xx。 + +显示匹配数与已加载总数;重置恢复全部行,并区分无匹配与空日志。界面选择支持方向键及 Home/End,不查询已加载范围之外的历史记录。 + ### 链接到某个部分 布局只有一种且会自适应,无需切换。桌面端使用侧边栏进行主要导航;窄屏时点击 **打开菜单** 可展开相同的页面链接。Dashboard 的各个部分都有自己的地址:`#dashboard` 打开 Overview,`#dashboard/providers` 与 `#dashboard/models` 打开另外两个。刷新、收藏和后退都会保留当前所在的部分。**Logs** 同理,使用 `#logs` 与 `#logs/debug`。旧的 `#providers/workspace` 书签现在会跳转到 `#providers`。 @@ -60,6 +68,19 @@ Overview 还提供 **30 天活动** 面板,显示 30 天的请求和 token 趋 **Models** 开关表示 Codex 中的最终可见状态。路由模型只有在 provider allowlist 中(或未设置 allowlist)且未被禁用时才会开启。开启模型会原子地协调两个过滤条件;**全部开启** 会清除 allowlist,因此以后新发现的模型也会开启。 +### 在提供方工作区管理模型 + +在提供方的**模型**标签页中,**删除**会移除已保存的自定义定义。原有的原生模型或实时发现的模型可能 +重新显示,因此模型数量可能保持不变。**隐藏**只改变目录可见性,不会删除定义,也不会改变直接路由策略。 +点击**在模型中管理可见性**可打开**模型**页面并恢复显示;即使提供方标签页已没有任何模型行,也能使用此入口。 + +**添加**会保存自定义定义,但不会清除已有的隐藏状态或提供方选择规则。保存后的模型可能仍被隐藏。 +如果模型已存在,请在**模型**页面管理其可见性。已确认保存时,即使目录刷新失败,定义也已保存; +请按刷新提示操作,不要重复添加。若无法确认更改结果,请先刷新模型状态,再重试。 + +提供方的模型数量统计服务器返回的当前模型清单中未禁用的唯一条目,计数在搜索和显示数量限制之前进行。 +它不是允许列表的大小或实时发现的模型数量,也不能证明条目来自上游发现。选择标记和发现信息与该计数分开显示。 + ## 委派选择器与生成路由的区别 Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以及可选的 @@ -143,7 +164,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | 选择下一次请求使用的账号并配置账号池路由。 | | `GET /api/codex-auth/active` · `PUT /api/codex-auth/accounts/priority` | 读取实际生效的账号(含表示是否固定的 `pinned` 和指明被固定账号的 `pinnedAccountId`),并设置单个账号的选择顺序。 | | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | 通过浏览器登录添加池账号。 | -| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。`limit`/`offset` 从最新一行向前分页(`offset=0` 为最新一页)。响应为 `{ timeZone, total, logs }`,其中 `total` 为分页前的匹配行数。 | +| `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | 使用 tail、provider、精确状态码或状态类别筛选近期请求元数据。`limit`/`offset` 从最新一行向前分页(`offset=0` 为最新一页)。响应为 `{ timeZone, generatedAt, total, logs }`,其中 `total` 为分页前的匹配行数。 | | `GET` / `PUT /api/subagent-models` | 读取或设置五个置顶的 `spawn_agent` override 模型。 | | `POST /api/stop` | 停止代理/服务,恢复原生 Codex 并退出。在 Windows 任务计划程序后端会以 `respawnable_service` 拒绝,无法读取该状态时以 `service_state_unknown` 拒绝;两种情况都不会做任何更改。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index dfae403438..f0c6ee5a59 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -153,6 +153,10 @@ ocx status --json 将 opencodex 作为登录管理的后台服务运行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登录时自动启动,在崩溃时自动重启。服务运行会设置 `OCX_SERVICE=1`,因此重启时不会反复改动 Codex 配置。 +Windows 任务计划程序安装使用普通进程优先级(`Priority=4`)。旧的后台优先级(`7`,省略时调度器也默认使用 `7`) +可能在 CPU 竞争时延迟健康检查响应,导致进程仍存活时托盘显示 Offline。升级后运行 `ocx service repair`, +即可迁移该注册优先级并重启服务;过程中可能需要批准 UAC 提示。已设为普通或高优先级时,不会仅因优先级而重新注册。 + | 子命令 | 操作 | | --- | --- | | none | 服务不存在时安装并启动;已存在时刷新并重启。正常的 Windows 任务计划程序定义会复用;过时定义可能会重新注册并需要提升权限。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index f9fec6a7b2..778df8eab2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -169,12 +169,11 @@ token,也不是简单重读账号列表。`--json` 返回 ### `ocx account auto-switch > [--json]` -只控制 `openai` 的 Codex 账号池。`on` 会设为 80%,`off` 会设为 0%,`status` 会读取 -当前值,而 `threshold ` 接受 0 到 100 之间的整数。其他提供方和无效值都会以 1 -退出。`--json` 返回: +控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值目前不参与运行;保存阈值不会启用阈值切换、改变提供方启用设置或禁用 429 错误后的轮换。通用池的查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值未应用;能力未知时也不会报告 `enabled: true`。API 密钥提供方、Anthropic 和无效值会被拒绝。 ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index f02691c559..ffac2b6776 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -52,7 +52,7 @@ per-role fallback 链必须放在 opencodex 配置里。把 `model_fallback` 写 `$CODEX_HOME/agents/*.toml` 会让 Codex 0.146+ 把整个角色文件当作未知字段拒绝并跳过该角色 (#1190)。TOML 中的旧版 `model_fallback` 仍会被读取以保持向后兼容,但 `ocx doctor` 会标记它。 -opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或已达到配额阈值的候选项。可用性快照会在 `subagentModelFallbackPollMs` 期间缓存。对于加密的子任务,候选链只包含规范的原生 ChatGPT 目标,以及通过 `allowEncryptedV2AgentTasks: true` 明确信任的直接密钥认证 Responses 路由。如果没有任何目标能处理加密载荷,请求就会失败,而不是把不可读的密文路由到别处。combo 仍然只使用规范的原生目标。 +opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或已达到配额阈值的候选项。可用性快照会在 `subagentModelFallbackPollMs` 期间缓存。对于加密的子任务,候选链只包含规范的原生 ChatGPT 目标,以及通过 `allowEncryptedV2AgentTasks: true` 明确信任的直接密钥认证 Responses 路由。如果没有目标能处理加密载荷,且可选恢复无法支持路由发送,请求就会失败,不会转发不可读的密文。combo 会先尝试可用的规范原生目标;如果没有可选择的原生目标或原生尝试已耗尽,且已启用 `agentTaskRecovery`,会在路由到 combo 目标前对加密的 `NEW_TASK` 恢复一次。 ```json { diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 54a92917ed..f0dfb0eac0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -33,8 +33,9 @@ ocx models provider openrouter on | `providers` | `Record` | — | 提供者名称到提供者配置的映射。 | | `openaiProviderTierVersion?` | `2` | 由迁移设置 | 标记单一、可感知选项的 OpenAI 投影已完成。 | | `disabledModels?` | `string[]` | — | 从 Codex catalog 和 `/v1/models` 中隐藏、但不阻止直接 proxy 调用的 model。routed id 会从列表中移除。account-qualified native id 只隐藏对应 selector row;bare native GPT id 会隐藏 bare row 以及该 model 的所有 account-selector row。Models 页面只显示裸原生行和路由行;若只隐藏一个 selector-qualified 行,请直接设置此配置字段。 | -| `providerContextCaps?` | `Record` | `{}` | 按提供者设置、对 Codex 可见的上下文上限。上限只会降低已知的上下文窗口。 | -| `contextCapValue?` | `number` | `350000` | 仪表板上下文上限控件使用的默认值。仅当勾选“应用到所有已路由的提供方”时,修改它才会把值应用到所有已路由提供方(包括没有现有 `providerContextCaps` 条目的提供方);否则每个提供方保留自己的上限。 | +| `providerContextCaps?` | `Record` | `{}` | 按提供商设置的有效上下文上限。普通窗口只能缩小;支持长窗口的原生模型可以扩展到该模型支持的上限。 | +| `providerContextCapValues?` | `Record` | `{}` | 各提供商最后选择的上限,关闭后仍保留。仅保存这些值不会启用上限。有效值优先于保存的选择值。 | +| `contextCapValue?` | `number` | `350000` | 首次开启时使用的默认值。再次开启时恢复该提供商的选择值。修改全局值时附带 `setAll: true` 只会更新已开启的上限;不带值的 `setAll: true` 会按当前全局值开启所有已配置提供商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | | `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。启用账户限定的选择器行后,target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | @@ -88,7 +89,7 @@ selector,而不是分配一个新名称。 | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key 头部样式。默认使用原生 `x-api-key`;仅对 key-auth `anthropic` 提供者有效。 | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多 key 池。`apiKey` 会镜像当前激活条目;每个条目都有 `id`、`key`、可选 `label`,以及可选的数值 `addedAt`。 | | `defaultModel?` | `string` | 当选择该提供者但未显式指定模型时使用的模型。 | -| `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 | +| `models?` | `string[]` | 初始/回退模型列表。`liveModels: false` 时,非空 `models` 后接 `retainModels`;若 `models` 为空或省略,则按已配置的 `defaultModel`、`retainModels` 顺序构建初始列表,重复 ID 仅保留首次出现的位置。 | | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | | `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。该映射最多可包含 2,000 个条目,与发现上限相同。 | @@ -367,7 +368,14 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 ## 静态模型允许列表 -将 `liveModels: false` 设为只暴露 `models`。如果 `models` 为空或省略,该提供者将不暴露任何路由模型。实时发现会在缓存前拒绝超过 4 MiB 或 2,000 条原始模型行;内置预设可能使用更低的限制,并过滤为可聊天的行。过大或格式错误的结果会走陈旧/配置回退。合法的、零可用结果的发现仍然具有权威性,不会被静默替换或截断。 +`liveModels: false` 时,若 `models` 为空或省略,初始列表先加入已配置的 `defaultModel`, +再加入 `retainModels`,重复 ID 仅保留首次出现的位置。若显式设置了非空 `models`,则按 +`models`、`retainModels` 顺序构建,不会自动加入另一个 `defaultModel`;仍可将该模型明确写入 +`models` 或 `retainModels`。这些字段均未提供 ID 时,初始列表为空。此顺序不保证最终选择器的显示顺序。 +`selectedModels`、`disabledModels` 和提供商禁用策略仍然适用。`authMode: "forward"` 保留原有独立分支, +不使用此静态路由列表。这些规则不改变实时发现失败时的回退行为。 + +实时发现会在缓存前拒绝超过 4 MiB 或 2,000 条原始模型行;内置预设可能使用更低的限制,并过滤为可聊天的行。过大或格式错误的结果会走陈旧/配置回退。合法的、零可用结果的发现仍然具有权威性,不会被静默替换或截断。 当需要继续运行发现,但只有选定 id 应该出现在 Codex 和 `/v1/models` 中时,请使用 `selectedModels`。仪表板会保留完整的已发现列表,以便之后调整允许列表。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index ce12c24f5d..80fbfffc95 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -188,3 +188,7 @@ Anthropic OAuth 侧车会复用 opencodex 现有的 Claude Code OAuth 指纹。 `runtimeRole` 默认为 `standalone`。Hub 使用 `hub.managementPublicOrigin`、仅回环的 `hub.managementIngress`(缺省为 `enabled:false`)和准确的 `remoteGui.allowedTailscaleUsers`(缺省为空)。客户端密钥保存在 `service-api-token` 而不是 `config.json`;轮换期间可能暂时存在 `service-api-token.prev`。使用记录不会镜像。 `remoteGui.allowInsecureHttp` 是已弃用的 no-op,仅为让旧的严格 schema 配置继续加载而保留。请从配置中删除它:pairing grant 只接受 loopback 或已认证的 HTTPS;设为 `true` 也不会重新开放明文 HTTP pairing。 + +## Codex 额度网络诊断 + +主 Codex 账户行中的 `quotaRefresh` 描述额度查询结果,并不代表剩余额度或模型访问权限。读取缓存或未执行查询时,该字段可能省略。查询使用正在运行的代理服务的环境,而不是当前终端的环境。未设置 `proxy` 时保留现有环境;`"auto"` 只在启动时读取 Windows 静态代理设置,不自动处理 PAC/WPAD、仅 SOCKS 的设置或运行中的更改。TUN 测试成功并不能单独证明 HTTP 代理路径正常。命令和状态说明见[英文网络诊断章节](/reference/configuration/server/#codex-quota-network-diagnostics)。 diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 56a1fedf42..415a5788ea 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -144,12 +144,15 @@ Authorization: Bearer | `GET /api/models` | 返回仪表板/CLI 模型行 | 收集饱和时返回 `catalog_busy` | | `GET /api/client-config?client=...` | 为任意支持的文件集成构建只读客户端配置 | 400 不支持的客户端;503 目录不可用 | | `PUT /api/disabled-models` | 替换共享的禁用模型列表 | 400 无效 JSON | -| `PUT /api/model-visibility` | 原子性地更改 provider 级或 model 级可见性 | 400 provider、scope、target 或请求体无效 | +| `PUT /api/model-visibility` | 原子性地更改 provider 级或 model 级可见性 | 400 provider、scope、target 或请求体无效; 409 `initial_model_selection_pending` (刷新模型列表后重试。) | | `GET, POST /api/custom-models` | 列出自定义模型或添加一个 | 400 字段无效;404 provider 缺失;409 模型重复 | | `PUT, DELETE /api/custom-models/{id}` | 编辑或删除一个自定义模型 | 400 id/字段无效;404 未找到;409 模型重复 | | `GET, PUT /api/selected-models` | 读取 provider 允许列表和可用性,或替换一个允许列表 | 400 缺少 provider/请求体;404 未知 provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | 读取预设信息或选择 preset/all/custom 模式 | 400 模式无效或不支持该预设;404 未知提供者; PUT 409 `initial_model_selection_pending` | +手动模型会替换 Models 仪表板中 provider 和 model ID 相同的行。OpenAI 手动行保留 `openai/`,并支持可见性控制。删除手动行后,不带账户限定符的原生行会恢复。带账户限定符的原生行仍单独保留。原生路由和账户权限不会改变。非原生 OpenAI 可见性目标必须匹配已配置的手动模型。 + + 可靠的初始模型列表尚未确认时,有效的 `PUT /api/selected-models` 和 `PUT /api/model-presets` 请求也会返回 HTTP 409 和代码 `initial_model_selection_pending`。请使用 `GET /api/models` 等方式刷新模型列表,成功后再重试。 ### OAuth 账户、provider 密钥和数据平面密钥 @@ -188,6 +191,14 @@ Authorization: Bearer | `GET, PUT /api/provider-context-caps` | 读取或更新全局、全部 provider,或单个 provider 的上下文上限 | 400 请求无效;404 未知 provider | | `GET /api/provider-presets` | 返回从运行时注册表派生的 GUI provider 预设 | — | +上下文上限响应包含 `caps`(当前有效的上限)和 `values`(关闭后仍保留的最后选择值)。 +开启提供商的上限时,如果未指定 `value`,则恢复其选择值;首次开启时使用全局 `contextCapValue`。 +OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上限约束每个原生窗口;支持长上下文的模型 +只能扩展到该模型支持的上限。 +`{ "value": 600000, "setAll": true }` 修改全局值,并且只更新已开启的上限;上限已关闭的提供商保留 +自己的选择值,供之后开启时恢复。不带 `value` 的 `{ "setAll": true }` 会按当前全局值开启所有 +已配置提供商的上限,并替换保存的选择值。关闭上限不会清除选择值,重新加载后仍保留,但不会将其作为限制应用。 + `provider_has_dependent_combos` 是一个安全屏障:在删除 provider 之前,先移除或编辑依赖它的 combos。 ### 侧边栏与基于同意的动作 diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index eaf4cb82f1..14380a7530 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -27,7 +27,7 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | 以 `chat.completion.chunk` SSE 结尾并带 `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | 不适用 | -| 模型发现 | `GET /v1/models` | 三种目录契约之一 | 不适用 | +| 模型发现 | `GET /v1/models` | 目录或显式 Desktop 快照 | 不适用 | | 语音和 Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | 转发的调用创建响应 | 独立的 sideband WebSocket 双向转发帧 | | Responses compaction | `POST /v1/responses/compact` | 替换历史 JSON | 不适用 | @@ -178,9 +178,15 @@ choice 增量、带 `finish_reason` 的终止 choice,以及 `data: [DONE]`。 { "input_tokens": 123 } ``` +无法解析的日期型 Desktop ID 也可能是发现结果中缺失的真实原生模型 ID。现有信息不足以 +解析该 ID 时,Messages 和 count-tokens 返回 HTTP 503 及固定错误 `desktop_model_mapping_unavailable`;这并不证明 +模型无效。未知的旧版哈希别名仍返回 HTTP 400。两种情况都不会去除日期或回退到其他路由。 +已知 ID、已注册映射、精确 `modelMap` 匹配及已识别的真实原生 ID 保持原有处理方式。 +请刷新模型发现或重新应用已连接 hub 的配置后再试;仅重试本身不能保证解决。 + ## `GET /v1/models` -同一路由要服务三种期望不兼容目录封装的客户端。除非同时存在 `client_version`,否则 Anthropic 形态优先。 +未指定 `format=desktop-config` 时,使用以下普通目录契约: | 契约 | 触发条件 | 顶层形态 | 模型 ID 行为 | | --- | --- | --- | --- | @@ -188,6 +194,25 @@ choice 增量、带 `finish_reason` 的终止 choice,以及 `data: [DONE]`。 | Codex catalog | `client_version` 查询参数 | `{ "models": [...] }` | 原生和路由条目携带更丰富的 Codex catalog 字段、可见性、effort、WebSocket 和 multi-agent 元数据 | | Plain OpenAI list | 两个触发条件都没有 | `{ "object": "list", "data": [...] }` | 可见的原生 ID 是裸值;路由 ID 是别名或 `provider/model` | +### Desktop 配置快照 + +`GET /v1/models?ids=desktop&format=desktop-config` 显式选择 Desktop 快照,不依赖 +user-agent。响应为 `{ "version": 1, "models": [...] }`,带有 `Cache-Control: no-store`。 +客户端发送 `Accept: application/json`、`anthropic-version: 2023-06-01` 及现有数据访问凭证; +不需要管理员令牌,也不上传配置。条目是 hub 发放的 Desktop 配置模型,不是 Codex 目录行。 + +此格式与 `ids=cli` 或任意 `client_version` 一起使用时返回 HTTP 400。不指定格式时,上述普通 +契约保持不变。Claude 关闭时返回 `{ "version": 1, "models": [] }`;已连接的 Desktop apply +会视为不可用,不写入替代配置。返回普通目录而非版本 1 的旧 hub 不受支持,客户端不会回退到 +本地生成的 ID。 + +快照仍是只读模型列表,不是密钥轮换或配置上传 API。Desktop 密钥迁移、恢复与断开由现有 +客户端连接流程处理。轮换保留模型条目和选择;CLI 的 `rotation` 区分 `committed` 与 +`rolled_back`。断开会恢复管理设置,或对已确认的旧配置报告标准回退,同时保留用户字段和 +后来有效的选择。冲突或未完成的恢复不会标为完成。需要重启 Desktop 才会读取磁盘变更; +断开不会自动撤销 hub 密钥。参见 [Desktop 指南](/zh-cn/guides/claude-code/)。 +thinking 重放与提示缓存仍由独立的 [#3719](https://github.com/lidge-jun/opencodex/issues/3719) 跟进。 + ## `POST /v1/live` 和 Realtime sideband `POST /v1/live` 接受 ChatGPT/Codex App 的 Frameless call-creation 表面。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index baaaef4282..28d3b5dcd7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -112,9 +112,12 @@ Claude Desktop 使用與 Claude Code 分開的設定檔。在儀表板開啟 **C 你也可以用命令列管理同一份設定檔: +以下設定檔編輯說明適用於本機設定檔;連接遠端 hub 時的套用方式另見下節。 + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] +ocx claude desktop status [--json] ocx claude desktop move [--default] ocx claude desktop default ocx claude desktop export @@ -168,6 +171,52 @@ Anthropic。若任一供應商標頭含有代理許可密鑰,該密鑰會被 可以設定 `claudeCode.nativePassthrough: false` 來停用;也可以透過 `claudeCode.anthropicBaseUrl` 指向其他位置。 +## 連接遠端 hub 的 Claude Desktop + +已連接的機器執行 `ocx claude desktop apply` 或 `ocx claude desktop` 時,會讀取 hub 的 +Desktop 快照,將 hub origin 和 hub 發出的完整模型 ID 原樣寫入本機 Desktop 設定,不再於本機 +產生別名。static/hybrid 模式也複製模型清單;discovery-only 模式使用 hub origin,不嵌入清單。 + +Desktop 設定檔、模型家族分組及預設值由 hub 管理。在 hub 上修改後,請在客戶端重新套用, +並在 Desktop 中重新選擇模型。以前只在客戶端產生的別名也需要重新套用、重新選擇,不會自動 +移轉。`show`、本機編輯及 import/export 仍只操作本機設定。連接期間不支援 +`ocx claude desktop import --apply`,會在儲存前拒絕;不帶 `--apply` 的 import 仍是本機操作。 + +讀取使用現有連線的資料存取憑證,不需要管理員權杖,也不會上傳設定檔。舊版 hub 不支援快照、 +回應無效或 Desktop 清單為空時,套用會失敗,不會改用本機目錄或回環位址。 +請更新或設定 hub 後重新套用。 + +本次別名修改不解決 [#3719](https://github.com/lidge-jun/opencodex/issues/3719) 中獨立的 `thinking` / `redacted_thinking` 重播與提示快取請求。 +只有代理存取憑證不會啟用原生 Anthropic 透傳,但經過轉換的 Anthropic 路由仍可使用提示快取。 +重播保真與快取命中率比較仍是獨立工作。 + +### 金鑰輪換、復原與中斷連線 + +金鑰輪換和復原會同步更新本機連線憑證與該連線管理的 Desktop 設定中的金鑰,無須為了移轉 +金鑰而手動重新 apply。模型 ID、家族分組、預設值及目前設定選擇都會保留;輪換不會重新選取 +管理設定,也不會啟用已關閉的整合。CLI JSON 的 `rotation: "committed"` 表示新金鑰已生效, +`rotation: "rolled_back"` 表示保留或還原了舊金鑰,不代表新金鑰已提交或舊金鑰已撤銷。 +結果不確定或復原未完成時,不會回報輪換成功。 + +首次連線套用會儲存原先的管理設定和選擇,以供還原;後續 apply 和輪換不會覆寫這份初始紀錄。 +`ocx disconnect` 還原連線管理的設定,同時保留使用者新增欄位和其他設定檔。只有管理設定檔 +仍被選取時才還原之前的選擇;使用者後來選取的其他有效設定檔保持不變。新建設定檔若已有 +使用者新增內容,會保留為可讀取的標準模式,而不是刪除這些內容。`--keep-catalog` 保留的是 +目錄,不是 Desktop 連線金鑰。 + +沒有原始紀錄的舊管理設定檔,只要能明確確認屬於目前 hub 和已識別的連線金鑰,就能移轉。 +apply、輪換/復原或直接 disconnect 均可處理,無須新參數或事先重新 apply。系統會警告: +先前的設定未記錄,中斷連線時將使用標準模式。只移除連線擁有的閘道設定,保留使用者欄位和 +另行選取的有效設定檔;結果標為標準回退,而非還原原始設定。 + +管理欄位衝突、無法識別的憑證或損壞的還原紀錄會保留並回報,不會覆寫。中斷的清理僅針對 +同一連線繼續,不會刪除新連線,也不會在還原未完成時宣稱完成。中斷前先完成待處理的金鑰 +輪換復原;重試中斷時保持原來的目錄保留選項。 + +套用、輪換/復原或還原設定後,請完全退出並重新開啟 Claude Desktop。修改磁碟檔案不會替換 +執行中應用程式持有的金鑰,也不會自動退出或重新啟動應用程式。中斷連線在本機完成,不會 +自動撤銷 hub 金鑰或刪除外部副本;如有需要,請另行在 hub 撤銷。 + ## /model 選擇器(“From gateway”) Claude Code 2.1.129+ 透過 `GET /v1/models?limit=1000` 發現閘道器模型,並在原生 `/model` @@ -199,6 +248,14 @@ user-agent 會獲得易讀的 CLI 形式,其他用戶端會獲得 Desktop 雜 **模型解析順序:**移除 `[1m]` 標記 → 解碼易讀別名 → 解碼 Desktop 雜湊別名 → `modelMap` 精確匹配 → 移除日期後的匹配(移除 `-20250514`)→ 透傳。 + + +無法解析的日期型 Desktop ID 也可能是探索結果中缺少的真實原生模型 ID。現有資訊不足以 +解析該 ID 時,Messages 和 count-tokens 回傳 HTTP 503 及固定錯誤 `desktop_model_mapping_unavailable`;這不代表 +模型無效。未知的舊版雜湊別名仍回傳 HTTP 400。兩種情況都不會移除日期或回退到其他路由。 +已知 ID、已註冊映射、精確 `modelMap` 匹配及已識別的真實原生 ID 維持原有處理方式。 +請重新整理模型探索或重新套用已連接 hub 的設定後再試;僅重試本身不能保證解決。 + 每個條目都帶有類似 `gemini-3-pro (gemini)` 的顯示名稱,以及官方 `ModelInfo` 結構中的完整 模型能力(推理強度階梯、思考型別)。真正的 Anthropic 模型在兩個介面上都保留其規範 ID。 @@ -298,6 +355,8 @@ opencodex 會在**已路由**請求中將該技能內容替換為一個短佔位 查詢順序:發現別名 → 精確 ID → 移除日期字尾的 ID(`-20250514`)→ 透傳。 +拒絕規則請見 [Desktop 別名解析](#desktop-alias-resolution)。 + ## Sidecar 矩陣:Web Search 與圖像理解 不同路由模型擁有的託管工具和圖像能力並不相同。opencodex 會在主模型回答前補齊這些能力: diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 4166276fbc..f371457be9 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -266,8 +266,12 @@ OpenCodex 直接注入路由,請先將 Codex 切回內建 `openai` provider, 已發現模型。不在 allowlist 中的 id 永遠不會進入目錄。 2. **`disabledModels`(頂層)**:會同時從目錄與 `/v1/models` 隱藏模型,並把裸原生 GPT slug 設為 `visibility: "hide"`。 -3. **`liveModels: false` 且 `models` 為空**:當即時探索關閉,且 `models` 為空或省略時,opencodex - 不會為該 provider 暴露任何路由模型。 +3. **`liveModels: false`** — `liveModels: false` 時,若 `models` 為空或省略,初始列表先加入已設定的 `defaultModel`, + 再加入 `retainModels`,重複 ID 僅保留首次出現的位置。若明確設定了非空 `models`,則按 + `models`、`retainModels` 順序建立,不會自動加入另一個 `defaultModel`;仍可將該模型明確寫入 + `models` 或 `retainModels`。這些欄位均未提供 ID 時,初始列表為空。此順序不保證最終選擇器的顯示順序。 + `selectedModels`、`disabledModels` 與供應商停用規則仍然適用。`authMode: "forward"` 保留原有獨立分支, + 不使用此靜態路由列表。這些規則不改變即時探索失敗時的後備行為。 4. **Cursor `GetUsableModels`**:Cursor adapter 透過 protobuf `GetUsableModels` RPC 探索模型,而不是 `/models`,所以 Cursor 端變更可獨立改變可見 id。 5. **cache 與 `ocx sync`**:即時目錄約快取五分鐘(`modelCacheTtlMs`,預設 `300000`)。執行 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 426cdae162..54751d5620 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -60,6 +60,8 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil **如果某個值無法忠實重寫,開關會拒絕執行。** 往返覆蓋這些格式在實務上會用到的值種類;當它做不到時——例如使用 `inf` 或 `nan` 的 TOML 檔案,我們可用的 parser 無法準確讀回——套用會停止並說明,而不是寫入被改動的值然後宣稱成功。你會看到檔案被指名,磁碟上沒有任何東西被移動。手動編輯那個檔案仍然有效;只有我們的自動重寫會拒絕。 +TOML 日期與時間值也會阻止自動重寫:合併步驟會將這些帶有型別的值轉成加引號的字串,陣列和行內表格中的值也一樣。原本就加引號的日期字串仍受支援;若要保留不加引號的日期型別,請手動編輯設定。 + **Pi、Kimi Code、Gajae Code、MiniMax Code 與受管理 DSH 整合只能對 loopback bind 運作。** 前四者的設定沒有非 loopback bind 所需的 `x-opencodex-api-key` header 欄位。DSH 雖然提供通用 headers map,但 rc.6 並未把這個專用准入 header 記錄為受支援的整合契約,因此受管理 writer 會選擇安全拒絕,而不自行猜測。請改用 SSH tunnel,或由本機 forwarder 加上該 header 後再以 loopback 存取。 **產生的 OMP 整合也刻意只支援 loopback。** OMP 確實支援 provider 層級的 headers,但這個最初的整合不會發出遠端 `x-opencodex-api-key` 憑證連線。手動的遠端 OMP 設定目前不在受管理的整合範圍內。 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md index efd505db85..d944aef199 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md @@ -14,6 +14,8 @@ Codex 的 models-manager 按 `priority` 升序排列選擇器中可見的目錄 因此,opencodex 透過分配更低的 priority 控制置頂位置,而不依賴陣列位置。相關 priority 如下: +以下優先級表與範例適用於未啟用完整選擇器排序的情況。 + | 目錄條目 | Priority | 來源 | | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | @@ -92,10 +94,51 @@ subagentModels = [ ## 更改順序 -自訂開頭模型順序的唯一受支援方式是重新排列 `subagentModels`。你可以在儀表板的 +要調整 `spawn_agent` 候選模型的順序,請重新排列 `subagentModels`。你可以在儀表板的 **Sub-agents** 頁面或 opencodex 設定中修改它。該列表最多接受五個模型,其陣列順序有實際意義。 -目前 `OcxConfig` 中沒有通用的 `modelOrder`、`providerOrder` 或 priority map 設定。受支援的排序 -欄位是 `subagentModels`(`src/types.ts:238-246`);`disabledModels` 和各 provider 的 -`selectedModels` 都是可見性欄位(`src/types.ts:276-282`、`src/types.ts:439-446`)。因此,要更改 -選擇器其餘部分的順序,需要修改程式碼行為,而不是調整設定。 +`modelPickerOrder` 只控制選擇器的顯示順序。如果列表只有路由 ID `/`, +其中未置頂的列會按列表順序進入獨立的顯示區間(`1000 + i`)。未列出的路由列保留原有優先級, +因此仍排在該區間之前。同時列在 `subagentModels` 中的列保留置頂優先級,原生列也維持原有位置。 +需要控制相對順序的路由列都應列入列表。 + +要對整個選擇器排序,請加入至少一個不含 `/` 的裸目錄 ID,例如 `gpt-5.6-sol`。 +空字串或只有空白的項目不會啟用此模式。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +列出的項目按陣列順序排在最前面,未列出的項目隨後按原有優先級排列。比對使用精確的目錄 ID: +`gpt-5.6-sol` 和 `openai/gpt-5.6-sol` 是不同的列。同一路由 ID 的原始寫法和編碼寫法也可比對, +但精確比對優先於等價比對。空項目和只有空白的項目會被忽略。帳號限定列必須使用包含 selector 的完整 ID。 + +### 遷移提醒:現有列表中的原生 ID + +以前 `modelPickerOrder` 中的裸原生 ID 會被忽略。現在,現有列表只要包含這類 ID,就會啟用 +整個選擇器的排序,包括置頂列。要保留以前只調整路由列的行為,請移除裸 ID。 +未設定、空列表、只有空白項目的列表以及只有路由 ID 的列表都保留原有行為。 + +`modelPickerOrder` 保留 OpenCodex 按原有優先級計算最多五個偏好候選項的規則,供子代理指引使用。 +每個移動列的原有優先級與原生 `priority` 分開儲存;僅改變選擇器順序不得改變這項計算結果。 +它也不會限制以精確模型名稱指定 override 的資格:公佈的列表不是允許清單,既有的驗證、模型、 +effort 與後端限制仍然適用。 + +原生 Codex 按原生 `priority` 排序,從符合條件且在選擇器中可見的模型中取前五個,公佈在 +`spawn_agent` 中。這適用於 V1,以及開放模型 override 的 V2。因此,即使 OpenCodex 的偏好候選項 +不變,原生公佈的五個模型仍可能隨選擇器順序改變。V1 不接收 OpenCodex 注入的偏好模型列表。 +V2 在用戶端目錄狀態允許時,可以額外接收基於原有優先級的 OpenCodex 指引;這些指引不會重排 +原生工具公佈的列表。 + +`disabledModels` 和各供應商的 `selectedModels` 仍是可見性欄位。沒有獨立的 `modelOrder`、 +`providerOrder` 或優先級對應表設定。 + +## 儀表板排序預設 + +在 **Models** 選擇預設、依模型名稱 A–Z、依供應商或使用量快照,再套用順序。儲存目前可用的路由 ID 和 `modelPickerOrderMode`(`alphabetical`、`provider`、`most-used`)。使用量排序僅在套用時讀取一次保留的全部歷史;重新開啟或模型增減不會重新計算。現有自訂與原生完整順序會保留,直到明確套用替換。即使沒有可用模型,預設也能清除兩個欄位。 + +`GET/PUT /api/subagent-models` 的 `chosen`、`available` 保留停用或缺少的已存 roster;`pickerAvailable` 僅包含可選路由 ID。Models 只傳送 `pickerOrder`、`pickerOrderMode`,不傳送 `models`。只儲存 roster 不影響排序;無效輸入或儲存失敗會保留原狀態。 + +預設保留精選與原生優先級區間,套用於 Codex 目錄和 Claude 探索清單的路由群組。Claude 原生前綴、明確的 Desktop 設定及 alias 歸屬不變。OpenCodex 指引排序和 fallback 設定不變,但原生 Codex 工具顯示的前五個候選與建議預設模型可能改變。儲存不會重新啟動用戶端;目錄更新可能尚未完成,舊清單可能需要重新開啟用戶端。 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-routing.md b/docs-site/src/content/docs/zh-tw/guides/model-routing.md index a3cb08885f..0a716351ee 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-routing.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-routing.md @@ -55,9 +55,13 @@ OpenAI 的 bare `gpt-*` 使用單一 `openai` provider。`codexAccountMode` 在 目錄和 `/v1/models` 輸出的模型範圍。 - `provider.disabled: true` 會把該供應商排除在目錄發現之外。顯式 `provider/model` 請求會失敗, `defaultModel` / `models[]` 掃描也會跳過它。 -- `providerContextCaps` 為各供應商設定 Codex 可見的上下文上限。`contextCapValue` 是儀表板共用的值, - 預設為 350,000;但只有 `providerContextCaps` 中列出了供應商時才會生效。上限只能降低已知上下文, - 不會把它調高,也不會改變上游模型的實際限制。 +- `providerContextCaps` 為各供應商設定 Codex 可見的上下文上限。`contextCapValue` 是儀表板的預設值, + 預設為 350,000;僅設定此值不會套用上限,供應商必須列在 `providerContextCaps` 中才會生效。 + 勾選「套用至所有路由供應商」後,修改儀表板值只會更新已啟用的上限;未勾選時,各供應商保留自己的上限。 + 一般已知視窗只能縮小;支援長視窗的原生模型可以擴展到該模型支援的上限,但不會改變上游模型的實際限制。 + 停用上限後,選擇值儲存在 `providerContextCapValues` 中,重新載入後仍保留;再次啟用時恢復該選擇值。 + 停用期間不會將儲存值套用為限制。不帶 `value` 的 `{ "setAll": true }` 會以目前全域值啟用所有 + 已設定供應商的上限,並取代其儲存的選擇值。 ```json { diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index be149d5901..c94572d96c 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -112,6 +112,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | +Google Antigravity 帳戶與供應商的配額查詢(包括模型清單備援)使用固定的 Google 計量端點。這些目標支援透明 Fake-IP DNS,同時保留 TLS 驗證、重新導向拒絕與私有位址檢查。自訂 base URL 只改變模型請求,不改變配額目標;`NO_PROXY` 仍使用直連政策。 + + 終端 Nous refresh 失敗後,執行 `ocx login nous` 重新認證。 對 canonical Kimi Coding Plan preset(`kimi` 帳號登入與 `kimi-code` API key),opencodex 只會把 caller diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md index e6ae93a76e..4aa10fcaa7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md @@ -32,6 +32,10 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi ## 試跑已儲存的設定檔 +候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 +無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 +缺少或停用的供應商也會在評分前以 `route-unavailable` 排除。 + 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 未儲存的編輯不會被試跑使用。請先儲存設定檔,讓顯示的 revision 與評估參照同一份設定。 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 0bdb3df097..6c8cd26c96 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -51,6 +51,14 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **Usage / Debug** | 檢視 token usage 覆蓋率與趨勢,或啟用可選的 provider transport 和 usage 提取診斷。 | | **Stop** | 優雅地停止代理和已安裝的後臺服務,恢復原生 Codex 並退出(`POST /api/stop`)。在使用工作排程器後端的 Windows 上,儀表板會拒絕並提示改用 `ocx stop`:工作結束後包裝程序仍可能重新啟動 Proxy,只有執行在 Proxy 之外的 stop 才能在還原用戶端設定前確認這個重啟視窗。被拒絕時不會做任何變更。 | +### 篩選請求日誌 + +Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。 + +時間範圍為最近 15 分鐘、1 小時或 1 天;Logs 分頁每 30 秒更新一次,即使關閉自動重新整理也會更新。速度按完整請求耗時計算每秒輸出 token,分為小於 15、15 至小於 50、至少 50;啟用速度篩選時排除無測量值的請求。成功為 2xx,錯誤為 4xx/5xx。 + +顯示符合數與已載入總數;重設恢復全部列,並區分無符合結果與空日誌。介面選擇支援方向鍵及 Home/End,不查詢已載入範圍以外的歷史記錄。 + ### 連結到某個部分 佈局只有一種且會自適應,無需切換。桌面版使用側邊欄進行主要導覽;窄螢幕時點選 **開啟選單** 可展開相同的頁面連結。Dashboard 的各個部分都有自己的地址:`#dashboard` 開啟 Overview,`#dashboard/providers` 與 `#dashboard/models` 開啟另外兩個。重新整理、收藏和後退都會保留目前所在的部分。**Logs** 同理,使用 `#logs` 與 `#logs/debug`。舊的 `#providers/workspace` 書籤現在會跳轉到 `#providers`。 @@ -64,6 +72,19 @@ Overview 也提供 **30 天活動** 面板,顯示 30 天的請求和 token 趨 **Models** 開關表示 Codex 中的最終可見狀態。路由模型只有在 provider allowlist 中(或未設定 allowlist)且未被停用時才會開啟。開啟模型會原子地協調兩個過濾條件;**全部開啟** 會清除 allowlist,因此以後新發現的模型也會開啟。 +### 在供應商工作區管理模型 + +在供應商的**模型**分頁中,**刪除**會移除已儲存的自訂定義。原有的原生模型或即時探索到的模型可能 +重新顯示,因此模型數量可能維持不變。**隱藏**只改變目錄可見性,不會刪除定義,也不會改變直接路由規則。 +點選**在模型中管理可見性**可開啟**模型**頁面並恢復顯示;即使供應商分頁已沒有任何模型列,也能使用此入口。 + +**新增**會儲存自訂定義,但不會清除既有的隱藏狀態或供應商選擇規則。儲存後的模型可能仍被隱藏。 +如果模型已存在,請在**模型**頁面管理其可見性。已確認儲存時,即使目錄重新整理失敗,定義也已儲存; +請依重新整理提示操作,不要重複新增。若無法確認變更結果,請先重新整理模型狀態,再重試。 + +供應商的模型數量統計伺服器回傳的目前模型清單中未停用的唯一項目,計數在搜尋與顯示數量限制之前進行。 +它不是允許清單的大小或即時探索的模型數量,也不能證明項目來自上游探索。選擇標記與探索資訊和此計數分開顯示。 + ## 委派選擇器與生成路由的區別 Dashboard 的 **Sub-agent delegation** 選擇器會儲存 `injectionModel`,以及可選的 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index bb377466fd..71d575e774 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -147,6 +147,10 @@ ocx status --json 將 opencodex 作為登入管理的背景服務執行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登入時自動啟動並在崩潰時自動重啟。服務執行時設定 `OCX_SERVICE=1`,使重啟不會折騰 Codex 設定。 +Windows 工作排程器安裝使用一般處理程序優先順序(`Priority=4`)。舊的背景優先順序(`7`,省略時排程器也預設使用 `7`) +可能在 CPU 競爭時延遲健康檢查回應,導致處理程序仍在執行時系統匣顯示 Offline。升級後執行 `ocx service repair`, +即可遷移該註冊優先順序並重新啟動服務;過程中可能需要核准 UAC 提示。已設為一般或高優先順序時,不會僅因優先順序而重新註冊。 + | 子指令 | 動作 | | --- | --- | | 無 | 服務不存在時安裝並啟動;已存在時重新整理並重啟。正常的 Windows 工作排程器定義會沿用;過時的定義可能會重新註冊並需要提高權限。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index fbe9c5c1f0..2aa988371b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -129,10 +129,11 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account auto-switch > [--json]` -僅控制 `openai` Codex 帳號池。`on` 設為 80%,`off` 設為 0%,`status` 讀取目前值,而 `threshold ` 接受 0 到 100 的整數。其他供應商與無效值離開 1。`--json` 回傳: +控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值目前不參與執行;儲存閾值不會啟用閾值切換、改變供應商啟用設定或停用 429 錯誤後的輪替。通用池的查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值未套用;能力未知時也不會回報 `enabled: true`。API 金鑰供應商、Anthropic 與無效值會被拒絕。 ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account login|reauth|code|cancel ...` diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md index 63ef9a64bd..fc0bd4ba7e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md @@ -50,7 +50,7 @@ V1 指引僅在 `max` 或 `ultra` 時為主動文字。V2 僅在存在偏好模 Codex 0.146+ 會將角色檔案中的 `model_fallback` 視為未知欄位並略過整個角色;`ocx doctor` 也會對此發出警告。因此新的角色級 fallback 應設定在 opencodex,而不是角色 TOML 中。 -opencodex 會跳過已停用、不可路由、不健康、冷卻中或達到配額閾值的候選項。可用性快取保存 `subagentModelFallbackPollMs`。對於加密的子任務,候選鏈僅包含規範的原生 ChatGPT 目標,以及透過 `allowEncryptedV2AgentTasks: true` 明確信任的直接金鑰驗證 Responses 路由。若無任何目標可處理加密 payload,請求會失敗,而不會將無法讀取的密文路由到別處。組合仍只使用規範的原生目標。 +opencodex 會跳過已停用、不可路由、不健康、冷卻中或達到配額閾值的候選項。可用性快取保存 `subagentModelFallbackPollMs`。對於加密的子任務,候選鏈僅包含規範的原生 ChatGPT 目標,以及透過 `allowEncryptedV2AgentTasks: true` 明確信任的直接金鑰驗證 Responses 路由。若無目標可處理加密 payload,且選用的恢復功能無法支援路由傳送,請求會失敗,不會轉送無法讀取的密文。組合會先嘗試可用的規範原生目標;若沒有可選擇的原生目標或原生嘗試已耗盡,且已啟用 `agentTaskRecovery`,會在路由到組合目標前對加密的 `NEW_TASK` 恢復一次。 ```json { diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 4a7b3c466b..34b2e95cf8 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -33,8 +33,9 @@ ocx models provider openrouter on | `providers` | `Record` | — | 供應商名稱到供應商設定的映射。 | | `openaiProviderTierVersion?` | `2` | 由遷移設定 | 標記單一選項感知的 OpenAI projection 已完成。 | | `disabledModels?` | `string[]` | — | 對 Codex 目錄與 `/v1/models` 隱藏的模型,但不阻擋直接代理呼叫。路由 id 從清單中移除;裸原生 GPT id 取得 `visibility: "hide"`。 | -| `providerContextCaps?` | `Record` | `{}` | Per-供應商的 Codex 可見 context 上限。上限只會降低已知的 context window。 | -| `contextCapValue?` | `number` | `350000` | 儀表板 context-cap 控制使用的值;變更它會更新每個啟用的 `providerContextCaps` 項目。 | +| `providerContextCaps?` | `Record` | `{}` | 各供應商目前生效的上下文上限。一般視窗只能縮小;支援長視窗的原生模型可以擴展到該模型支援的上限。 | +| `providerContextCapValues?` | `Record` | `{}` | 各供應商最後選擇的上限,停用後仍保留。僅儲存這些值不會啟用上限。生效中的值優先於儲存的選擇值。 | +| `contextCapValue?` | `number` | `350000` | 首次啟用時使用的預設值。再次啟用時恢復該供應商的選擇值。修改全域值時附帶 `setAll: true` 只會更新已啟用的上限;不帶值的 `setAll: true` 會以目前全域值啟用所有已設定供應商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池帳號中繼資料。秘密分別存在 `codex-accounts.json`。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | @@ -70,7 +71,7 @@ ocx models provider openrouter on | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 金鑰標頭風格。預設為原生 `x-api-key`;僅對 key-auth `anthropic` 供應商有效。 | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多金鑰池。`apiKey` 反映現用項目;每個項目有 `id`、`key`、可選 `label` 與可選數值 `addedAt`。 | | `defaultModel?` | `string` | 在未指定明確模型時選擇此供應商所使用的模型。 | -| `models?` | `string[]` | 播種/後備模型清單。在 `liveModels: false` 時,這些是唯一探索的模型。 | +| `models?` | `string[]` | 初始/後備模型列表。`liveModels: false` 時,非空 `models` 後接 `retainModels`;若 `models` 為空或省略,則按已設定的 `defaultModel`、`retainModels` 順序建立初始列表,重複 ID 僅保留首次出現的位置。 | | `liveModels?` | `boolean` | 在啟動/同步時擷取即時目錄(預設 `true`)。自訂供應商使用 `${baseUrl}/models`;內建可能使用 registry URL 並過濾。 | | `selectedModels?` | `string[]` | 探索後的目錄允許清單。非空時僅暴露那些 id;空或省略時暴露所有探索的模型。 | | `contextWindow?` | `number` | 供應商範圍的 Codex 可見 context 上限。較小的即時中繼資料被保留。 | @@ -333,7 +334,14 @@ Vercel AI Gateway 可在多個底層推論供應商之間路由一個模型。`v ## 靜態模型允許清單 -設定 `liveModels: false` 以僅暴露 `models`。若 `models` 為空或省略,供應商暴露無路由模型。即時探索在快取前拒絕超過 4 MiB 或 2,000 個原始模型列;內建預設可能使用較低限制並過濾到 chat 合格列。過大或格式錯誤的結果遵循過時/設定的後備。有效的零合格結果恆為權威,且不被靜默取代或截斷。 +`liveModels: false` 時,若 `models` 為空或省略,初始列表先加入已設定的 `defaultModel`, +再加入 `retainModels`,重複 ID 僅保留首次出現的位置。若明確設定了非空 `models`,則按 +`models`、`retainModels` 順序建立,不會自動加入另一個 `defaultModel`;仍可將該模型明確寫入 +`models` 或 `retainModels`。這些欄位均未提供 ID 時,初始列表為空。此順序不保證最終選擇器的顯示順序。 +`selectedModels`、`disabledModels` 與供應商停用規則仍然適用。`authMode: "forward"` 保留原有獨立分支, +不使用此靜態路由列表。這些規則不改變即時探索失敗時的後備行為。 + +即時探索在快取前拒絕超過 4 MiB 或 2,000 個原始模型列;內建預設可能使用較低限制並過濾到 chat 合格列。過大或格式錯誤的結果遵循過時/設定的後備。有效的零合格結果恆為權威,且不被靜默取代或截斷。 當探索應仍然執行但只有 selected id 應出現在 Codex 與 `/v1/models` 時,請使用 `selectedModels`。儀表板保留完整的探索清單供日後允許清單變更。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 3f10c76f35..e45dd3cd07 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -210,3 +210,7 @@ Anthropic OAuth sidecar 重用 opencodex 既有的 Claude Code OAuth 指紋。 `runtimeRole` 預設為 `standalone`。Hub 使用 `hub.managementPublicOrigin`、僅限迴路的 `hub.managementIngress`(缺省為 `enabled:false`)與正確的 `remoteGui.allowedTailscaleUsers`(缺省為空)。用戶端金鑰保存在 `service-api-token` 而不是 `config.json`;輪替期間可能暫時存在 `service-api-token.prev`。用量不會鏡像。 `remoteGui.allowInsecureHttp` 是已棄用的 no-op,只為讓舊的 strict-schema 設定繼續載入而保留。請從設定移除:pairing grant 僅接受 loopback 或已驗證的 HTTPS;設為 `true` 也不會重新開放明文 HTTP pairing。 + +## Codex 配額網路診斷 + +主 Codex 帳戶列中的 `quotaRefresh` 描述配額查詢結果,並不代表剩餘配額或模型存取權限。讀取快取或未執行查詢時,這個欄位可能省略。查詢使用執行中代理服務的環境,而不是目前終端機的環境。未設定 `proxy` 時保留既有環境;`"auto"` 只在啟動時讀取 Windows 靜態代理設定,不會自動處理 PAC/WPAD、僅 SOCKS 的設定或執行中的變更。TUN 測試成功本身不能證明 HTTP 代理路徑正常。命令與狀態說明請見[英文網路診斷章節](/reference/configuration/server/#codex-quota-network-diagnostics)。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 5e7fca910b..8319dda9b0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -144,12 +144,15 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET /api/models` | 回傳儀表板/CLI 模型列 | 收集飽和時 `catalog_busy` | | `GET /api/client-config?client=...` | 為 `opencode`、`pi`、`omp`、`hermes`、`openclaw`、`kimi`、`gajae` 或 `dsh` 建構唯讀客戶端設定 | 400 不支援客戶端;503 目錄不可用 | | `PUT /api/disabled-models` | 取代共享的 disabled-model 清單 | 400 無效 JSON | -| `PUT /api/model-visibility` | 原子地變更供應商或模型層級可見性 | 400 無效供應商、scope、目標或 body | +| `PUT /api/model-visibility` | 原子地變更供應商或模型層級可見性 | 400 無效供應商、scope、目標或 body; 409 `initial_model_selection_pending` (重新整理模型清單後再試。) | | `GET, POST /api/custom-models` | 列出自訂模型或新增一個 | 400 無效欄位;404 供應商缺失;409 重複模型 | | `PUT, DELETE /api/custom-models/{id}` | 編輯或刪除一個自訂模型 | 400 無效 id/欄位;404 未找到;409 重複模型 | | `GET, PUT /api/selected-models` | 讀取供應商允許清單與可用性,或取代一個允許清單 | 400 缺失供應商/body;404 未知供應商; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | 讀取預設資訊或選擇 preset/all/custom 模式 | 400 模式無效或不支援該預設;404 未知供應商; PUT 409 `initial_model_selection_pending` | +手動模型會取代 Models 儀表板中 provider 與 model ID 相同的列。OpenAI 手動列保留 `openai/`,並支援可見性控制。刪除手動列後,不含帳戶限定符的原生列會恢復。含帳戶限定符的原生列仍獨立保留。原生路由與帳戶權限不變。非原生 OpenAI 可見性目標必須符合已設定的手動模型。 + + 尚未確認可靠的初始模型清單時,有效的 `PUT /api/selected-models` 和 `PUT /api/model-presets` 請求也會回傳 HTTP 409 和代碼 `initial_model_selection_pending`。請使用 `GET /api/models` 等方式更新模型清單,成功後再重試。 ### OAuth 帳號、供應商金鑰與 data-plane 金鑰 @@ -188,6 +191,14 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET, PUT /api/provider-context-caps` | 讀取或更新全域、所有供應商或單一供應商的 context 上限 | 400 無效請求;404 未知供應商 | | `GET /api/provider-presets` | 回傳從 runtime registry 衍生的 GUI 供應商預設 | — | +上下文上限回應包含 `caps`(目前生效的上限)和 `values`(停用後仍保留的最後選擇值)。 +啟用供應商的上限時,若未指定 `value`,便會恢復其選擇值;首次啟用時使用全域 `contextCapValue`。 +OpenAI 也遵循此規則:開關不會選擇特殊的 922k 模式。生效中的上限會限制每個原生視窗;支援長上下文 +的模型只能擴展到該模型支援的上限。 +`{ "value": 600000, "setAll": true }` 會修改全域值,並且只更新已啟用的上限;上限已停用的供應商會 +保留自己的選擇值,供之後啟用時恢復。不帶 `value` 的 `{ "setAll": true }` 會以目前全域值啟用所有 +已設定供應商的上限,並取代儲存的選擇值。停用不會清除選擇值,重新載入後仍保留,但不會將其套用為限制。 + `provider_has_dependent_combos` 是安全屏障:在刪除其供應商前,先移除或編輯相依的組合。 ### 側邊欄與同意約束動作 diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 1cef760881..24abd657c4 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -22,7 +22,7 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE,以 `[DONE]` 結束 | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token 計數 | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | 不適用 | -| 模型探索 | `GET /v1/models` | 三種目錄契約之一 | 不適用 | +| 模型探索 | `GET /v1/models` | 目錄或明確指定的 Desktop 快照 | 不適用 | | 語音與 Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | 中繼的 call-creation 回應 | 一個獨立的 sideband WebSocket 雙向中繼 frame | | Responses compaction | `POST /v1/responses/compact` | 取代歷史 JSON | 不適用 | @@ -150,9 +150,15 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 { "input_tokens": 123 } ``` +無法解析的日期型 Desktop ID 也可能是探索結果中缺少的真實原生模型 ID。現有資訊不足以 +解析該 ID 時,Messages 和 count-tokens 回傳 HTTP 503 及固定錯誤 `desktop_model_mapping_unavailable`;這不代表 +模型無效。未知的舊版雜湊別名仍回傳 HTTP 400。兩種情況都不會移除日期或回退到其他路由。 +已知 ID、已註冊映射、精確 `modelMap` 匹配及已識別的真實原生 ID 維持原有處理方式。 +請重新整理模型探索或重新套用已連接 hub 的設定後再試;僅重試本身不能保證解決。 + ## `GET /v1/models` -相同路由服務三個期待不相容目錄封裝的客戶端。除非也存在 `client_version`,否則 Anthropic flavor 勝出。 +未指定 `format=desktop-config` 時,使用以下一般目錄契約: | 契約 | 觸發 | 頂層結構 | 模型 id 行為 | | --- | --- | --- | --- | @@ -160,6 +166,25 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 | Codex 目錄 | `client_version` query 參數 | `{ "models": [...] }` | 原生與路由項目帶有更豐富的 Codex 目錄欄位、可見性、effort、WebSocket 與多代理中繼資料 | | 普通 OpenAI 清單 | 無觸發 | `{ "object": "list", "data": [...] }` | 可見的原生 id 為裸 id;路由 id 為別名或 `provider/model` | +### Desktop 設定快照 + +`GET /v1/models?ids=desktop&format=desktop-config` 明確選擇 Desktop 快照,不依賴 +user-agent。回應為 `{ "version": 1, "models": [...] }`,帶有 `Cache-Control: no-store`。 +客戶端送出 `Accept: application/json`、`anthropic-version: 2023-06-01` 及現有資料存取憑證; +不需要管理員權杖,也不上傳設定檔。項目是 hub 發出的 Desktop 設定模型,不是 Codex 目錄列。 + +此格式與 `ids=cli` 或任何 `client_version` 一起使用時回傳 HTTP 400。未指定格式時,上述一般 +契約維持不變。Claude 關閉時回傳 `{ "version": 1, "models": [] }`;已連接的 Desktop apply +會視為無法使用,不寫入替代設定。回傳一般目錄而非版本 1 的舊 hub 不受支援,客戶端不會改用 +本機產生的 ID。 + +快照仍是唯讀模型清單,不是金鑰輪換或設定檔上傳 API。Desktop 金鑰移轉、復原與中斷由既有 +客戶端連線流程處理。輪換保留模型項目和選擇;CLI 的 `rotation` 區分 `committed` 與 +`rolled_back`。中斷會還原管理設定,或對已確認的舊設定檔回報標準回退,同時保留使用者欄位和 +後來有效的選擇。衝突或未完成的復原不會標為完成。需要重新啟動 Desktop 才會讀取磁碟變更; +中斷不會自動撤銷 hub 金鑰。參見 [Desktop 指南](/zh-tw/guides/claude-code/)。 +thinking 重播與提示快取仍由獨立的 [#3719](https://github.com/lidge-jun/opencodex/issues/3719) 跟進。 + ## `POST /v1/live` 與 Realtime sideband `POST /v1/live` 接受 ChatGPT/Codex App Frameless call-creation 介面。 diff --git a/docs/pr-assets/dashboard-settings-aligned.jpg b/docs/pr-assets/dashboard-settings-aligned.jpg new file mode 100644 index 0000000000..8cf7c1f26f Binary files /dev/null and b/docs/pr-assets/dashboard-settings-aligned.jpg differ diff --git a/gui/src/components/provider-workspace/ProviderModelChip.tsx b/gui/src/components/provider-workspace/ProviderModelChip.tsx new file mode 100644 index 0000000000..9ab2e66739 --- /dev/null +++ b/gui/src/components/provider-workspace/ProviderModelChip.tsx @@ -0,0 +1,37 @@ +import type { MouseEvent } from "react"; +import type { ModelRow } from "../../pages/models-shared"; +import { useT } from "../../i18n/shared"; +import { IconEyeOff, IconTrash } from "../../icons"; + +/** Presentation only: ProviderModels owns identity, readiness and all mutations. */ +export default function ProviderModelChip({ row, disambiguate, copied, isDefault, selected, action, disabled, onCopy, onRemove }: { + row: ModelRow; + disambiguate: boolean; + copied: boolean; + isDefault: boolean; + selected: boolean; + action: "delete" | "hide" | null; + disabled: boolean; + onCopy: () => void; + onRemove: (button: HTMLButtonElement) => void; +}) { + const t = useT(); + const label = t(action === "delete" ? "models.customDelete" : "models.hide"); + return ( +
  • + + {isDefault && {t("prov.defaultBadge")}} + {selected && {t("pws.selected")}} + {action && } +
  • + ); +} diff --git a/gui/src/model-picker-order.ts b/gui/src/model-picker-order.ts new file mode 100644 index 0000000000..dfa5073b08 --- /dev/null +++ b/gui/src/model-picker-order.ts @@ -0,0 +1,106 @@ +export type ModelPickerOrderMode = "default" | "alphabetical" | "provider" | "most-used" | "custom"; +export type SavedModelPickerOrderMode = Exclude; +export interface ModelPickerUsage { + provider: string; + model: string; + resolvedModel?: string; + requests: number; +} +export interface PickerModelIdentity { provider: string; id: string; namespaced: string } +export interface PickerOrderSaved { + pickerOrder: string[]; + pickerOrderMode: SavedModelPickerOrderMode | null; +} +export interface PickerOrderSettings extends PickerOrderSaved { pickerAvailable: string[] } + +function stringList(value: unknown): value is string[] { + return Array.isArray(value) && value.every(id => typeof id === "string" && id.trim().length > 0); +} +function savedMode(value: unknown): value is SavedModelPickerOrderMode | null { + return value === null || value === "alphabetical" || value === "provider" || value === "most-used"; +} +export function isPickerOrderSaved(value: unknown): value is PickerOrderSaved { + if (value === null || typeof value !== "object") return false; + const row = value as Record; + return stringList(row.pickerOrder) && savedMode(row.pickerOrderMode); +} +export function isPickerOrderSettings(value: unknown): value is PickerOrderSettings { + return isPickerOrderSaved(value) && stringList((value as PickerOrderSettings).pickerAvailable); +} +export function isModelPickerUsage(value: unknown): value is ModelPickerUsage[] { + return Array.isArray(value) && value.every(row => row !== null && typeof row === "object" + && typeof row.provider === "string" && typeof row.model === "string" + && (row.resolvedModel === undefined || typeof row.resolvedModel === "string") + && typeof row.requests === "number" && Number.isFinite(row.requests) && row.requests >= 0); +} +function parts(slug: string): [string, string] { + const slash = slug.indexOf("/"); + return slash < 0 ? ["", slug] : [slug.slice(0, slash), slug.slice(slash + 1)]; +} +// Fixed locale makes snapshots independent of the user's display language/OS locale. +const compare = (a: string, b: string) => a.localeCompare(b, "en"); +function byProvider(a: string, b: string): number { + const [ap, am] = parts(a), [bp, bm] = parts(b); + return compare(ap, bp) || compare(am, bm); +} + +export function modelPickerOrder( + mode: Exclude, + models: readonly string[], + usage: readonly ModelPickerUsage[] = [], + identities: readonly PickerModelIdentity[] = [], +): string[] | null { + if (mode === "default") return null; + const unique = [...new Set(models)]; + if (mode === "alphabetical") return unique.sort((a, b) => compare(parts(a)[1], parts(b)[1]) || byProvider(a, b)); + if (mode === "provider") return unique.sort(byProvider); + const candidates = new Set(unique); + const raw = new Map>(); + const owners = new Map>(); + for (const row of identities) { + if (!candidates.has(row.namespaced)) continue; + const key = JSON.stringify([row.provider, row.id]); + const values = raw.get(key) ?? new Set(); + values.add(row.namespaced); + raw.set(key, values); + const sources = owners.get(row.namespaced) ?? new Set(); + sources.add(key); + owners.set(row.namespaced, sources); + } + const unambiguous = (slug: string): string | null => (owners.get(slug)?.size ?? 0) > 1 ? null : slug; + const resolve = (provider: string, id: string): string | null | undefined => { + const exact = raw.get(JSON.stringify([provider, id])); + if (exact) return exact.size === 1 ? unambiguous([...exact][0]!) : null; + // A raw upstream slash is not a namespace. Use the observed identity table above; + // only fall back to an exact same-provider catalog id or an ordinary bare model id. + if (id.startsWith(`${provider}/`) && candidates.has(id)) return unambiguous(id); + const slug = `${provider}/${id}`; + return !id.includes("/") && candidates.has(slug) ? unambiguous(slug) : undefined; + }; + const counts = new Map(); + for (const row of usage) { + // Summary buckets are keyed by requested model. resolvedModel is only a + // representative observation, not proof that every request used that target. + const target = resolve(row.provider, row.model); + if (target) counts.set(target, (counts.get(target) ?? 0) + row.requests); + } + return unique.sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || byProvider(a, b)); +} + +export function modelPickerOrderMode( + models: readonly string[], saved: readonly string[], mode?: SavedModelPickerOrderMode | null, +): ModelPickerOrderMode { + if (saved.length === 0) return "default"; + // Existing complete/native orders are never silently replaced by a routed preset. + if (saved.some(id => !id.includes("/"))) return "custom"; + if (mode === "alphabetical" || mode === "provider" || mode === "most-used") return mode; + const candidates = new Set(models); + if (new Set(saved).size !== saved.length || saved.length !== candidates.size + || saved.some(id => !candidates.has(id))) return "custom"; + for (const preset of ["alphabetical", "provider"] as const) { + const expected = modelPickerOrder(preset, models); + if (expected !== null && expected.length === saved.length + && expected.every((id, index) => id === saved[index])) return preset; + } + return "custom"; +} diff --git a/gui/src/pages/integrations/AsideProfilesPage.tsx b/gui/src/pages/integrations/AsideProfilesPage.tsx new file mode 100644 index 0000000000..8d1434dacf --- /dev/null +++ b/gui/src/pages/integrations/AsideProfilesPage.tsx @@ -0,0 +1,166 @@ +import { useCallback, useRef, useState } from "react"; +import { useT } from "../../i18n/shared"; +import { useDataSurface } from "../../data-surface"; +import { DataSurfaceSkeleton } from "../../components/data-surface"; +import ClientMark from "../../components/ClientMark"; +import { markFor } from "../../components/integration-marks"; +import { Notice, Switch } from "../../ui"; +import IntegrationStateBadge from "./IntegrationStateBadge"; +import FileIntegrationPage from "./FileIntegrationPage"; +import { describeRefusal, describeAsideProfileOutcome } from "./refusal-copy"; +import type { AsideProfileOutcome } from "./aside-profile-contract"; +import { IntegrationApiError, toggleIntegration } from "./integration-api"; +import { loadAsideProfiles, syncAsideProfiles, type AsideProfileStatus } from "./aside-profile-api"; + +export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: string; active?: boolean }) { + const t = useT(); + const [selected, setSelected] = useState(null); + const [pending, setPending] = useState(null); + const pendingRef = useRef(false); + const [failure, setFailure] = useState(null); + const [profileFailures, setProfileFailures] = useState>(new Map()); + const fetchProfiles = useCallback((signal: AbortSignal) => loadAsideProfiles(apiBase, signal), [apiBase]); + const resource = useDataSurface(`aside-profiles:${apiBase}`, [apiBase], fetchProfiles, { + enabled: active, + isEmpty: value => !value.error && value.profiles.length === 0, + sessionCacheKey: `ocx.integrations.aside-profiles.v1:${apiBase}`, + }); + const data = resource.state.data; + const profiles = data?.profiles ?? []; + const loadError = resource.state.showError || Boolean(data?.error); + const busy = pending !== null || resource.state.refreshing; + const label = (profile: AsideProfileStatus) => profile.name || t("integrations.aside.profile", { id: profile.profileId }); + + const reconcileOutcomes = (outcomes: AsideProfileOutcome[]) => setProfileFailures(previous => { + const next = new Map(previous); + for (const row of outcomes) { + if (row.ok) next.delete(row.profileId); + else next.set(row.profileId, describeAsideProfileOutcome(t, row)); + } + return next; + }); + const failed = (error: unknown, profileId?: number) => { + if (error instanceof IntegrationApiError && "results" in error.body && error.body.results?.length) { + reconcileOutcomes(error.body.results); + setFailure(t("integrations.aside.partial")); + } else if (profileId !== undefined) { + setProfileFailures(previous => new Map(previous).set(profileId, describeRefusal(t, error))); + } else setFailure(describeRefusal(t, error)); + }; + + const mutate = async (enabled: boolean, profileId?: number) => { + if (pendingRef.current) return; + pendingRef.current = true; + setPending(profileId ?? "all"); + setFailure(null); + try { + const result = await toggleIntegration(apiBase, "aside", enabled, undefined, undefined, profileId); + reconcileOutcomes(result.results ?? (profileId === undefined ? profiles.map(row => row.profileId) : [profileId]) + .map(id => ({ profileId: id, ok: true }))); + } + catch (error) { failed(error, profileId); } + finally { + // Even a refused writer may have durably saved desired sync preferences. + resource.refresh(); + pendingRef.current = false; + setPending(null); + } + }; + const sync = async () => { + if (pendingRef.current) return; + pendingRef.current = true; + setPending("sync"); + setFailure(null); + try { + const result = await syncAsideProfiles(apiBase); + reconcileOutcomes(result.results); + if (!result.ok) setFailure(t("integrations.aside.partial")); + } catch (error) { failed(error); } + finally { + resource.refresh(); + pendingRef.current = false; + setPending(null); + } + }; + + if (selected) return ( +
    + + {profileFailures.has(selected.profileId) && {profileFailures.get(selected.profileId)}} + +
    + ); + + return ( +
    +
    + +

    {t("integrations.aside.profilesTitle")}

    +
    +

    {t("integrations.aside.profilesHint")}

    +
    + 0 && data.enabledCount < data.total)} + onClick={() => void mutate(!data?.allEnabled)} disabled={busy || loadError || profiles.length === 0} + label={t("integrations.aside.all")} showLabel /> + {t("integrations.aside.applied", { count: data?.appliedCount ?? 0, total: data?.total ?? 0 })} + +
    + {failure && {failure}} + {[...profileFailures].filter(([id]) => !profiles.some(row => row.profileId === id)).map(([id, detail]) => ( + {t("integrations.aside.profile", { id })}: {detail} + ))} + {loadError && ( + + {t("integrations.aside.loadError")} + {data?.error && {data.error}} + + + )} + {resource.state.showSkeleton && } + {!resource.state.showSkeleton && !loadError && data && profiles.length === 0 &&

    {t("integrations.aside.empty")}

    } + {profiles.length > 0 && ( +
    + {profiles.map(profile => { + const name = label(profile); + const applied = profile.state === "current" || profile.state === "stale"; + const needsUpdate = profile.enabled !== applied || (profile.enabled && profile.state === "stale"); + const locked = (!profile.installed || profile.state === "unsafe" || profile.state === "conflict") && !profile.enabled; + return ( +
    +
    + +
    + {profile.profileId} + {profile.current && {t("integrations.aside.current")}} +
    + {needsUpdate && {t("integrations.aside.pending")}} + {profile.error && {profile.error}} + {profileFailures.has(profile.profileId) && {profileFailures.get(profile.profileId)}} +
    +
    + + {needsUpdate && } + void mutate(!profile.enabled, profile.profileId)} + disabled={busy || loadError || locked} label={t("integrations.aside.toggle", { name })} /> +
    +
    + ); + })} +
    + )} +
    + ); +} diff --git a/gui/src/pages/integrations/aside-profile-api.ts b/gui/src/pages/integrations/aside-profile-api.ts new file mode 100644 index 0000000000..0c467ea50c --- /dev/null +++ b/gui/src/pages/integrations/aside-profile-api.ts @@ -0,0 +1,46 @@ +import { IntegrationApiError, readIntegrationResponse } from "./integration-api"; + +import { parseAsideProfileStatus, parseAsideProfileOutcomes, type AsideProfileStatus, type AsideProfileOutcome } from "./aside-profile-contract"; +export type { AsideProfileStatus } from "./aside-profile-contract"; + +export interface AsideProfileList { + profiles: AsideProfileStatus[]; + allEnabled: boolean; + enabledCount: number; + appliedCount: number; + total: number; + error?: string; +} +export interface AsideSyncResult { + ok: boolean; + results: AsideProfileOutcome[]; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function invalid(): never { throw new IntegrationApiError(502, { code: "invalid_aside_profile_response" }); } + +export async function loadAsideProfiles(apiBase: string, signal?: AbortSignal): Promise { + const body = await readIntegrationResponse(await fetch(`${apiBase}/api/client-integrations/aside/profiles`, { signal })); + if (!isRecord(body) || !Array.isArray(body.profiles) || typeof body.allEnabled !== "boolean") invalid(); + const profiles = body.profiles.map(value => parseAsideProfileStatus(value) ?? invalid()); + if (new Set(profiles.map(row => row.profileId)).size !== profiles.length) invalid(); + const enabledCount = profiles.filter(row => row.enabled).length; + const appliedCount = profiles.filter(row => row.state === "current" || row.state === "stale").length; + if (body.total !== profiles.length || body.enabledCount !== enabledCount || body.appliedCount !== appliedCount + || body.allEnabled !== (profiles.length > 0 && enabledCount === profiles.length)) invalid(); + return { profiles, allEnabled: body.allEnabled, enabledCount, appliedCount, total: profiles.length, + ...(typeof body.error === "string" ? { error: body.error } : {}) }; +} + +export async function syncAsideProfiles(apiBase: string, signal?: AbortSignal): Promise { + const body = await readIntegrationResponse(await fetch(`${apiBase}/api/client-integrations/aside/sync`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", signal, + })); + if (!isRecord(body) || typeof body.ok !== "boolean" || !Array.isArray(body.results)) invalid(); + if (body.results.some(value => !isRecord(value) || value.client !== "aside")) invalid(); + const results = parseAsideProfileOutcomes(body.results) ?? invalid(); + if (body.ok !== results.every(row => row.ok)) invalid(); + return { ok: body.ok, results }; +} diff --git a/gui/src/pages/integrations/aside-profile-contract.ts b/gui/src/pages/integrations/aside-profile-contract.ts new file mode 100644 index 0000000000..fad2ff3cc4 --- /dev/null +++ b/gui/src/pages/integrations/aside-profile-contract.ts @@ -0,0 +1,55 @@ +import type { IntegrationReason, IntegrationState, IntegrationStatus } from "./integration-api"; + +export interface AsideProfileStatus extends IntegrationStatus { + clientId: "aside"; profileId: number; name?: string; current: boolean; enabled: boolean; error?: string; +} +export interface AsideProfileOutcome { + profileId: number; ok: boolean; state?: IntegrationState; reason?: string; refusalReason?: string; + message?: string; snapshotPath?: string; residual?: boolean; +} +const STATES = new Set(["absent", "current", "stale", "conflict", "unsafe"]); +const REASONS = new Set(["unparseable", "not-regular-file", "foreign-edit", "unowned-key", "blocked-container", "unresolvable-path"]); +function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function id(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } + +/** Shared by list and detail reads, including every field that controls actions or recovery. */ +export function parseAsideProfileStatus(value: unknown): AsideProfileStatus | null { + if (!record(value) || value.clientId !== "aside" || !id(value.profileId) + || typeof value.current !== "boolean" || typeof value.enabled !== "boolean" + || !STATES.has(value.state as IntegrationState) || typeof value.installed !== "boolean" + || typeof value.configPath !== "string" || !Number.isSafeInteger(value.snapshotCount) + || (value.snapshotCount as number) < -1 || typeof value.retentionDegraded !== "boolean" + || (value.reason !== undefined && !REASONS.has(value.reason as IntegrationReason))) return null; + for (const key of ["name", "error", "appliedAt", "lastOpId"]) if (value[key] !== undefined && typeof value[key] !== "string") return null; + return { + clientId: "aside", profileId: value.profileId, current: value.current, enabled: value.enabled, + state: value.state as IntegrationState, installed: value.installed, configPath: value.configPath, + snapshotCount: value.snapshotCount as number, retentionDegraded: value.retentionDegraded, + ...(typeof value.name === "string" ? { name: value.name } : {}), + ...(typeof value.error === "string" ? { error: value.error } : {}), + ...(typeof value.appliedAt === "string" ? { appliedAt: value.appliedAt } : {}), + ...(typeof value.lastOpId === "string" ? { lastOpId: value.lastOpId } : {}), + ...(value.reason !== undefined ? { reason: value.reason as IntegrationReason } : {}), + }; +} + +/** Keep refusal data even when only one profile failed; it cannot be recovered by a later GET. */ +export function parseAsideProfileOutcomes(value: unknown): AsideProfileOutcome[] | null { + if (!Array.isArray(value)) return null; + const results: AsideProfileOutcome[] = []; + for (const row of value) { + if (!record(row) || !id(row.profileId) || typeof row.ok !== "boolean" + || (row.state !== undefined && !STATES.has(row.state as IntegrationState)) + || (row.residual !== undefined && typeof row.residual !== "boolean")) return null; + for (const key of ["reason", "refusalReason", "message", "snapshotPath"]) if (row[key] !== undefined && typeof row[key] !== "string") return null; + results.push({ profileId: row.profileId, ok: row.ok, + ...(row.state !== undefined ? { state: row.state as IntegrationState } : {}), + ...(typeof row.reason === "string" ? { reason: row.reason } : {}), + ...(typeof row.refusalReason === "string" ? { refusalReason: row.refusalReason } : {}), + ...(typeof row.message === "string" ? { message: row.message } : {}), + ...(typeof row.snapshotPath === "string" ? { snapshotPath: row.snapshotPath } : {}), + ...(typeof row.residual === "boolean" ? { residual: row.residual } : {}), + }); + } + return results; +} diff --git a/gui/src/pages/log-poll.ts b/gui/src/pages/log-poll.ts new file mode 100644 index 0000000000..ad13d986f7 --- /dev/null +++ b/gui/src/pages/log-poll.ts @@ -0,0 +1,38 @@ +export interface ParsedLogPollResponse { + rows: T[]; + cursor: string | null; + reset: boolean; + generatedAt?: unknown; + timeZone?: string; + total?: number; +} + +/** Legacy responses replace the window; malformed cursor responses keep last-good data. */ +export function parseLogPollResponse(body: unknown): ParsedLogPollResponse { + if (Array.isArray(body)) return { rows: body as T[], cursor: null, reset: false }; + if (!body || typeof body !== "object") throw new Error("Invalid log response"); + const value = body as Record; + const hasCursor = Object.hasOwn(value, "cursor") || Object.hasOwn(value, "reset"); + if ((value.logs !== undefined && !Array.isArray(value.logs)) + || (hasCursor && (!Array.isArray(value.logs) + || typeof value.cursor !== "string" || value.cursor.length === 0 || value.cursor.length > 512 + || !/^[A-Za-z0-9_-]+$/.test(value.cursor) || typeof value.reset !== "boolean"))) { + throw new Error("Invalid log response"); + } + return { + rows: (value.logs ?? []) as T[], + cursor: hasCursor ? value.cursor as string : null, + reset: value.reset === true, + generatedAt: value.generatedAt, + ...(typeof value.timeZone === "string" ? { timeZone: value.timeZone } : {}), + ...(typeof value.total === "number" && Number.isFinite(value.total) && value.total >= 0 + ? { total: value.total } : {}), + }; +} + +/** Updates/removals arrive as resets. Preserve order and even repeated IDs in valid suffixes. */ +export function mergeLogDelta(previous: T[], incoming: readonly T[], cap = 2000): T[] { + if (incoming.length === 0 && previous.length <= cap) return previous; + const merged = [...previous, ...incoming]; + return merged.length > cap ? merged.slice(merged.length - cap) : merged; +} diff --git a/gui/src/pages/logs-clock.ts b/gui/src/pages/logs-clock.ts new file mode 100644 index 0000000000..e9a8a8bf1f --- /dev/null +++ b/gui/src/pages/logs-clock.ts @@ -0,0 +1,17 @@ +/** Proxy epoch sampled by GET /api/logs, paired with the browser's monotonic receipt time. */ +export interface LogsClockAnchor { + generatedAt: number; + receivedAt: number; +} + +/** Legacy arrays and envelopes without a valid server sample do not replace an anchor. */ +export function logsClockAnchor(generatedAt: unknown, receivedAt: number): LogsClockAnchor | undefined { + return typeof generatedAt === "number" && Number.isFinite(generatedAt) && generatedAt >= 0 + ? { generatedAt, receivedAt } + : undefined; +} + +/** Older proxies retain browser-wall-clock behavior until this resource supplies a sample. */ +export function logsClockNow(anchor: LogsClockAnchor | undefined, monotonicNow: number, wallNow: number): number { + return anchor ? anchor.generatedAt + Math.max(0, monotonicNow - anchor.receivedAt) : wallNow; +} diff --git a/gui/src/pages/logs-surface-keydown.ts b/gui/src/pages/logs-surface-keydown.ts new file mode 100644 index 0000000000..010600d2ab --- /dev/null +++ b/gui/src/pages/logs-surface-keydown.ts @@ -0,0 +1,24 @@ +import type { KeyboardEvent } from "react"; +import type { LogSurfaceFilter } from "./logs-surface-filter"; + +const SURFACES: readonly LogSurfaceFilter[] = ["all", "claude", "codex", "grok"]; + +/** Implements arrow/Home/End navigation for the Logs surface radio group. */ +export function logsSurfaceKeyDown( + e: KeyboardEvent, + current: LogSurfaceFilter, + select: (surface: LogSurfaceFilter) => void, +) { + const index = SURFACES.indexOf(current); + let nextIndex = -1; + if (e.key === "ArrowRight" || e.key === "ArrowDown") nextIndex = (index + 1) % SURFACES.length; + else if (e.key === "ArrowLeft" || e.key === "ArrowUp") nextIndex = (index - 1 + SURFACES.length) % SURFACES.length; + else if (e.key === "Home") nextIndex = 0; + else if (e.key === "End") nextIndex = SURFACES.length - 1; + if (nextIndex < 0) return; + + e.preventDefault(); + const next = SURFACES[nextIndex]!; + select(next); + document.getElementById(`logs-surface-${next}`)?.focus(); +} diff --git a/gui/src/provider-workspace/model-inventory.ts b/gui/src/provider-workspace/model-inventory.ts new file mode 100644 index 0000000000..a21128a802 --- /dev/null +++ b/gui/src/provider-workspace/model-inventory.ts @@ -0,0 +1,121 @@ +import type { ModelRow } from "../pages/models-shared"; +import type { ProviderModelCounts, ProviderAvailableModels, ProviderSelectedModels, ProviderLiveModelCounts } from "./usage"; +import { parseSelectedModels } from "../model-visibility"; + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid model response"); + return value as Record; +} + +function identity(value: unknown): string { + if (typeof value !== "string" || !value.trim()) throw new Error("Invalid model identity"); + return value; +} + +/** Validate server DTOs, without deriving selector spelling or native membership. */ +export function parseModelInventory(value: unknown): ModelRow[] { + if (!Array.isArray(value)) throw new Error("Invalid model inventory"); + const groups = new Map>(); + const rows: ModelRow[] = []; + for (const raw of value) { + const row = record(raw); + const provider = identity(row.provider); + const id = identity(row.id); + const namespaced = identity(row.namespaced); + if (typeof row.disabled !== "boolean") throw new Error("Invalid model visibility"); + for (const flag of ["native", "custom", "initialSelectionPending", "contextCapped"]) { + if (row[flag] !== undefined && typeof row[flag] !== "boolean") throw new Error("Invalid model flag"); + } + if (row.custom === true) identity(row.customId); + if (row.customId !== undefined && (row.custom !== true || typeof row.customId !== "string")) throw new Error("Invalid custom ownership"); + if (row.custom === true && row.native === true) throw new Error("Conflicting model ownership"); + if (row.displayName !== undefined && typeof row.displayName !== "string") throw new Error("Invalid model label"); + for (const field of ["inputModalities", "reasoningEfforts"]) { + if (row[field] !== undefined && (!Array.isArray(row[field]) || row[field].some(v => typeof v !== "string"))) throw new Error("Invalid model metadata"); + } + for (const field of ["contextWindow", "contextCap"]) { + if (row[field] !== undefined && (typeof row[field] !== "number" || !Number.isFinite(row[field]))) throw new Error("Invalid model context"); + } + // Identity/used metadata have been validated at this HTTP boundary. Keep additive DTO fields. + const parsed = { ...row, provider, id, namespaced, disabled: row.disabled } as ModelRow; + const group = groups.get(provider) ?? new Map(); + const previous = group.get(namespaced); + if (previous && (previous.id !== id || previous.disabled !== parsed.disabled + || !!previous.native !== !!parsed.native || !!previous.custom !== !!parsed.custom + || previous.customId !== parsed.customId || !!previous.initialSelectionPending !== !!parsed.initialSelectionPending)) { + throw new Error("Conflicting model selector"); + } + if (!previous) { group.set(namespaced, parsed); rows.push(parsed); } + groups.set(provider, group); + } + return rows; +} + +export function countModelInventory(rows: readonly ModelRow[]): ProviderModelCounts { + const groups = new Map>(); + for (const row of rows) { + const group = groups.get(row.provider) ?? new Set(); + if (!row.disabled) group.add(row.namespaced); + groups.set(row.provider, group); + } + return Object.fromEntries([...groups].map(([provider, ids]) => [provider, ids.size])); +} + +/** Full selected-models response: available is not a visible-row projection. */ +export function parseModelSelection(value: unknown): { + available: ProviderAvailableModels; selected: ProviderSelectedModels; liveModelCounts: ProviderLiveModelCounts; +} { + const data = record(value); + const selected: ProviderSelectedModels = Object.assign(Object.create(null), parseSelectedModels(data)); + for (const [provider, ids] of Object.entries(selected)) { identity(provider); ids.forEach(identity); } + const available = Object.fromEntries(Object.entries(record(data.available)).map(([provider, ids]) => { + identity(provider); + if (!Array.isArray(ids)) throw new Error("Invalid available models"); + return [provider, ids.map(identity)]; + })); + // Older servers omit provenance. Missing means unknown, not an invalid action snapshot. + // A present malformed field is still rejected; DTO/custom ownership remain independent gates. + const liveCounts = Object.hasOwn(data, "liveModelCounts") ? record(data.liveModelCounts) : {}; + const liveModelCounts = Object.fromEntries(Object.entries(liveCounts).map(([provider, count]) => { + identity(provider); + if (typeof count !== "number" || !Number.isInteger(count) || count < 0) throw new Error("Invalid discovery count"); + return [provider, count]; + })); + return { available: Object.assign(Object.create(null), available), selected, liveModelCounts: Object.assign(Object.create(null), liveModelCounts) }; +} + +export interface CustomModelRecord { id: string; provider: string; modelId: string } + +function parseCustomRecord(value: unknown): CustomModelRecord { + const row = record(value); + return { id: identity(row.id), provider: identity(row.provider), modelId: identity(row.modelId) }; +} + +export function parseCustomModelInventory(value: unknown): CustomModelRecord[] { + if (!Array.isArray(value)) throw new Error("Invalid custom model inventory"); + const ids = new Map(); + const selectors = new Set(); + for (const raw of value) { + const row = parseCustomRecord(raw); + const key = JSON.stringify([row.provider, row.modelId]); + if (ids.has(row.id) || selectors.has(key)) throw new Error("Duplicate custom ownership"); + ids.set(row.id, row); + selectors.add(key); + } + return [...ids.values()]; +} + +export function parseCustomModelCreated(value: unknown, provider: string, modelId: string): CustomModelRecord { + const row = parseCustomRecord(value); + if (row.provider !== provider || row.modelId !== modelId) throw new Error("Unexpected created model"); + return row; +} + +/** Save confirmation and catalog convergence are separate outcomes. */ +export function catalogRefreshPending(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return true; + const status = (value as Record).catalogRefresh; + if (!status || typeof status !== "object" || Array.isArray(status)) return true; + const disposition = status as Record; + return disposition.status !== "committed" || typeof disposition.changed !== "boolean" || typeof disposition.degraded !== "boolean"; +} diff --git a/gui/tests/aside-profiles-page.test.tsx b/gui/tests/aside-profiles-page.test.tsx new file mode 100644 index 0000000000..22c47fd582 --- /dev/null +++ b/gui/tests/aside-profiles-page.test.tsx @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import type { AsideProfileList, AsideProfileState } from "../../src/integrations/aside-profiles"; +import type { IntegrationJournalRow } from "../src/pages/integrations/integration-api"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT", +] as const; +const apiBase = "http://aside-profiles-test.invalid"; +const profilesPath = "/api/client-integrations/aside/profiles"; +const syncPath = "/api/client-integrations/aside/sync"; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +type RequestRecord = { path: string; method: string; body: unknown }; +let requests: RequestRecord[]; +let profiles: AsideProfileState[]; +let journal: IntegrationJournalRow[]; +let listResponse: () => Response; +let mutationResponse: (request: RequestRecord) => Response; + +function profile(profileId: number, overrides: Partial = {}): AsideProfileState { + return { + clientId: "aside", profileId, current: false, enabled: true, + state: "current", installed: true, + configPath: `/tmp/aside-fixture/u/${profileId}/models.json`, + snapshotCount: 1, retentionDegraded: false, ...overrides, + }; +} + +function list(overrides: Partial = {}): AsideProfileList { + return { + clientId: "aside", state: "stale", installed: profiles.length > 0, + configPath: "/tmp/aside-fixture/u", snapshotCount: profiles.length, retentionDegraded: false, + profiles, total: profiles.length, enabledCount: profiles.filter(row => row.enabled).length, + appliedCount: profiles.filter(row => row.state === "current" || row.state === "stale").length, + allEnabled: profiles.length > 0 && profiles.every(row => row.enabled), ...overrides, + }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function success(profileId?: number) { + return { ok: true, clientId: "aside", changed: true, state: "current", message: "updated", results: [], ...(profileId === undefined ? {} : { profileId }) }; +} + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + clearClientResourceStoresForTests(); + testWindow = new Window({ url: "http://localhost/#integrations/aside" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true }, + }); + profiles = [ + profile(0, { name: "Work", current: true }), + profile(2, { name: "Personal", enabled: false, state: "absent" }), + profile(7, { state: "stale" }), + ]; + requests = []; + journal = []; + listResponse = () => json(list()); + mutationResponse = request => { + const scopedId = request.path.match(/\/profiles\/(\d+)/)?.[1]; + return json(success(scopedId === undefined ? undefined : Number(scopedId))); + }; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input instanceof Request ? input.url : input)); + const request = { + path: url.pathname + url.search, + method: (init?.method ?? "GET").toUpperCase(), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + requests.push(request); + if (request.method !== "GET") return mutationResponse(request); + if (url.pathname === profilesPath) return listResponse(); + const scoped = url.pathname.match(/^\/api\/client-integrations\/aside\/profiles\/(\d+)(\/journal)?$/); + if (scoped) { + const row = profiles.find(item => item.profileId === Number(scoped[1])); + if (scoped[2]) return json({ operations: journal.filter(item => item.configPath === row?.configPath) }); + if (row) return json(row); + } + return json({ error: `Unexpected request: ${request.path}` }, 404); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); + Object.defineProperty(testWindow, "fetch", { configurable: true, value: mockFetch }); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const mounted = root; + await act(async () => { mounted.unmount(); }); + root = null; + } + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function mount(active = true): Promise { + const [{ createRoot }, { LanguageProvider }, { default: AsideProfilesPage }] = await Promise.all([ + import("react-dom/client"), import("../src/i18n/provider"), + import("../src/pages/integrations/AsideProfilesPage"), + ]); + await act(async () => { + root = createRoot(container); + root.render(); + }); +} + +function findButton(name: string, scope: ParentNode = container): HTMLButtonElement | undefined { + return Array.from(scope.querySelectorAll("button")).find(button => + (button.getAttribute("aria-label") ?? button.textContent?.trim()) === name, + ); +} + +function button(name: string, scope: ParentNode = container): HTMLButtonElement { + const found = findButton(name, scope); + if (!found) throw new Error(`Button not found: ${name}`); + return found; +} + +// Poll observable state under act; never assume a fixed delay means the request settled. +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1500; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`State did not settle: ${container.textContent}`); + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + } +} + +async function click(name: string, scope: ParentNode = container): Promise { + await act(async () => { button(name, scope).click(); }); +} + +async function ready(): Promise { + await mount(); + await waitFor(() => Boolean(findButton("Sync Work"))); +} + +function listReads(): number { + return requests.filter(request => request.method === "GET" && request.path === profilesPath).length; +} + +function writes(): RequestRecord[] { + return requests.filter(request => request.method !== "GET"); +} + +test("three profiles show desired switches, actual badges, current marker and a mixed global switch", async () => { + await ready(); + expect(button("Sync all profiles").getAttribute("aria-pressed")).toBe("mixed"); + expect(button("Sync Work").getAttribute("aria-pressed")).toBe("true"); + expect(button("Sync Personal").getAttribute("aria-pressed")).toBe("false"); + expect(button("Sync Profile 7").getAttribute("aria-pressed")).toBe("true"); + expect(container.querySelectorAll("button[aria-pressed]").length).toBe(4); + for (const text of ["2 of 3 profiles applied", "Current profile", "Not applied", "Applied"]) { + expect(container.textContent).toContain(text); + } + expect(writes()).toEqual([]); +}); + +test("the mixed global switch enables all profiles through the bulk endpoint", async () => { + await ready(); + mutationResponse = () => { + profiles = profiles.map(row => ({ ...row, enabled: true, state: "current" })); + return json(success()); + }; + const before = listReads(); + await click("Sync all profiles"); + await waitFor(() => listReads() > before && button("Sync all profiles").getAttribute("aria-pressed") === "true"); + expect(writes()).toEqual([{ path: profilesPath, method: "PUT", body: { enabled: true } }]); + expect(container.textContent).toContain("3 of 3 profiles applied"); +}); + +test("an individual toggle uses its exact profile path and body and keeps siblings unchanged", async () => { + await ready(); + mutationResponse = () => { + profiles = profiles.map(row => row.profileId === 0 ? { ...row, enabled: false, state: "absent" } : row); + return json(success(0)); + }; + await click("Sync Work"); + await waitFor(() => button("Sync Work").getAttribute("aria-pressed") === "false"); + expect(writes()).toEqual([{ path: `${profilesPath}/0`, method: "PUT", body: { enabled: false } }]); + expect(button("Sync Personal").getAttribute("aria-pressed")).toBe("false"); + expect(button("Sync Profile 7").getAttribute("aria-pressed")).toBe("true"); +}); + +test("a partial bulk response refetches saved choices without claiming failed profiles applied", async () => { + await ready(); + mutationResponse = () => { + profiles = profiles.map(row => row.profileId === 2 + ? { ...row, enabled: true, state: "conflict", reason: "foreign-edit" } + : { ...row, state: "current" }); + return json({ ...success(), ok: false, state: "conflict", results: [ + { ...success(), profileId: 0 }, + { ok: false, profileId: 2, clientId: "aside", reason: "conflict", state: "conflict", message: "Profile changed" }, + { ...success(), profileId: 7 }, + ] }, 207); + }; + const before = listReads(); + await click("Sync all profiles"); + await waitFor(() => listReads() > before && container.textContent?.includes("Conflict") === true); + expect(button("Sync Personal").getAttribute("aria-pressed")).toBe("true"); + expect(container.textContent).toContain("2 of 3 profiles applied"); + expect(container.textContent).toContain("Some profiles need attention"); +}); + +test("a refused individual update refetches saved off intent while showing the actual applied state", async () => { + await ready(); + mutationResponse = () => { + profiles = profiles.map(row => row.profileId === 0 ? { ...row, enabled: false } : row); + return json({ error: "Write failed", code: "integration_mutation_failed", clientId: "aside", + profileId: 0, state: "current", reason: "write_failed", message: "Write failed" }, 500); + }; + const before = listReads(); + await click("Sync Work"); + await waitFor(() => listReads() > before && button("Sync Work").getAttribute("aria-pressed") === "false"); + expect(container.textContent).toContain("2 of 3 profiles applied"); + expect(container.textContent).toContain("Sync choice saved; file update pending."); + expect(writes()).toEqual([{ path: `${profilesPath}/0`, method: "PUT", body: { enabled: false } }]); +}); + +test("Sync now uses server-selected synchronization and preserves a profile that is off", async () => { + await ready(); + mutationResponse = () => json({ ok: true, clientId: "aside", results: [] }); + const before = listReads(); + await click("Sync now"); + await waitFor(() => listReads() > before && !button("Sync now").disabled); + expect(writes()).toEqual([{ path: syncPath, method: "POST", body: {} }]); + expect(button("Sync Personal").getAttribute("aria-pressed")).toBe("false"); + expect(button("Sync all profiles").getAttribute("aria-pressed")).toBe("mixed"); +}); + +test("a successful empty list invites profile creation", async () => { + profiles = []; + await mount(); + await waitFor(() => container.textContent?.includes("Open Aside and create a profile to connect it.") === true); + expect(container.textContent).not.toContain("Could not load Aside profiles"); +}); + +for (const transportFailure of [false, true]) { + test(`${transportFailure ? "HTTP failure" : "error DTO with an empty list"} shows an error instead of the empty invitation`, async () => { + profiles = []; + listResponse = () => json(list({ state: "unsafe", installed: false, + error: "Cannot discover profiles", retentionDegraded: true }), transportFailure ? 500 : 200); + await mount(); + await waitFor(() => container.textContent?.includes("Could not load Aside profiles") === true); + expect(container.textContent).not.toContain("Open Aside and create a profile to connect it."); + expect(writes()).toEqual([]); + }); +} + +test("an inactive page does not fetch profiles or nested details", async () => { + await mount(false); + expect(requests).toEqual([]); +}); + +test("details keep state, journal and restore scoped to the selected profile, then return to the list", async () => { + journal = [{ opId: "aside-profile-2-op", clientId: "aside", profileId: 2, kind: "disable", + at: "2026-09-06T00:00:00Z", configPath: profiles[1]!.configPath, + snapshot: "stored", undoable: true, deletable: false }]; + await ready(); + await click("Manage Personal"); + await waitFor(() => Boolean(findButton("Undo"))); + expect(requests.some(request => request.path === `${profilesPath}/2` && request.method === "GET")).toBe(true); + expect(requests.some(request => request.path === `${profilesPath}/2/journal` && request.method === "GET")).toBe(true); + expect(container.textContent).toContain("/tmp/aside-fixture/u/2/models.json"); + expect(container.textContent).not.toContain("/tmp/aside-fixture/u/0/models.json"); + await click("Undo"); + await waitFor(() => Boolean(container.querySelector("dialog[open]"))); + await click("Restore", container.querySelector("dialog[open]")!); + await waitFor(() => !container.querySelector("dialog[open]")); + expect(writes()).toEqual([{ path: `${profilesPath}/2/restore`, method: "POST", + body: { opId: "aside-profile-2-op", confirmDrift: false } }]); + await click("All Aside profiles"); + await waitFor(() => Boolean(findButton("Sync Work"))); + expect(button("Sync Personal").getAttribute("aria-pressed")).toBe("false"); + expect(requests.some(request => request.path === "/api/client-integrations/aside" + || request.path.startsWith("/api/client-integrations/journal") + || request.path === "/api/client-integrations/restore")).toBe(false); +}); + +for (const withSnapshot of [true, false]) { + test(`bulk 207 keeps a profile's refusal and residual warning ${withSnapshot ? "with" : "without"} a snapshot`, async () => { + await ready(); + const snapshotPath = "/tmp/aside-fixture/recovery/profile-2-before.json"; + mutationResponse = () => { + profiles = profiles.map(row => ({ ...row, enabled: true })); + return json({ ...success(), ok: false, results: [ + success(0), + { ok: false, clientId: "aside", profileId: 2, state: "absent", reason: "write_failed", + message: "Personal file could not be replaced", residual: true, + ...(withSnapshot ? { snapshotPath } : {}) }, + success(7), + ] }, 207); + }; + const before = listReads(); + await click("Sync all profiles"); + await waitFor(() => listReads() > before && !button("Sync all profiles").disabled); + const failedRow = button("Manage Personal").closest(".aside-profile-row")!; + expect(failedRow.textContent).toContain("Personal file could not be replaced"); + if (withSnapshot) { + expect(failedRow.textContent).toContain("The file may be in an intermediate state:"); + expect(failedRow.textContent).toContain(`Restore it from ${snapshotPath}.`); + } else { + expect(failedRow.textContent).toContain("Automatic recovery did not finish. Check the client configuration before retrying."); + expect(failedRow.textContent).not.toContain("Restore it from"); + } + expect(button("Manage Work").closest(".aside-profile-row")!.textContent).not.toContain("Personal file could not be replaced"); + expect(button("Sync Personal").getAttribute("aria-pressed")).toBe("true"); + expect(container.textContent).toContain("2 of 3 profiles applied"); + expect(writes()).toEqual([{ path: profilesPath, method: "PUT", body: { enabled: true } }]); + }); +} + +test("Sync now preserves distinct refusal reasons on the affected profiles after refetch", async () => { + await ready(); + mutationResponse = () => json({ ok: false, clientId: "aside", results: [ + { client: "aside", profileId: 0, ok: false, reason: "Work file changed outside opencodex" }, + { client: "aside", profileId: 7, ok: false, reason: "Profile 7 file cannot be read" }, + ] }, 207); + const before = listReads(); + await click("Sync now"); + await waitFor(() => listReads() > before && !button("Sync now").disabled); + const work = button("Manage Work").closest(".aside-profile-row")!; + const unnamed = button("Manage Profile 7").closest(".aside-profile-row")!; + const personal = button("Manage Personal").closest(".aside-profile-row")!; + expect(work.textContent).toContain("Work file changed outside opencodex"); + expect(work.textContent).not.toContain("Profile 7 file cannot be read"); + expect(unnamed.textContent).toContain("Profile 7 file cannot be read"); + expect(unnamed.textContent).not.toContain("Work file changed outside opencodex"); + expect(personal.textContent).not.toContain("Work file changed outside opencodex"); + expect(personal.textContent).not.toContain("Profile 7 file cannot be read"); + expect(button("Sync Personal").getAttribute("aria-pressed")).toBe("false"); + expect(writes()).toEqual([{ path: syncPath, method: "POST", body: {} }]); +}); + +test("a failed Aside restore keeps the error dialog open but refetches persisted desired state", async () => { + journal = [{ opId: "aside-profile-2-failed-restore", clientId: "aside", profileId: 2, kind: "disable", + at: "2026-09-06T00:00:00Z", configPath: profiles[1]!.configPath, + snapshot: "stored", undoable: true, deletable: false }]; + await ready(); + await click("Manage Personal"); + await waitFor(() => Boolean(findButton("Undo"))); + expect(button("Apply").getAttribute("aria-pressed")).toBe("false"); + const stateReads = () => requests.filter(request => request.method === "GET" && request.path === `${profilesPath}/2`).length; + const before = stateReads(); + mutationResponse = () => { + // The server persists intent before restoring bytes; the byte write can still fail. + profiles = profiles.map(row => row.profileId === 2 ? { ...row, enabled: true } : row); + return json({ error: "Restore write failed", code: "integration_mutation_failed", clientId: "aside", + profileId: 2, state: "absent", reason: "write_failed", message: "Personal restore could not finish", + residual: true }, 500); + }; + await click("Undo"); + await waitFor(() => Boolean(container.querySelector("dialog[open]"))); + await click("Restore", container.querySelector("dialog[open]")!); + await waitFor(() => container.querySelector("dialog[open]")?.textContent?.includes("Personal restore could not finish") === true); + await waitFor(() => stateReads() > before && findButton("Disable")?.getAttribute("aria-pressed") === "true"); + const dialog = container.querySelector("dialog[open]")!; + expect(dialog.textContent).toContain("Automatic recovery did not finish. Check the client configuration before retrying."); + expect(button("Restore", dialog).disabled).toBe(false); + expect(writes()).toEqual([{ path: `${profilesPath}/2/restore`, method: "POST", + body: { opId: "aside-profile-2-failed-restore", confirmDrift: false } }]); + await click("Cancel", dialog); + await waitFor(() => !container.querySelector("dialog[open]")); + expect(button("Disable").getAttribute("aria-pressed")).toBe("true"); + expect(container.textContent).toContain("Not applied"); + await click("All Aside profiles"); + await waitFor(() => findButton("Sync Personal")?.getAttribute("aria-pressed") === "true"); +}); diff --git a/gui/tests/log-poll.test.ts b/gui/tests/log-poll.test.ts new file mode 100644 index 0000000000..d7c2559a1f --- /dev/null +++ b/gui/tests/log-poll.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { mergeLogDelta, parseLogPollResponse } from "../src/pages/log-poll"; + +describe("log polling protocol", () => { + test("legacy arrays and envelopes replace snapshots without a cursor", () => { + const rows = [{ requestId: "a" }]; + expect(parseLogPollResponse(rows)).toEqual({ rows, cursor: null, reset: false }); + expect(parseLogPollResponse({ logs: rows, generatedAt: 123, timeZone: "UTC", total: 5 })) + .toEqual({ rows, cursor: null, reset: false, generatedAt: 123, timeZone: "UTC", total: 5 }); + expect(parseLogPollResponse({ logs: rows, generatedAt: "bad" }).generatedAt).toBe("bad"); + }); + + test("empty deltas and resets retain clock and window metadata", () => { + for (const reset of [false, true]) { + expect(parseLogPollResponse({ logs: [], cursor: "opaque-cursor", reset, generatedAt: 456, total: 2, timeZone: "UTC" })) + .toEqual({ rows: [], cursor: "opaque-cursor", reset, generatedAt: 456, total: 2, timeZone: "UTC" }); + } + }); + + test("invalid cursor envelopes fail instead of clearing accepted rows", () => { + for (const body of [null, "bad", { logs: {} }, { logs: [], cursor: null, reset: false }, + { logs: [], cursor: "", reset: false }, { logs: [], cursor: "c", reset: "false" }, + { logs: [], cursor: "c" }, { logs: [], reset: false }, { cursor: "c", reset: false }, + { logs: [], cursor: "a".repeat(513), reset: false }, { logs: [], cursor: " c ", reset: false }]) { + expect(() => parseLogPollResponse(body)).toThrow("Invalid log response"); + } + }); + + test("append preserves order and repeated IDs without mutating inputs; cap keeps newest rows", () => { + const previous = [{ requestId: "same", value: 1 }, { requestId: "other", value: 2 }]; + const incoming = [{ requestId: "same", value: 3 }]; + expect(mergeLogDelta(previous, incoming)).toEqual([...previous, ...incoming]); + expect(mergeLogDelta(previous, incoming, 2)).toEqual([previous[1], incoming[0]]); + expect(mergeLogDelta(previous, [])).toBe(previous); + expect(mergeLogDelta(previous, [], 1)).toEqual([previous[1]]); + expect(previous).toEqual([{ requestId: "same", value: 1 }, { requestId: "other", value: 2 }]); + expect(incoming).toEqual([{ requestId: "same", value: 3 }]); + }); +}); diff --git a/gui/tests/logs-clock.test.ts b/gui/tests/logs-clock.test.ts new file mode 100644 index 0000000000..e26bca8612 --- /dev/null +++ b/gui/tests/logs-clock.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { logsClockAnchor, logsClockNow } from "../src/pages/logs-clock"; + +test.each([undefined, null, "1700000000000", -1, NaN, Infinity, -Infinity])( + "invalid generatedAt %s leaves the browser fallback in effect", generatedAt => { + const anchor = logsClockAnchor(generatedAt, 10); + expect(anchor).toBeUndefined(); + expect(logsClockNow(anchor, 100, 42_000)).toBe(42_000); + }, +); + +test("zero is a valid proxy epoch and elapsed time is monotonic", () => { + const anchor = logsClockAnchor(0, 100); + expect(anchor).toEqual({ generatedAt: 0, receivedAt: 100 }); + expect(logsClockNow(anchor, 150, 80_000)).toBe(50); + expect(logsClockNow(anchor, 160, 1)).toBe(60); +}); + +test("proxy-relative time ignores browser wall-clock skew and subsequent jumps", () => { + const anchor = logsClockAnchor(1_800_000_000_000, 500); + expect(logsClockNow(anchor, 30_500, 1_800_021_600_000)).toBe(1_800_000_030_000); + expect(logsClockNow(anchor, 30_500, 1_799_978_400_000)).toBe(1_800_000_030_000); +}); diff --git a/gui/tests/logs-filter-bar.test.ts b/gui/tests/logs-filter-bar.test.ts new file mode 100644 index 0000000000..06ae773e8e --- /dev/null +++ b/gui/tests/logs-filter-bar.test.ts @@ -0,0 +1,185 @@ +import { expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement, useEffect, useState } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { TFn } from "../src/i18n/shared"; +import { DEFAULT_LOG_FILTER_STATE, hasActiveLogFilters, type LogFilterState } from "../src/pages/logs-filter"; +import { LogsFilterBar } from "../src/pages/logs-filter-bar"; +import { logsSurfaceKeyDown } from "../src/pages/logs-surface-keydown"; + +test("surface radios support wrapping arrows and Home/End with roving focus", () => { + const previousDocument = globalThis.document; + const win = new Window(); + Object.defineProperty(globalThis, "document", { configurable: true, value: win.document }); + try { + for (const surface of ["all", "claude", "codex", "grok"]) { + const button = win.document.createElement("button"); + button.id = `logs-surface-${surface}`; + win.document.body.append(button); + } + const selected: string[] = []; + const key = (value: string) => ({ key: value, preventDefault() {}, } as never); + logsSurfaceKeyDown(key("ArrowRight"), "grok", surface => selected.push(surface)); + expect(selected).toEqual(["all"]); + expect(win.document.activeElement?.id).toBe("logs-surface-all"); + logsSurfaceKeyDown(key("Home"), "codex", surface => selected.push(surface)); + expect(selected).toEqual(["all", "all"]); + expect(win.document.activeElement?.id).toBe("logs-surface-all"); + logsSurfaceKeyDown(key("Enter"), "all", surface => selected.push(surface)); + expect(selected).toHaveLength(2); + } finally { + Object.defineProperty(globalThis, "document", { configurable: true, value: previousDocument }); + win.close(); + } +}); + +const translate: TFn = (key, vars) => key === "logs.filter.showingCount" + ? `Showing ${vars?.count} of ${vars?.total}` : key; + +async function withFilterBar( + initial: LogFilterState, + exercise: (ui: { container: HTMLElement; win: Window; filters: () => LogFilterState }) => Promise, +): Promise { + const win = new Window({ url: "http://localhost/#logs" }); + const values = { + document: win.document, window: win, navigator: win.navigator, + localStorage: win.localStorage, IS_REACT_ACT_ENVIRONMENT: true, + }; + const previous = Object.fromEntries(Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + let root: Root | undefined; + let current = initial; + try { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + const container = document.createElement("div"); + document.body.append(container); + function Harness() { + const [filters, setFilters] = useState(initial); + useEffect(() => { current = filters; }, [filters]); + return createElement(LogsFilterBar, { + filters, options: { models: ["model-a", "model-a-plus"], providers: ["openai", "xai"] }, + hasActiveFilters: hasActiveLogFilters(filters), filteredCount: 1, totalCount: 2, + t: translate, onFilterChange: setFilters, + onResetFilters: () => setFilters({ ...DEFAULT_LOG_FILTER_STATE }), + }); + } + const { createRoot } = await import("react-dom/client"); + root = createRoot(container); + await act(async () => { root!.render(createElement(LanguageProvider, null, createElement(Harness))); }); + await exercise({ container, win, filters: () => current }); + } finally { + try { + if (root) await act(async () => { root!.unmount(); }); + } finally { + win.close(); + for (const [key, descriptor] of Object.entries(previous)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } + } +} + +test("LogsFilterBar changes labeled selects without dropping other filters", async () => { + await withFilterBar({ ...DEFAULT_LOG_FILTER_STATE, conversationId: "conversation-a" }, async ui => { + for (const [field, value] of [["provider", "xai"], ["model", "model-a"], ["time", "15m"], ["status", "errors"]] as const) { + const select = ui.container.querySelector(`select[aria-label="logs.filter.${field}.label"]`); + expect(select).not.toBeNull(); + await act(async () => { + select!.value = value; + select!.dispatchEvent(new ui.win.Event("change", { bubbles: true })); + }); + expect(select!.value).toBe(value); + } + const intercepted = ui.container.querySelector('input[type="checkbox"]')!; + await act(async () => { intercepted.click(); }); + expect(ui.filters()).toMatchObject({ + provider: "xai", model: "model-a", timeWindow: "15m", status: "errors", + conversationId: "conversation-a", interceptedOnly: true, + }); + expect(ui.container.querySelector('input[aria-label="logs.filter.conversation.label"]')).not.toBeNull(); + }); +}); + +test("LogsFilterBar speed choices have non-overlapping bounds and clear both bounds on All", async () => { + await withFilterBar({ ...DEFAULT_LOG_FILTER_STATE, provider: "openai" }, async ui => { + const select = ui.container.querySelector('select[aria-label="logs.filter.speed.label"]')!; + for (const [value, min, max] of [ + ["slow", undefined, 15], ["medium", 15, 50], ["fast", 50, undefined], ["all", undefined, undefined], + ] as const) { + await act(async () => { + select.value = value; + select.dispatchEvent(new ui.win.Event("change", { bubbles: true })); + }); + expect(select.value).toBe(value); + expect(ui.filters().minTokPerSec).toBe(min); + expect(ui.filters().maxTokPerSec).toBe(max); + expect(ui.filters().provider).toBe("openai"); + } + }); +}); + +test.each(["pointer", "keyboard"] as const)("LogsFilterBar %s reset restores focus to All and clears every field", async activation => { + await withFilterBar({ + ...DEFAULT_LOG_FILTER_STATE, surface: "grok", status: "errors", provider: "xai", + model: "model-a", timeWindow: "1h", minTokPerSec: 50, interceptedOnly: true, + conversationId: "conversation-a", conversationQueryHash: "cached-hash", + }, async ui => { + expect(ui.container.textContent).toContain("Showing 1 of 2"); + const reset = ui.container.querySelector(".logs-filter-status button")!; + expect(reset).not.toBeNull(); + reset.focus(); + expect(document.activeElement).toBe(reset); + const all = ui.container.querySelector("#logs-surface-all")!; + const focus = jest.spyOn(all, "focus"); + try { + // Native buttons dispatch click with detail=0 for keyboard activation. + // Browser QA separately exercises Enter/Space's native event synthesis. + await act(async () => { + reset.dispatchEvent(new ui.win.MouseEvent("click", { + bubbles: true, cancelable: true, detail: activation === "keyboard" ? 0 : 1, + })); + }); + expect(document.activeElement).toBe(all); + expect(all.getAttribute("aria-checked")).toBe("true"); + expect(all.tabIndex).toBe(0); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + } finally { + focus.mockRestore(); + } + expect(ui.filters()).toEqual(DEFAULT_LOG_FILTER_STATE); + expect(ui.container.querySelector(".logs-filter-status")).toBeNull(); + expect(ui.container.querySelector('input[type="search"]')!.value).toBe(""); + expect(ui.container.querySelector('input[type="checkbox"]')!.checked).toBe(false); + expect(ui.container.querySelector('select[aria-label="logs.filter.speed.label"]')!.value).toBe("all"); + }); +}); + +test("LogsFilterBar rendered radios move selection, focus and the single tab stop together", async () => { + await withFilterBar({ ...DEFAULT_LOG_FILTER_STATE }, async ui => { + const radio = (surface: string) => ui.container.querySelector(`#logs-surface-${surface}`)!; + radio("all").focus(); + const moves = [ + ["ArrowLeft", "grok"], ["ArrowRight", "all"], ["ArrowDown", "claude"], + ["ArrowUp", "all"], ["End", "grok"], ["Home", "all"], + ] as const; + for (const [key, expected] of moves) { + const event = new ui.win.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + await act(async () => { document.activeElement!.dispatchEvent(event); }); + expect(event.defaultPrevented).toBe(true); + expect(ui.filters().surface).toBe(expected); + expect(document.activeElement).toBe(radio(expected)); + expect(radio(expected).getAttribute("aria-checked")).toBe("true"); + const radios = [...ui.container.querySelectorAll('[role="radio"]')]; + expect(radios.filter(button => button.tabIndex === 0)).toEqual([radio(expected)]); + expect(radios.filter(button => button.getAttribute("aria-checked") === "true")).toEqual([radio(expected)]); + } + const unrelated = new ui.win.KeyboardEvent("keydown", { key: "q", bubbles: true, cancelable: true }); + await act(async () => { radio("all").dispatchEvent(unrelated); }); + expect(unrelated.defaultPrevented).toBe(false); + expect(ui.filters().surface).toBe("all"); + expect(document.activeElement).toBe(radio("all")); + }); +}); diff --git a/gui/tests/model-picker-order.test.ts b/gui/tests/model-picker-order.test.ts new file mode 100644 index 0000000000..29c72c0b16 --- /dev/null +++ b/gui/tests/model-picker-order.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { summarizeUsage } from "../../src/usage/summary"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { isModelPickerUsage, isPickerOrderSaved, isPickerOrderSettings, modelPickerOrder, modelPickerOrderMode } from "../src/model-picker-order"; + +const models = ["zeta/beta", "alpha/zeta", "alpha/alpha"]; + +test("presets save deterministic model/provider ordering and Default clears", () => { + expect(modelPickerOrder("alphabetical", models)).toEqual(["alpha/alpha", "zeta/beta", "alpha/zeta"]); + expect(modelPickerOrder("provider", [...models, models[0]!])).toEqual(["alpha/alpha", "alpha/zeta", "zeta/beta"]); + expect(modelPickerOrder("default", models)).toBeNull(); + expect(models).toEqual(["zeta/beta", "alpha/zeta", "alpha/alpha"]); +}); +test("Most used counts only requested identities, ignoring representative resolved targets", () => { + expect(modelPickerOrder("most-used", models, [ + { provider: "alpha", model: "zeta", resolvedModel: "alpha", requests: 4 }, + { provider: "alpha", model: "alpha/zeta", requests: 4 }, + { provider: "zeta", model: "missing", resolvedModel: "beta", requests: 3 }, + ])).toEqual(["alpha/zeta", "alpha/alpha", "zeta/beta"]); + expect(modelPickerOrder("most-used", models, [])).toEqual(["alpha/alpha", "alpha/zeta", "zeta/beta"]); +}); +test("raw slash-bearing ids resolve through observed canonical identities, never guessed namespaces", () => { + const available = ["vendor/team-model", "vendor/other", "team/model"]; + expect(modelPickerOrder("most-used", available, [{ provider: "vendor", model: "team/model", requests: 9 }], + [{ provider: "vendor", id: "team/model", namespaced: "vendor/team-model" }])) + .toEqual(["vendor/team-model", "team/model", "vendor/other"]); + expect(modelPickerOrder("most-used", available, [{ provider: "vendor", model: "team/model", requests: 9 }])) + .toEqual(["team/model", "vendor/other", "vendor/team-model"]); +}); +test("ambiguous raw identity does not choose a catalog row", () => { + expect(modelPickerOrder("most-used", ["p/a", "p/b"], [{ provider: "p", model: "upstream", resolvedModel: "b", requests: 9 }], [ + { provider: "p", id: "upstream", namespaced: "p/a" }, { provider: "p", id: "upstream", namespaced: "p/b" }, + ])).toEqual(["p/a", "p/b"]); +}); +test("saved mode is snapshot provenance across roster drift; full native orders remain Custom", () => { + expect(modelPickerOrderMode(models, [])).toBe("default"); + expect(modelPickerOrderMode(models, ["alpha/alpha", "alpha/zeta", "zeta/beta"])).toBe("provider"); + expect(modelPickerOrderMode([...models, "new/model"], ["gone/model", "alpha/zeta"], "most-used")).toBe("most-used"); + expect(modelPickerOrderMode(models, ["gpt-5.5", "alpha/zeta"], "most-used")).toBe("custom"); + expect(modelPickerOrderMode(models, ["alpha/zeta"])).toBe("custom"); +}); +test("transport guards reject missing/malformed state instead of synthesizing a successful reset", () => { + expect(isPickerOrderSettings({ pickerAvailable: [], pickerOrder: [], pickerOrderMode: null })).toBe(true); + for (const value of [undefined, null, {}, { pickerOrder: [] }, { pickerOrder: [], pickerOrderMode: "default" }]) { + expect(isPickerOrderSaved(value)).toBe(false); + } + expect(isModelPickerUsage([])).toBe(true); + expect(isModelPickerUsage([{ provider: "p", model: "a", requests: -1 }])).toBe(false); + expect(isModelPickerUsage([{ provider: "p", model: "a", requests: Infinity }])).toBe(false); +}); + + +test("encoded collisions cannot attribute usage to an unproven winner", () => { + expect(modelPickerOrder("most-used", ["p/a", "p/team-model"], + [{ provider: "p", model: "team/model", requests: 100 }], [ + { provider: "p", id: "team/model", namespaced: "p/team-model" }, + { provider: "p", id: "team-model", namespaced: "p/team-model" }, + ])).toEqual(["p/a", "p/team-model"]); +}); + + +test("real mixed-resolved usage summary never credits an entire legacy bucket to its representative", () => { + const now = Date.UTC(2026, 8, 7, 12); + const entries: PersistedUsageEntry[] = Array.from({ length: 15 }, (_, index) => ({ + requestId: `picker-mixed-${index}`, timestamp: now - 15 + index, + provider: "p", model: index < 10 ? "legacy" : "a", + resolvedModel: index === 0 ? "b" : index < 10 ? "c" : "a", + status: 200, durationMs: 10, usageStatus: "unreported", + })); + const summary = summarizeUsage(entries, "all", now); + const legacy = summary.models.find(row => row.model === "legacy")!; + expect(legacy.requests).toBe(10); + expect(legacy.resolvedModel).toBe("b"); + expect(summary.models.find(row => row.model === "a")?.requests).toBe(5); + // Only a's five requested-identity calls are attributable to current candidates. + // b/c remain tied at zero; the first representative b does not inherit ten calls. + expect(modelPickerOrder("most-used", ["p/c", "p/b", "p/a"], summary.models)) + .toEqual(["p/a", "p/b", "p/c"]); +}); diff --git a/gui/tests/provider-model-inventory.test.ts b/gui/tests/provider-model-inventory.test.ts new file mode 100644 index 0000000000..a522d276ac --- /dev/null +++ b/gui/tests/provider-model-inventory.test.ts @@ -0,0 +1,169 @@ +import { expect, test } from "bun:test"; +import { + parseModelInventory, + countModelInventory, + parseModelSelection, + parseCustomModelInventory, + parseCustomModelCreated, + catalogRefreshPending, +} from "../src/provider-workspace/model-inventory"; +import type { ModelRow } from "../src/pages/models-shared"; + +const row = (overrides: Partial = {}): ModelRow => ({ + provider: "vendor", id: "model", namespaced: "vendor/model", disabled: false, ...overrides, +}); + +test("empty is authoritative; malformed envelopes cannot masquerade as an empty inventory", () => { + expect(parseModelInventory([])).toEqual([]); + for (const value of [null, undefined, {}, { models: [] }, "[]", false]) { + expect(() => parseModelInventory(value)).toThrow(); + } +}); + +for (const field of ["provider", "id", "namespaced"] as const) { + for (const value of [undefined, null, "", " ", 1, [], {}]) { + test(`inventory rejects invalid ${field}: ${JSON.stringify(value)}`, () => { + expect(() => parseModelInventory([row(), { ...row(), [field]: value }])).toThrow(); + }); + } +} + +for (const field of ["disabled", "native", "custom", "initialSelectionPending"] as const) { + for (const value of [null, "false", 0, [], {}]) { + test(`inventory rejects invalid action flag ${field}: ${JSON.stringify(value)}`, () => { + expect(() => parseModelInventory([{ ...row(), [field]: value }])).toThrow(); + }); + } +} + +test("disabled is required; optional action flags may be absent", () => { + const { disabled: _disabled, ...missing } = row(); + expect(() => parseModelInventory([missing])).toThrow(); + expect(parseModelInventory([row()])).toEqual([row()]); +}); + +for (const customId of [undefined, null, "", " ", 3]) { + test(`custom rows require a stable id: ${JSON.stringify(customId)}`, () => { + expect(() => parseModelInventory([{ ...row(), custom: true, customId }])).toThrow(); + }); +} + +test("raw identities and native flags are preserved, never inferred from provider or spelling", () => { + const inputs = [ + row({ provider: "openai", id: "gpt-5.5", namespaced: "gpt-5.5", native: true }), + row({ provider: "openai", id: "gpt-5.5", namespaced: "openai/gpt-5.5", native: false, custom: true, customId: "c1" }), + row({ provider: "openai", id: "account-work/gpt-5.5", namespaced: "account-work/gpt-5.5", native: true }), + row({ id: "vendor/model", namespaced: "vendor/vendor-model" }), + row({ id: " spaced-id ", namespaced: "vendor/ spaced-id " }), + ]; + expect(parseModelInventory(inputs)).toEqual(inputs); +}); + +test("identical identity duplicates collapse without merging separate namespaced rows", () => { + const native = row({ provider: "openai", namespaced: "model", native: true }); + const routed = row({ provider: "openai", namespaced: "openai/model", custom: true, customId: "c1" }); + expect(parseModelInventory([native, { ...native }, routed])).toEqual([native, routed]); + expect(countModelInventory(parseModelInventory([native, { ...native }, routed]))).toEqual({ openai: 2 }); +}); + +for (const conflict of [ + { id: "other" }, { disabled: true }, { native: true }, { initialSelectionPending: true }, + { custom: true, customId: "c1" }, +]) { + test(`same provider/selector with conflicting action identity fails closed: ${JSON.stringify(conflict)}`, () => { + expect(() => parseModelInventory([row(), row(conflict)])).toThrow(); + }); +} + +test("stable custom ids cannot silently change between otherwise duplicate DTOs", () => { + expect(() => parseModelInventory([ + row({ custom: true, customId: "old" }), row({ custom: true, customId: "replacement" }), + ])).toThrow(); +}); + +test("the same namespaced key in distinct provider groups remains distinct", () => { + const rows = [row(), row({ provider: "another" })]; + expect(parseModelInventory(rows)).toEqual(rows); + expect(countModelInventory(rows)).toEqual({ vendor: 1, another: 1 }); +}); + +test("counts use unique non-disabled inventory before selection, query or the 300-chip cap", () => { + const rows = Array.from({ length: 305 }, (_, index) => row({ id: `model-${index}`, namespaced: `vendor/model-${index}` })); + rows.push({ ...rows[0]! }, row({ id: "hidden", namespaced: "vendor/hidden", disabled: true }), + row({ provider: "hidden-only", disabled: true }), row({ provider: "other", initialSelectionPending: true, disabled: true })); + expect(countModelInventory(rows)).toEqual({ vendor: 305, "hidden-only": 0, other: 0 }); + expect(countModelInventory([])).toEqual({}); +}); + +test("prototype-like provider names are own data keys rather than inherited counters", () => { + const rows = ["__proto__", "constructor", "toString"].map(provider => row({ provider })); + const counts = countModelInventory(rows); + for (const provider of ["__proto__", "constructor", "toString"]) { + expect(Object.hasOwn(counts, provider)).toBe(true); expect(counts[provider]).toBe(1); + } +}); + +test("selection retains full available and live provenance independently", () => { + const value = { selected: { vendor: ["chosen"] }, available: { vendor: ["chosen", "hidden", "not-chosen"] }, liveModelCounts: { vendor: 2 } }; + expect(parseModelSelection(value)).toEqual(value); + expect(parseModelSelection({ selected: {}, available: {}, liveModelCounts: {} })).toEqual({ selected: {}, available: {}, liveModelCounts: {} }); +}); + +for (const value of [ + null, {}, { selected: [], available: {}, liveModelCounts: {} }, + { selected: {}, available: { vendor: "model" }, liveModelCounts: {} }, + { selected: { vendor: [null] }, available: {}, liveModelCounts: {} }, + { selected: {}, available: {}, liveModelCounts: { vendor: -1 } }, + { selected: {}, available: {}, liveModelCounts: { vendor: "2" } }, +]) { + test(`malformed paired selection fails the observation: ${JSON.stringify(value)}`, () => { + expect(() => parseModelSelection(value)).toThrow(); + }); +} + +test("custom ownership validates the full list, including foreign provider rows", () => { + const record = { id: "stable", provider: "vendor", modelId: "model" }; + expect(parseCustomModelInventory([record])).toEqual([record]); + for (const value of [null, {}, [{ provider: "vendor", modelId: "model" }], [record, { id: "foreign", provider: "other", modelId: 3 }]]) { + expect(() => parseCustomModelInventory(value)).toThrow(); + } + expect(() => parseCustomModelInventory([record, { ...record, provider: "other" }])).toThrow(); + expect(() => parseCustomModelInventory([record, { ...record, modelId: "replacement" }])).toThrow(); +}); + +test("POST adoption requires exact provider, raw model and nonblank stable id", () => { + const record = { id: "new-id", provider: "vendor", modelId: "vendor/model" }; + expect(parseCustomModelCreated(record, "vendor", "vendor/model")).toEqual(record); + for (const value of [null, {}, { ...record, id: " " }, { ...record, provider: "other" }, { ...record, modelId: "vendor-model" }]) { + expect(() => parseCustomModelCreated(value, "vendor", "vendor/model")).toThrow(); + } +}); + +test("refresh outcome cannot imply success from an absent or malformed disposition", () => { + expect(catalogRefreshPending({ catalogRefresh: { status: "committed", changed: true, degraded: false, notices: [] } })).toBe(false); + for (const value of [{}, { catalogRefresh: null }, { catalogRefresh: { status: "failed" } }, { catalogRefresh: { status: "unknown" } }]) { + expect(catalogRefreshPending(value)).toBe(true); + } +}); + +test("custom/native flags and used metadata cannot authorize a malformed DTO", () => { + for (const extra of [ + { custom: true, customId: "c1", native: true }, { customId: "orphan-id" }, + { inputModalities: "text" }, { reasoningEfforts: [null] }, { displayName: [] }, + { contextWindow: "128000" }, { contextCap: Infinity }, { contextCapped: "false" }, + ]) expect(() => parseModelInventory([{ ...row(), ...extra }])).toThrow(); +}); + +test("absent liveModelCounts preserves unknown provenance without losing full selection or inventory", () => { + const parsed = parseModelSelection({ selected: { vendor: ["chosen"] }, available: { vendor: ["chosen", "other"] } }); + expect(parsed).toEqual({ selected: { vendor: ["chosen"] }, available: { vendor: ["chosen", "other"] }, liveModelCounts: {} }); + expect(Object.hasOwn(parsed.liveModelCounts, "vendor")).toBe(false); + expect(parseModelSelection({ selected: {}, available: {}, liveModelCounts: { vendor: 0 } }).liveModelCounts) + .toEqual({ vendor: 0 }); +}); + +for (const liveModelCounts of [null, [], "", 0, false, { vendor: 1.5 }, { vendor: NaN }, { vendor: Infinity }]) { + test(`present malformed liveModelCounts must not become unknown (${JSON.stringify(liveModelCounts)})`, () => { + expect(() => parseModelSelection({ selected: {}, available: {}, liveModelCounts })).toThrow(); + }); +} diff --git a/gui/tests/provider-model-management.test.tsx b/gui/tests/provider-model-management.test.tsx new file mode 100644 index 0000000000..01dc606859 --- /dev/null +++ b/gui/tests/provider-model-management.test.tsx @@ -0,0 +1,407 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useEffect, useState } from "react"; +import type { Root } from "react-dom/client"; +import ProviderWorkspaceShell from "../src/components/provider-workspace/ProviderWorkspaceShell"; +import ProviderModels from "../src/components/provider-workspace/ProviderModels"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { ModelRow } from "../src/pages/models-shared"; +import type { WorkspaceProvider } from "../src/provider-workspace/catalog"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const originalFetch = globalThis.fetch; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let root: Root | undefined; +let host: HTMLElement; +let rows: ModelRow[]; +let custom: Array<{ id: string; provider: string; modelId: string }>; +let selected: Record; +let available: Record; +let requests: Array<{ path: string; method: string; body?: unknown }>; +let reads: Record; +let recovery: number; +const unmountedControl = () => { throw new Error("Provider management harness is not mounted"); }; +let refresh: () => void = unmountedControl; +let choose: (name: string) => void = unmountedControl; +let deleteMode: "ok" | "reject" | "lost" | "malformed" | "refresh-failed"; +let underlying: ModelRow | undefined; +let writeGate: Promise | undefined; +const queues = new Map void; response: Promise }>>(); +const committed = { status: "committed", changed: true, degraded: false, notices: [] }; +const providers: Record = { + vendor: { adapter: "openai-chat", baseUrl: "https://vendor.invalid/v1", hasApiKey: true }, + openai: { adapter: "openai-responses", baseUrl: "https://openai.invalid/v1", authMode: "forward" }, + other: { adapter: "openai-chat", baseUrl: "https://other.invalid/v1", hasApiKey: true }, +}; +const row = (id: string, extra: Partial = {}): ModelRow => ({ provider: "vendor", id, namespaced: `vendor/${id}`, disabled: false, ...extra }); + +function hold(path: string) { + let arrived!: () => void; let release!: (response: Response) => void; + const started = new Promise(resolve => { arrived = resolve; }); + const response = new Promise(resolve => { release = resolve; }); + queues.set(path, [...(queues.get(path) ?? []), { arrived, response }]); + return { started, release }; +} +function selection() { return { selected, available, liveModelCounts: { vendor: 2, openai: 0, other: 1 } }; } +function addCustom(id = "custom-1", provider = "vendor", modelId = "custom-model") { + custom.push({ id, provider, modelId }); + rows.push(row(modelId, { provider, namespaced: `${provider}/${modelId}`, custom: true, customId: id })); +} +async function api(input: RequestInfo | URL, init?: RequestInit): Promise { + const path = new URL(String(input), "http://localhost").pathname; + const method = init?.method ?? "GET"; + if (method === "GET") { + reads[path] = (reads[path] ?? 0) + 1; + const queued = queues.get(path)?.shift(); + if (queued) { queued.arrived(); return queued.response; } + if (path === "/api/models") return Response.json(rows); + if (path === "/api/selected-models") return Response.json(selection()); + if (path === "/api/custom-models") return Response.json(custom); + if (path === "/api/provider-quotas") return Response.json({ reports: [] }); + if (path === "/api/usage") return Response.json({ providers: [], models: [] }); + throw new Error(`Unexpected read: ${path}`); + } + const body: unknown = init?.body ? JSON.parse(String(init.body)) : undefined; + requests.push({ path, method, ...(body === undefined ? {} : { body }) }); + if (writeGate) await writeGate; + if (method === "POST" && path === "/api/custom-models") { + const target = body as { provider: string; modelId: string }; + if (custom.some(value => value.provider === target.provider && value.modelId === target.modelId)) return Response.json({ error: "duplicate model" }, { status: 409 }); + addCustom("readded-id", target.provider, target.modelId); + return Response.json({ id: "readded-id", ...target, catalogRefresh: committed }, { status: 201 }); + } + if (method === "DELETE") { + const id = decodeURIComponent(path.split("/").at(-1)!); + const record = custom.find(value => value.id === id); + if (!record || deleteMode === "reject") return Response.json({ error: "not found" }, { status: 404 }); + custom = custom.filter(value => value.id !== id); + rows = rows.filter(value => value.customId !== id); + if (underlying) rows.push({ ...underlying }); + if (deleteMode === "lost") throw new Error("transport lost after deletion"); + if (deleteMode === "malformed") return new Response("{", { status: 200 }); + return Response.json({ ok: true, catalogRefresh: deleteMode === "refresh-failed" + ? { status: "failed", reason: "disk", phase: "commit", retryable: false, partialWrite: true } + : committed }); + } + if (method === "PUT" && path === "/api/model-visibility") { + const target = body as { scope: string; provider: string; targets: Array<{ id: string; native?: boolean }>; enabled: boolean }; + for (const entry of target.targets) { + const found = rows.find(value => value.provider === target.provider && value.id === entry.id && (value.native === true) === (entry.native === true)); + if (!found || found.custom) return Response.json({ error: "invalid target" }, { status: 400 }); + found.disabled = !target.enabled; + } + return Response.json({ ok: true, scope: target.scope, provider: target.provider, enabled: target.enabled, + disabled: rows.filter(value => value.disabled).map(value => value.namespaced), catalogRefresh: committed }); + } + throw new Error(`Unexpected write: ${method} ${path}`); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; + win = new Window({ url: "http://localhost/#providers" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + for (const [key, value] of Object.entries({ document: win.document, window: win, navigator: win.navigator, + localStorage: win.localStorage, sessionStorage: win.sessionStorage, IS_REACT_ACT_ENVIRONMENT: true })) { + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + win.confirm = () => true; + rows = []; custom = []; selected = {}; available = {}; requests = []; reads = {}; recovery = 0; + deleteMode = "ok"; underlying = undefined; writeGate = undefined; queues.clear(); + globalThis.fetch = api as typeof fetch; + host = document.createElement("div"); document.body.append(host); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; globalThis.fetch = originalFetch; + win.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); +}); + +// Observe DOM state, not elapsed time. Timeout is only a failing-test bound. +function observed(predicate: () => boolean): Promise { + if (predicate()) return Promise.resolve(); + return new Promise((resolve, reject) => { + const observer = new win.MutationObserver(() => { + if (!predicate()) return; + observer.disconnect(); clearTimeout(timeout); resolve(); + }); + const timeout = setTimeout(() => { observer.disconnect(); reject(new Error(`DOM condition not reached: ${host.textContent}`)); }, 3000); + observer.observe(host as never, { childList: true, subtree: true, attributes: true, characterData: true }); + }); +} +const actionButtons = () => [...host.querySelectorAll('.pws-model-chip button[aria-label^="Hide: "], .pws-model-chip button[aria-label^="Delete: "]')]; +const actionable = () => actionButtons().filter(button => !button.disabled); +const chip = (selector: string) => [...host.querySelectorAll(".pws-model-chip")].find(value => value.querySelector(".pws-model-chip-main")?.getAttribute("title") === selector)!; +function action(selector: string, kind: "Delete" | "Hide"): HTMLButtonElement { + const expectedName = `${kind}: ${selector}`; + const matches = [...chip(selector).querySelectorAll("button")] + .filter(button => button.getAttribute("aria-label") === expectedName); + expect(matches).toHaveLength(1); + expect(matches[0]!.title).toBe(expectedName); + return matches[0]!; +} +const ids = () => [...host.querySelectorAll(".pws-model-id")].map(node => node.textContent); +const feedback = () => [...host.querySelectorAll('[role="alert"], [role="status"]')].map(node => node.textContent).join(" "); +async function waitFor(predicate: () => boolean) { + // Let React finish effects before awaiting a future DOM transition in a separate turn. + await act(async () => {}); + await observed(predicate); + await act(async () => {}); +} +async function mount(name = "vendor") { + function Harness() { + const [epoch, setEpoch] = useState(0); const [provider, setProvider] = useState(name); + useEffect(() => { + const committedRefresh = () => setEpoch(value => value + 1); + refresh = committedRefresh; + choose = setProvider; + return () => { + if (refresh === committedRefresh) refresh = unmountedControl; + if (choose === setProvider) choose = unmountedControl; + }; + }, []); + return setProvider(value ?? "vendor")} onAddProvider={() => {}} + modelsRefreshToken={epoch} detail={(item, data) =>
    + + { recovery += 1; }} /> +
    } />; + } + const { createRoot } = await import("react-dom/client"); + await act(async () => { root = createRoot(host); root.render(); }); +} +async function click(button: HTMLButtonElement) { expect(button).toBeDefined(); await act(async () => { button.click(); }); } +async function current() { await waitFor(() => host.querySelector('[data-testid="parent-ready"]')?.getAttribute("data-ready") === "true"); } +async function refreshCurrent() { await act(async () => { refresh(); }); await current(); } + +for (const counterpart of ["native", "discovered", "none"] as const) { + test(`DELETE only: ${counterpart} counterpart is reconciled without a visibility tombstone`, async () => { + const provider = counterpart === "native" ? "openai" : "vendor"; + addCustom("stable/id", provider, "same"); + if (counterpart !== "none") underlying = row("same", { provider, namespaced: counterpart === "native" ? "same" : "vendor/same", native: counterpart === "native" }); + rows.push(row("independently-hidden", { provider, namespaced: `${provider}/independently-hidden`, disabled: true })); + selected = { [provider]: ["another"] }; + await mount(provider); await waitFor(() => actionable().length === 1); + await click(action(`${provider}/same`, "Delete")); + await waitFor(() => feedback().includes("Custom definition deleted")); await current(); + expect(requests).toEqual([{ path: "/api/custom-models/stable%2Fid", method: "DELETE" }]); + expect(custom).toEqual([]); expect(selected).toEqual({ [provider]: ["another"] }); + expect(rows.find(value => value.id === "independently-hidden")?.disabled).toBe(true); + expect(ids()).toEqual(counterpart === "none" ? [] : ["same"]); + await refreshCurrent(); expect(ids()).toEqual(counterpart === "none" ? [] : ["same"]); + }); +} + +test("same-label custom and account-native rows keep disjoint Delete/Hide identities", async () => { + const id = "account-work/gpt-5.5"; + const nativeSelector = id; + const customSelector = "openai/account-work-gpt-5.5"; + custom = [{ id: "override", provider: "openai", modelId: id }]; + rows = [row(id, { provider: "openai", namespaced: nativeSelector, native: true }), + row(id, { provider: "openai", namespaced: customSelector, custom: true, customId: "override" })]; + await mount("openai"); await waitFor(() => actionable().length === 2); + expect(ids()).toEqual([nativeSelector, customSelector]); + expect(actionButtons().map(button => button.getAttribute("aria-label"))).toEqual([ + "Hide: account-work/gpt-5.5", "Delete: openai/account-work-gpt-5.5", + ]); + expect(actionButtons().map(button => button.title)).toEqual([ + "Hide: account-work/gpt-5.5", "Delete: openai/account-work-gpt-5.5", + ]); + expect([...host.querySelectorAll(".pws-model-chip-main")].map(button => button.getAttribute("aria-label"))) + .toEqual(["Copy ID", "Copy ID"]); + await click(action(customSelector, "Delete")); await waitFor(() => ids().length === 1); + expect(requests).toEqual([{ path: "/api/custom-models/override", method: "DELETE" }]); + expect(rows[0]?.disabled).toBe(false); + await refreshCurrent(); expect(ids()).toEqual([id]); + await waitFor(() => actionable().length === 1); await click(action(nativeSelector, "Hide")); + await waitFor(() => ids().length === 0); + expect(requests[1]?.body).toEqual({ scope: "models", provider: "openai", targets: [{ id, native: true }], enabled: false }); + const recover = [...host.querySelectorAll("button")].find(button => button.textContent === "Manage visibility in Models")!; + await click(recover); expect(recovery).toBe(1); +}); + +for (const customRow of [true, false]) { + test(`cancel ${customRow ? "Delete" : "Hide"} sends no write and preserves the row`, async () => { + if (customRow) addCustom(); else rows = [row("custom-model")]; + win.confirm = () => false; await mount(); await waitFor(() => actionable().length === 1); + await click(action("vendor/custom-model", customRow ? "Delete" : "Hide")); expect(requests).toEqual([]); expect(ids()).toEqual(["custom-model"]); + }); +} + +for (const mode of ["reject", "lost", "malformed", "refresh-failed"] as const) { + test(`DELETE ${mode} reconciles persisted truth, preserves feedback and never PUTs`, async () => { + addCustom(); deleteMode = mode; await mount(); await waitFor(() => actionable().length === 1); + await click(action("vendor/custom-model", "Delete")); + await waitFor(() => feedback().includes(mode === "refresh-failed" ? "could not be refreshed" : mode === "reject" ? "Failed" : "could not be confirmed")); + await current(); + expect(requests).toEqual([{ path: "/api/custom-models/custom-1", method: "DELETE" }]); + expect(ids()).toEqual(mode === "reject" ? ["custom-model"] : []); + expect(custom).toHaveLength(mode === "reject" ? 1 : 0); + expect((reads["/api/custom-models"] ?? 0)).toBeGreaterThan(1); + expect((reads["/api/models"] ?? 0)).toBeGreaterThan(1); + expect(host.querySelector('[role="alert"], [role="status"]')).not.toBeNull(); + }); +} + +test("single flight blocks a second row until the first write and all reconciliation reads finish", async () => { + addCustom(); rows.push(row("second")); let releaseWrite!: () => void; + writeGate = new Promise(resolve => { releaseWrite = resolve; }); + await mount(); await waitFor(() => actionable().length === 2); + const first = action("vendor/custom-model", "Delete"); const second = action("vendor/second", "Hide"); + await act(async () => { first.click(); second.click(); }); expect(requests).toHaveLength(1); + const inventory = hold("/api/models"); const ownership = hold("/api/custom-models"); + await act(async () => { releaseWrite(); }); await inventory.started; await ownership.started; + expect(actionable()).toEqual([]); + await act(async () => { inventory.release(Response.json(rows)); }); await current(); expect(actionable()).toEqual([]); + await act(async () => { ownership.release(Response.json(custom)); }); + await waitFor(() => actionable().length === 1); expect(requests).toHaveLength(1); +}); + +test("three-read revision: parent pair cannot certify actions while current custom GET is pending", async () => { + addCustom(); await mount(); await waitFor(() => actionable().length === 1); + const inventory = hold("/api/models"), selectionRead = hold("/api/selected-models"), ownership = hold("/api/custom-models"); + await act(async () => { refresh(); }); + expect(actionable()).toEqual([]); + await Promise.all([inventory.started, selectionRead.started, ownership.started]); + await act(async () => { selectionRead.release(Response.json(selection())); }); expect(actionable()).toEqual([]); + await act(async () => { inventory.release(Response.json(rows)); }); await current(); expect(actionable()).toEqual([]); + await act(async () => { ownership.release(Response.json(custom)); }); await waitFor(() => actionable().length === 1); + expect(requests).toEqual([]); +}); + +for (const failedPath of ["/api/models", "/api/selected-models"]) { + test(`a failed half (${failedPath}) keeps the paired observation read-only until Retry`, async () => { + rows = [row("live")]; await mount(); await waitFor(() => actionable().length === 1); + const bad = hold(failedPath); await act(async () => { refresh(); }); await bad.started; + await act(async () => { bad.release(Response.json({ error: "offline" }, { status: 503 })); }); + await waitFor(() => host.querySelector('[role="alert"]') !== null); + expect(actionable()).toEqual([]); expect(requests).toEqual([]); + const retry = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Retry")!; + await click(retry); await waitFor(() => actionable().length === 1); + }); +} + +test("reversed parent and ownership responses cannot restore an older custom stable id", async () => { + addCustom("old"); await mount(); await waitFor(() => actionable().length === 1); + const oldRows = structuredClone(rows), oldCustom = structuredClone(custom); + const staleRows = hold("/api/models"), staleSelection = hold("/api/selected-models"), staleCustom = hold("/api/custom-models"); + await act(async () => { refresh(); }); await Promise.all([staleRows.started, staleSelection.started, staleCustom.started]); + custom[0]!.id = "replacement"; rows[0]!.customId = "replacement"; + await act(async () => { refresh(); }); await waitFor(() => actionable().length === 1); + await act(async () => { + staleCustom.release(Response.json(oldCustom)); staleRows.release(Response.json(oldRows)); staleSelection.release(Response.json(selection())); + }); + await click(action("vendor/custom-model", "Delete")); await waitFor(() => custom.length === 0 && ids().length === 0); + expect(requests).toEqual([{ path: "/api/custom-models/replacement", method: "DELETE" }]); +}); + +test("mismatched current custom ownership never falls back to Hide", async () => { + addCustom("dto-id"); custom[0]!.id = "different-id"; + await mount(); await current(); + expect(actionable()).toEqual([]); expect(chip("vendor/custom-model").querySelector('button[aria-label="Hide: vendor/custom-model"]')).toBeNull(); expect(requests).toEqual([]); +}); + +test("provider switch rejects the previous provider's delayed ownership", async () => { + addCustom("vendor-id"); addCustom("other-id", "other", "other-model"); + const delayed = hold("/api/custom-models"); await mount(); await delayed.started; + await act(async () => { choose("other"); }); await waitFor(() => actionable().length === 1); + await act(async () => { delayed.release(Response.json([{ id: "vendor-id", provider: "vendor", modelId: "custom-model" }])); }); + expect(ids()).toEqual(["other-model"]); await click(action("other/other-model", "Delete")); + await waitFor(() => ids().length === 0); + expect(requests).toEqual([{ path: "/api/custom-models/other-id", method: "DELETE" }]); + expect(custom).toEqual([{ id: "vendor-id", provider: "vendor", modelId: "custom-model" }]); +}); + +test("pending selection rows have no destructive action", async () => { + rows = [row("pending", { initialSelectionPending: true, disabled: true })]; await mount(); await current(); + expect(ids()).toEqual([]); expect(feedback()).toContain("Finish model selection"); expect(actionable()).toEqual([]); expect(requests).toEqual([]); +}); + +test("malformed DTO makes the parent unavailable, not an editable fallback inventory", async () => { + const invalid = hold("/api/models"); await mount(); await invalid.started; + await act(async () => { invalid.release(Response.json([{ provider: "vendor", id: "bad", disabled: false }])); }); + await waitFor(() => host.querySelector('[role="alert"]') !== null); + expect(actionable()).toEqual([]); + expect(host.textContent).toContain("Manage visibility in Models"); expect(requests).toEqual([]); +}); + +test("rail counts inventory before search/cap and routed selection does not badge native", async () => { + rows = Array.from({ length: 305 }, (_, i) => row(`model-${String(i).padStart(3, "0")}`)); + rows.push(row("hidden", { disabled: true })); selected = { vendor: ["model-304"] }; + available = { vendor: rows.map(value => value.id) }; + await mount(); await waitFor(() => ids().length === 300); + const vendorOptions = host.querySelectorAll('[role="option"][title="Vendor"]'); + expect(vendorOptions).toHaveLength(1); + const rail = vendorOptions[0]!; + expect(rail.getAttribute("aria-selected")).toBe("true"); + expect(rail.textContent).toContain("305"); + const search = host.querySelector("input.pws-model-search")!; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(search, "model-304"); + search.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + expect(ids()).toEqual(["model-304"]); expect(rail.textContent).toContain("305"); + expect(chip("vendor/model-304").textContent).toContain("Selected"); + custom = [{ id: "override", provider: "openai", modelId: "account/model" }]; + rows = [row("account/model", { provider: "openai", namespaced: "account/model", native: true }), + row("account/model", { provider: "openai", namespaced: "openai/account-model", custom: true, customId: "override" })]; + selected = { openai: ["account/model"] }; await act(async () => { choose("openai"); refresh(); }); + await waitFor(() => ids().length === 2); + expect(chip("account/model").querySelector(".badge-accent")).toBeNull(); + expect(chip("openai/account-model").querySelector(".badge-accent")).not.toBeNull(); +}); + +for (const moved of [false, true]) { + test(`focused deletion restores a stable control without stealing focus (moved=${moved})`, async () => { + addCustom(); rows.push(row("remaining")); let release!: () => void; + writeGate = new Promise(resolve => { release = resolve; }); + await mount(); await waitFor(() => actionable().length === 2); + const remove = action("vendor/custom-model", "Delete"); remove.focus(); await click(remove); + const stable = host.querySelector("input.pws-model-search")!; + if (moved) stable.focus(); + await act(async () => { release(); }); await waitFor(() => ids().length === 1); + if (moved) expect(document.activeElement).toBe(stable); + else { + expect(document.activeElement).not.toBe(document.body); + expect(host.contains(document.activeElement)).toBe(true); + expect(document.activeElement?.matches('input.pws-model-search, button')).toBe(true); + } + }); +} + +test("custom-only Delete then re-add survives an actual unmount with no tombstone", async () => { + addCustom(); await mount(); await waitFor(() => actionable().length === 1); + await click(action("vendor/custom-model", "Delete")); await waitFor(() => ids().length === 0); await current(); + const draft = host.querySelector('input[aria-label="Add custom model"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(draft, "custom-model"); + draft.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + const add = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add")!; + await waitFor(() => !add.disabled); await click(add); await waitFor(() => ids().length === 1); + expect(requests).toEqual([ + { path: "/api/custom-models/custom-1", method: "DELETE" }, + { path: "/api/custom-models", method: "POST", body: { provider: "vendor", modelId: "custom-model" } }, + ]); + await act(async () => { root!.unmount(); }); root = undefined; + await mount(); await waitFor(() => actionable().length === 1); + expect(ids()).toEqual(["custom-model"]); expect(custom[0]?.id).toBe("readded-id"); +}); + +test("independent hides persist on remount and an external unhide returns through paired refresh", async () => { + rows = [row("live")]; await mount(); await waitFor(() => actionable().length === 1); + await click(action("vendor/live", "Hide")); await waitFor(() => ids().length === 0); + await act(async () => { root!.unmount(); }); root = undefined; + await mount(); await current(); expect(ids()).toEqual([]); + rows[0]!.disabled = false; await refreshCurrent(); await waitFor(() => actionable().length === 1); + expect(ids()).toEqual(["live"]); expect(requests).toEqual([{ path: "/api/model-visibility", method: "PUT", body: { scope: "models", provider: "vendor", targets: [{ id: "live", native: false }], enabled: false } }]); +}); + +test("one parent endpoint pair serves the entire inventory instead of per-chip requests", async () => { + rows = Array.from({ length: 25 }, (_, i) => row(`model-${i}`)); + await mount(); await waitFor(() => actionable().length === 25); + expect(reads["/api/models"]).toBe(1); expect(reads["/api/selected-models"]).toBe(1); + expect(reads["/api/custom-models"]).toBe(1); +}); diff --git a/package.json b/package.json index 07d059ba85..ab761f8eff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yansigit/opencodex", - "version": "2.43.0", + "version": "2.45.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", @@ -18,7 +18,6 @@ "files": [ "bin", "src", - "scripts/benchmark-claude-tokens.ts", "gui/dist", "assets/banner.png", "assets/architecture.png", @@ -26,7 +25,8 @@ "assets/codex-app-picker.png", "README.md", "AGENTS_INSTALL.md", - "LICENSE" + "LICENSE", + "scripts/benchmark-claude-tokens.ts" ], "engines": { "node": ">=18" diff --git a/scripts/ci/assert-mergeable-review.sh b/scripts/ci/assert-mergeable-review.sh index 9f2b337e35..21836d7ca8 100755 --- a/scripts/ci/assert-mergeable-review.sh +++ b/scripts/ci/assert-mergeable-review.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # Fail-closed pre-merge review gate for the bug-PR campaign. # -# MAINTAINERS.md requires a maintainer approval, forbids self-approval, and requires -# explicit security review on security-boundary changes. GitHub cannot express the last +# The default path requires a non-self maintainer approval. The explicit +# --maintainer-integration path permits a trusted maintain/admin actor to integrate +# into dev without a second approval; it is not an approving review. CI and explicit +# security review remain separate duties. GitHub cannot express the last # part, and `dismiss_stale_reviews_on_push` is false on this repository, so an approval # granted to an older head survives a force-push that invalidates it. An admin merge can # bypass the approval requirement entirely. @@ -29,13 +31,30 @@ # partial view of the review history. A gate that treats a failed lookup as an empty result # is not fail-closed. # -# Usage: scripts/ci/assert-mergeable-review.sh [repo] +# Usage: scripts/ci/assert-mergeable-review.sh [--maintainer-integration] [repo] set -euo pipefail -PR="${1:?usage: assert-mergeable-review.sh [repo]}" -REPO="${2:-lidge-jun/opencodex}" +maintainer_integration=false +positionals=() +for arg in "$@"; do + case "$arg" in + --maintainer-integration) maintainer_integration=true ;; + -*) echo "FAIL: unknown option $arg" >&2; exit 2 ;; + *) positionals+=("$arg") ;; + esac +done +if [ "${#positionals[@]}" -lt 1 ] || [ "${#positionals[@]}" -gt 2 ]; then + echo "usage: assert-mergeable-review.sh [--maintainer-integration] [repo]" >&2 + exit 2 +fi +PR="${positionals[0]}" +REPO="${positionals[1]:-lidge-jun/opencodex}" +if [[ ! "$PR" =~ ^[0-9]+$ ]] || [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "FAIL: invalid pull-request number or repository" >&2 + exit 2 +fi -meta=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,author,title) || { +meta=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,author,title,baseRefName) || { echo "FAIL: could not read required metadata for #$PR" >&2 exit 2 } @@ -53,19 +72,59 @@ IFS=$'\t' read -r head author <<< "$identity" # Maintainer roster comes from MAINTAINERS.md itself, not from a hardcoded list here, so # the gate cannot drift from the policy document it enforces. -roster=$(gh api "repos/$REPO/contents/MAINTAINERS.md" --jq .content \ +roster_endpoint="repos/$REPO/contents/MAINTAINERS.md" +if "$maintainer_integration"; then + roster_endpoint="$roster_endpoint?ref=dev" + if ! printf '%s' "$meta" | jq -e '.baseRefName == "dev"' >/dev/null; then + echo "FAIL: maintainer integration is restricted to dev" >&2 + exit 1 + fi +fi +load_roster() { + gh api "$roster_endpoint" --jq .content \ | base64 -d \ | sed -n '/^## Current maintainers/,/^## Former maintainers/p' \ | grep -oE '\[@[A-Za-z0-9-]+\]' \ | tr -d '@[]' \ | jq -Rr 'ascii_downcase' \ - | sort -u) + | sort -u +} +roster=$(load_roster) || { + echo "FAIL: could not read trusted maintainer roster" >&2 + exit 2 +} if [ -z "$roster" ]; then echo "FAIL: could not parse the maintainer roster from MAINTAINERS.md" >&2 exit 2 fi +authorize_actor() { + local trusted_roster="$1" user actor permission + user=$(gh api user) || return 2 + actor=$(printf '%s' "$user" | jq -er ' + select(.type == "User") | .login + | select(type == "string" and test("^[A-Za-z0-9-]+$")) | ascii_downcase + ') || return 2 + if ! printf '%s\n' "$trusted_roster" | grep -Fxq "$actor"; then + echo "FAIL: authenticated actor is not a current maintainer" >&2 + return 1 + fi + permission=$(gh api "repos/$REPO/collaborators/$actor/permission") || return 2 + if ! printf '%s' "$permission" | jq -e '.role_name == "maintain" or .role_name == "admin"' >/dev/null; then + echo "FAIL: maintainer integration requires live maintain/admin access" >&2 + return 1 + fi + printf '%s' "$actor" +} +actor="" +if "$maintainer_integration"; then + actor=$(authorize_actor "$roster") || { + echo "FAIL: could not authorize the authenticated maintainer" >&2 + exit 2 + } +fi + # No `|| true`: a failed or partial review fetch must abort, not degrade to "no approvals". reviews=$(gh api "repos/$REPO/pulls/$PR/reviews" --paginate --slurp) || { echo "FAIL: could not read reviews for #$PR (API or pagination failure)" >&2 @@ -147,35 +206,57 @@ if [ -n "$blockers" ]; then exit 1 fi -decision=$(gh pr view "$PR" --repo "$REPO" --json reviewDecision --jq '.reviewDecision // ""') || { - echo "FAIL: could not read reviewDecision for #$PR" >&2 - exit 2 -} -if [ "$decision" != "APPROVED" ]; then - echo "FAIL: #$PR reviewDecision is '${decision:-none}', not APPROVED" >&2 - exit 1 -fi +qualified="" +if ! "$maintainer_integration"; then + decision=$(gh pr view "$PR" --repo "$REPO" --json reviewDecision --jq '.reviewDecision // ""') || { + echo "FAIL: could not read reviewDecision for #$PR" >&2 + exit 2 + } + if [ "$decision" != "APPROVED" ]; then + echo "FAIL: #$PR reviewDecision is '${decision:-none}', not APPROVED" >&2 + exit 1 + fi -qualified=$(printf '%s' "$latest" | jq -r --arg head "$head" --arg author "$author" --argjson roster "$(printf '%s\n' "$roster" | jq -R . | jq -s .)" ' - .[] - | select(.state == "APPROVED") - | select(.commit == $head) - | select(.login != $author) - | select(.login as $l | $roster | index($l)) - | .login -' | head -1) + qualified=$(printf '%s' "$latest" | jq -r --arg head "$head" --arg author "$author" --argjson roster "$(printf '%s\n' "$roster" | jq -R . | jq -s .)" ' + .[] + | select(.state == "APPROVED") + | select(.commit == $head) + | select(.login != $author) + | select(.login as $l | $roster | index($l)) + | .login + ' | head -1) -if [ -z "$qualified" ]; then - echo "FAIL: #$PR has no maintainer approval bound to head $head" >&2 - echo " author: $author" >&2 - echo " approvals at head: ${approvals:-(none)}" >&2 - echo " maintainer roster: $(printf '%s' "$roster" | tr '\n' ' ')" >&2 - exit 1 + if [ -z "$qualified" ]; then + echo "FAIL: #$PR has no maintainer approval bound to head $head" >&2 + echo " author: $author" >&2 + echo " approvals at head: ${approvals:-(none)}" >&2 + echo " maintainer roster: $(printf '%s' "$roster" | tr '\n' ' ')" >&2 + exit 1 + fi +fi + +if "$maintainer_integration"; then + final_roster=$(load_roster) || { + echo "FAIL: could not re-read trusted maintainer roster" >&2 + exit 2 + } + if [ "$final_roster" != "$roster" ]; then + echo "FAIL: maintainer roster changed during validation" >&2 + exit 1 + fi + final_actor=$(authorize_actor "$final_roster") || { + echo "FAIL: maintainer authorization no longer holds" >&2 + exit 2 + } + if [ "$final_actor" != "$actor" ]; then + echo "FAIL: authenticated actor changed during validation" >&2 + exit 1 + fi fi # The review work above may race a contributor push. Re-read the head immediately before # success so this verdict and the printed --match-head-commit instruction name one SHA. -final_meta=$(gh pr view "$PR" --repo "$REPO" --json headRefOid) || { +final_meta=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,baseRefName,author) || { echo "FAIL: could not re-read head SHA for #$PR" >&2 exit 2 } @@ -190,5 +271,17 @@ if [ "$final_head" != "$head" ]; then exit 1 fi -echo "OK: #$PR approved at head $head by maintainer $qualified (author $author)" -echo "Merge with: gh pr merge $PR --repo $REPO --match-head-commit $head" +if "$maintainer_integration"; then + if ! printf '%s' "$final_meta" | jq -e --arg author "$author" ' + .baseRefName == "dev" and (.author.login | type == "string") + and (.author.login | ascii_downcase) == $author + ' >/dev/null; then + echo "FAIL: pull-request base or author changed during validation" >&2 + exit 1 + fi + echo "OK: validation snapshot for #$PR into dev at head $head by $actor; CI and security review remain separate" + echo "Snapshot only: revalidate the current actor and dev base before a separately authorized merge; head matching does not pin the base." +else + echo "OK: #$PR approved at head $head by maintainer $qualified (author $author)" + echo "Merge with: gh pr merge $PR --repo $REPO --match-head-commit $head" +fi diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 71b32d8740..d309073a3f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,7 +11,7 @@ "domains": { "providers": { "match": [ - "^(?:aside|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" + "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" ], "children": { "cursor": [ @@ -38,6 +38,7 @@ }, "server": { "match": [ + "^aside-profiles-routes", "^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-" ] }, @@ -107,12 +108,10 @@ }, "clients": { "match": [ + "^aside-profile(?!s-routes)", "^(?:desktop|omp|pi|prime|remote|sync)-" ] }, - "fork": { - "match": [] - }, "service": { "match": [ "^(?:autostart|crash|doctor|init|service|service\\.test\\.ts|shutdown|stale|stop|systemd|winsw\\.test\\.ts)-" @@ -165,40 +164,12 @@ } }, "explicit": { - "auto-release-workflow.test.ts": "fork", - "core-fork-preservation.test.ts": "responses", - "dev-auto-release-workflow.test.ts": "fork", - "dev-promotion-workflow.test.ts": "fork", - "promotion-backmerge.test.ts": "fork", - "register.test.ts": "fork", - "release-candidate-publish-workflow.test.ts": "fork", - "release-candidate-workflow.test.ts": "fork", - "release-pr-workflow.test.ts": "fork", - "sync-cli.test.ts": "fork", - "sync-contained.test.ts": "fork", - "sync-detect.test.ts": "fork", - "sync-generic-cli.test.ts": "fork", - "sync-generic-http.test.ts": "fork", - "sync-lane.test.ts": "fork", - "sync-notify.test.ts": "fork", - "sync-overlap.test.ts": "fork", - "sync-ownership.test.ts": "fork", - "sync-pin.test.ts": "fork", - "sync-pr-mergeable.test.ts": "fork", - "sync-prepare.test.ts": "fork", - "sync-preservation.test.ts": "fork", - "sync-publish.test.ts": "fork", - "sync-pull-request.test.ts": "fork", - "sync-vendor-atomic.test.ts": "fork", - "sync-webhook.test.ts": "fork", - "sync-workflow.test.ts": "fork", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", "active-registry-admission.test.ts": "codex-integration", - "actionlint-runner.test.ts": "ci-workflows", "adapter-buffered-tool-conformance.test.ts": "adapters", "adapter-error-inline.test.ts": "adapters", "adapter-event-oauth-failover.test.ts": "oauth", @@ -207,7 +178,6 @@ "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", - "agent-roles-sync.test.ts": "routing", "agent-task-recovery-cache.test.ts": "server", "agent-task-recovery-combo.test.ts": "server", "agent-task-recovery-fallback.test.ts": "server", @@ -264,22 +234,17 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", - "aistudio-bridge-endpoint.test.ts": "adapters/google", - "aistudio-credentials.test.ts": "adapters/google", - "aistudio-extension.test.ts": "adapters/google", - "aistudio-login-cli.test.ts": "adapters/google", - "aistudio-login-flow.test.ts": "adapters/google", - "aistudio-native-webkit.test.ts": "adapters/google", - "aistudio-session-sync.test.ts": "adapters/google", - "audit-high.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", "azure-adapter.test.ts": "providers", "azure-model-router-tool-schema.test.ts": "providers", "baseten-provider.test.ts": "providers", "bearer-admission-routed-provider.test.ts": "codex-integration", - "benchmark-claude-tokens-script.test.ts": "claude-integration", "bounded-body.test.ts": "server", "bridge-legacy-shell-normalization.test.ts": "adapters", "bridge-lifecycle.test.ts": "adapters", @@ -296,6 +261,8 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", @@ -303,10 +270,11 @@ "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", + "chat-json-sse-fallback.test.ts": "responses", + "chat-refusal.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", - "check-hygiene.test.ts": "ci-workflows", "chutes-provider.test.ts": "providers", "ci-workflows.test.ts": "ci-workflows", "citation-markers.test.ts": "responses", @@ -321,20 +289,16 @@ "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", - "claude-certification.test.ts": "claude-integration", - "claude-client-version.test.ts": "claude-integration", - "claude-code-compatibility-manifest.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", + "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", - "claude-directive-auth.test.ts": "claude-integration", - "claude-directive-fallback.test.ts": "claude-integration", - "claude-directive-lifecycle.test.ts": "claude-integration", + "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", @@ -346,14 +310,9 @@ "claude-models-discovery.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", - "claude-reasoning-roundtrip.test.ts": "claude-integration", - "claude-route-callback.test.ts": "claude-integration", - "claude-session.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", - "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", - "claude-token-benchmark.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", "cli-account-pool-verbs.test.ts": "cli", @@ -394,6 +353,8 @@ "client-config-export.test.ts": "config", "client-config-new-clients.test.ts": "config", "client-connect.test.ts": "clients", + "client-injection-guard.test.ts": "codex-integration", + "client-lifecycle-lock.test.ts": "clients", "client-export-modality-enum.test.ts": "clients", "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", @@ -494,7 +455,6 @@ "codex-quota-auto-refresh.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", - "codex-quota.test.ts": "providers", "codex-quota-rejection.test.ts": "codex-integration", "codex-refresh.test.ts": "codex-integration", "codex-reset-credit-auto-redeem.test.ts": "codex-integration", @@ -535,6 +495,7 @@ "command-code-quota.test.ts": "providers", "command-code-workspace-cache.test.ts": "providers", "commandcode-provider.test.ts": "providers", + "compaction-progress.test.ts": "responses", "compatibility-manifest.test.ts": "codex-integration", "compatibility-provider-equivalence.test.ts": "routing", "compatibility-version.test.ts": "ci-workflows", @@ -549,7 +510,6 @@ "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", "continuation-dedup.test.ts": "responses", - "conversation-progress.test.ts": "usage", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", "cost-scoring.test.ts": "usage", @@ -633,6 +593,7 @@ "desktop-3p-guard.test.ts": "clients", "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", + "desktop-remote-store.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", @@ -774,6 +735,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", @@ -808,10 +770,6 @@ "lab-public-wire-contract.test.ts": "lab", "lab-read-filter-validation.test.ts": "lab", "lab-read-surfaces.test.ts": "lab", - "live-inference-workflow.test.ts": "ci-workflows", - "live-smoke-ci.test.ts": "ci-workflows", - "live-smoke-cli.test.ts": "ci-workflows", - "live-smoke-report.test.ts": "ci-workflows", "legacy-shell-compat.test.ts": "responses", "local-management-attestation.test.ts": "server", "local-management-capability.test.ts": "server", @@ -819,12 +777,12 @@ "local-provider-reload-client.test.ts": "server", "local-token-detect.test.ts": "oauth", "logs-model-tier-confirmation.test.ts": "gui", - "logs-filter.test.ts": "gui", "logs-timezone.test.ts": "server", "loop-reasoning-replay.test.ts": "images", "loop.test.ts": "images", "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", + "macos-serial-lanes.test.ts": "ci-workflows", "management-api-logs-metrics.test.ts": "server", "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", @@ -850,7 +808,6 @@ "model-cache.test.ts": "codex-integration", "model-discovery-management-api.test.ts": "server", "model-display-names-management-api.test.ts": "codex-integration", - "model-metadata-resolver.test.ts": "codex-integration", "model-metadata-sync.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", @@ -901,7 +858,6 @@ "oauth-callback-binds.test.ts": "oauth", "oauth-callback-server.test.ts": "oauth", "oauth-device-code-contract.test.ts": "oauth", - "oauth-failover-optout-security.test.ts": "oauth", "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", @@ -919,7 +875,6 @@ "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", - "oauth-transport.test.ts": "oauth", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", @@ -953,6 +908,7 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", + "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -981,16 +937,13 @@ "policy-execution.test.ts": "routing", "port-reclaim.test.ts": "server", "ports.test.ts": "server", - "post-release-decision.test.cjs": "fork", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", - "privacy-scan.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", "process-control-graceful.test.ts": "lib", "process-control.test.ts": "lib", "process-state.test.ts": "service", "project-config-warnings.test.ts": "codex-integration", - "promotion-audit-reuse.test.ts": "ci-workflows", "provider-account-quota-persistence.test.ts": "providers", "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", @@ -1013,7 +966,6 @@ "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", "provider-static-model-discovery.test.ts": "providers", - "provider-tls-profile.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", @@ -1055,8 +1007,6 @@ "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", - "replit-pair-install-response.test.ts": "providers", - "replit-provider-setup.test.ts": "providers", "version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", "remove-tree-helper.test.ts": "lib", @@ -1075,6 +1025,8 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", + "responses-forward-incomplete-quota.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", @@ -1134,10 +1086,12 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", + "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", @@ -1180,8 +1134,6 @@ "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", - "smoke-fingerprint-cache.test.ts": "ci-workflows", - "smoke-runner.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", @@ -1227,10 +1179,6 @@ "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", "tencent-siliconflow-providers.test.ts": "gui", - "telemetry-dispatcher.test.ts": "usage", - "telemetry-fingerprint.test.ts": "usage", - "telemetry-hook.test.ts": "usage", - "telemetry-ledger.test.ts": "usage", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", @@ -1321,7 +1269,6 @@ "windows-tray.test.ts": "windows", "windows-user-principal-nonascii.test.ts": "windows", "windows-user-principal.test.ts": "windows", - "workflow-policy.test.ts": "ci-workflows", "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", "ws-endpoint.test.ts": "responses", @@ -1352,7 +1299,6 @@ "clients", "codex-integration", "config", - "fork", "gui", "lab", "lib", diff --git a/skills/ocx/SKILL.md b/skills/ocx/SKILL.md index a9975e7745..a5f19d861f 100644 --- a/skills/ocx/SKILL.md +++ b/skills/ocx/SKILL.md @@ -1,12 +1,12 @@ --- name: ocx -description: Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API. +description: "Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API." --- # Operating `ocx` `ocx` controls a locally running opencodex proxy. The CLI covers the dashboard's operational -surface, with one consent exception (starring) recorded under Consent below. `ocx capabilities` +surface, subject to Consent and Secret-bearing commands below. `ocx capabilities` lists the *declared* index, not every verb. Be precise about the gap, because guessing costs you more than reading: the capability index below @@ -90,6 +90,27 @@ starring would be useful, say so and let the user decide. The same boundary covers the session-gated `/api/codex-prompt` writes: read them with `ocx inspect codex-prompt`, and leave the writes to the dashboard. +## Secret-bearing commands + +**Do not create an access key or start an access-key rotation from an agent session.** +This covers the create and rotation-start operations under `ocx access key`, +`ocx access keys`, and `ocx api-key`, their `opencodex` equivalents and executable +wrappers, and direct POST requests to `/api/keys` and `/api/keys/rotate`. +Both text and JSON responses contain a one-time plaintext data-plane credential, +which can enter the agent transcript. Ask the user to perform that step in a +human-operated terminal outside the agent session, configure and verify the +replacement, and report only confirmation plus non-secret key/rotation IDs. +Never ask for the plaintext key in chat or offer a pipe, redirection, or API +workaround to perform the secret-returning step inside the agent session. + +Configuration confirmation is not approval to revoke the existing credential. +Identify the existing key ID and obtain separate explicit revocation approval +before committing an in-place rotation or removing an old, separately replaced key. +An existing explicit approval for that exact revocation remains valid; setup +confirmation alone does not supply it. Commit and abort return no plaintext key, +but still require authority for their state changes. Follow +[recipe 5](references/03_recipes.md#5-prepare-an-access-key-rotation-without-exposing-the-new-key). + ## Destructive verbs `storage trash restore` and `storage policy run` refuse without `--yes` (exit 2, nothing sent). diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index ea4bb86076..d5711f3cac 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -373,26 +373,6 @@ JSON mode: `payload`. - Requires transient authority on stdin; the credential is never persisted or echoed. - A rotation left pending by a crash is resumed here — startup and status stop rather than guess which key generation is live. -### `ocx provider install-replit` - -Install the paired Replit OpenAI and Anthropic providers. - -| Method | Route | -|---|---| -| POST | `/api/providers/replit-pair` | - -| Flag | Value | Meaning | -|---|---|---| -| `--origin` | string | Replit gateway origin. | -| `--stdin` | boolean | Read the gateway key from stdin. | -| `--gateway-key-file` | string | Read the gateway key from a private file. | -| `--allow-custom-domain` | boolean | Allow a non-Replit gateway domain. | -| `--replace` | boolean | Replace an existing provider pair. | -| `--set-default` | boolean | Select Replit as the default provider. | -| `--json` | boolean | Emit the installation result as JSON. | - -JSON mode: `payload`. - ### `ocx provider keychain` Move a provider's API key into the OS keychain, restore it, or report where it lives. @@ -603,94 +583,70 @@ JSON mode: `payload`. - The list renders per-client state, installed, and desired columns; a blocked disable is named rather than left silent. - Each client has its own route because a toggle rewrites that client's own config file. -### `ocx agent request-user-input` +### `ocx integration client` -Show or set whether default mode may ask the operator a question mid-task. +Inspect and toggle Aside profile catalogs, read their history, and restore a selected profile operation. | Method | Route | |---|---| -| GET | `/api/codex-auth/features/default-mode-request-user-input` | -| PUT | `/api/codex-auth/features/default-mode-request-user-input` | +| GET | `/api/client-integrations/aside/profiles` | +| PUT | `/api/client-integrations/aside/profiles` | +| GET | `/api/client-integrations/aside/profiles/{profileId}` | +| PUT | `/api/client-integrations/aside/profiles/{profileId}` | +| GET | `/api/client-integrations/aside/profiles/journal` | +| GET | `/api/client-integrations/aside/profiles/{profileId}/journal` | +| POST | `/api/client-integrations/aside/profiles/{profileId}/restore` | | Flag | Value | Meaning | |---|---|---| -| `--json` | boolean | Emit the feature state as JSON. | +| `--client` | string | Select the file integration; use aside for profile controls. | +| `--profile` | number | Select one registered Aside account; omitted toggles affect all profiles. | +| `--op` | string | Operation ID for restore. | +| `--confirm-drift` | boolean | Explicitly allow restore to replace subsequent edits. | +| `--overwrite-conflict` | boolean | Explicitly allow enable to replace a conflicting provider block. | +| `--json` | boolean | Emit the profile state, history, or mutation result as JSON. | JSON mode: `payload`. -- A bare invocation reads and never writes. +- Use status/show/list, history/journal, enable/disable, or restore after integration client. +- These declarations cover the dedicated Aside profile paths; existing generic client routes retain their separate parity inventory. -### `ocx agent roles` +### `ocx sync` -Show, replace, or remove subagent roles. +Synchronize client catalogs, including Aside profiles through the running server's mutation owner. | Method | Route | |---|---| -| GET | `/api/subagent-roles` | -| PUT | `/api/subagent-roles` | +| POST | `/api/client-integrations/aside/sync` | | Flag | Value | Meaning | |---|---|---| -| `--file` | string | Read role JSON from a file instead of stdin. | -| `--json` | boolean | Emit role state as JSON. | +| `--restart-codex` | boolean | Restart Codex app-servers after a catalog or cache write. | +| `--restart-desktop-app` | boolean | Restart the Codex desktop app after a catalog or cache write. | -JSON mode: `payload`. +JSON mode: `none`. -- A status invocation reads and never writes. +- The Aside refresh uses the live server; other catalog synchronization also performs local work. -### `ocx agent authority` +### `ocx agent request-user-input` -Resolve subagent model authority for a supplied request. +Show or set whether default mode may ask the operator a question mid-task. | Method | Route | |---|---| -| POST | `/api/subagent-model-authority` | - -| Flag | Value | Meaning | -|---|---|---| -| `--file` | string | Read authority JSON from a file instead of stdin. | - -JSON mode: `none`. - -### `ocx lab run` - -Enqueue a manual Lab run and optionally pair a stored Cursor oracle observation. - -Drives no management route. - -| Flag | Value | Meaning | -|---|---|---| -| `--layer` | string | protocol_conformance | live_route_compatibility | task_effectiveness | -| `--scenario` | string | Scenario id | -| `--provider` | string | Optional provider filter | -| `--model` | string | Model id | -| `--oracle-run` | string | Stored oracle run id; scenario and model must match | -| `--json` | boolean | Emit {run, oracle?, comparison?} envelope as JSON | - -JSON mode: `envelope`. - -- Reads local projection, validates an immutable sanitized oracle sidecar when supplied, then enqueues the manual run. - -### `ocx lab oracle cursor` - -Cursor oracle probe: isolated working state and loopback-only sanitized observation V1. - -Drives no management route. +| GET | `/api/codex-auth/features/default-mode-request-user-input` | +| PUT | `/api/codex-auth/features/default-mode-request-user-input` | | Flag | Value | Meaning | |---|---|---| -| `--scenario` | string | Lab scenario id | -| `--model` | string | Model id for oracle prompt | -| `--agent-bin` | string | Path to cursor-agent binary | -| `--keep-raw` | boolean | Persist raw bytes 0600 under lab scratch 24h TTL; without it only names + byte lengths are kept | -| `--json` | boolean | Emit sanitized observation V1 as JSON | +| `--json` | boolean | Emit the feature state as JSON. | -JSON mode: `envelope`. +JSON mode: `payload`. -- Config/data/workspace use OS tmp 0700 while the authenticated child retains normal home/keychain access; loopback 127.0.0.1:0 forwards only to https://api2.cursor.sh; auth bodies are opaque; sanitized observations contain protocol cases, counts, byte lengths, hashes, and diagnostics. +- A bare invocation reads and never writes. ## Counts -- declared capabilities: 38 -- of those, state-changing: 18 +- declared capabilities: 35 +- of those, state-changing: 15 - head-resolved invocations: 2 diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 4f48d2bfd7..424ad34a53 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -1,6 +1,7 @@ # Recipes -Each sequence below was run against a live proxy. Every command named here exists; where the +The original sequences below were run against a live proxy; the Aside profile sequence was +verified through an isolated live management handler and the production CLI. Every command named here exists; where the obvious-sounding command does *not* exist, that is called out rather than left as a trap. Preflight for all of them: @@ -93,20 +94,61 @@ Read `accounts[]`. Two things to respect: `providers[]` and `models[]` carry `estimatedCostUsd`. Costs are estimates; `estimateReasons` in the log rows tells you why (for example `usage_estimated`, `expected_price_overlay`). -## 5. Rotate an access key and confirm it went quiet +## 5. Prepare an access-key rotation without exposing the new key ```bash ocx access key list --json -ocx access key create rotated --json # the plaintext key is in THIS response only +``` + +Creating a key or starting a rotation returns a one-time plaintext credential in both text and +JSON output. **Do not perform either operation in an agent session**, including through the +aliases, executable wrappers, or management POST routes named in +[Secret-bearing commands](../SKILL.md#secret-bearing-commands). Ask the user to perform that step +in a terminal outside the agent session, configure and verify the replacement, and report only +configuration confirmation and the non-secret key/rotation IDs. Never ask for the key itself. + +Configuration confirmation is not revocation approval. Identify the existing key ID and obtain +separate explicit revocation approval before taking either path below. An existing explicit +approval for that exact revocation remains valid; do not ask again for the same action and ID. + +For an in-place rotation, commit the pending replacement on the same ID: + +```bash +ocx access key rotate commit --json +``` + +For a separately created replacement, remove only the old ID: + +```bash ocx access key remove --yes --json -ocx access key list --json # the old id is gone; check usage on the rest ``` -Note the argument style: `create ` and `remove ` are **positionals**, not `--label` and -`--id`. `remove` also refuses without `--yes`. +After the command succeeds, inspect the matching result: + +```bash +ocx access key list --json +``` + +For an in-place rotation, the same ID remains and `pendingRotation` disappears. For a separately +created replacement, the old ID disappears. The list alone does not prove the replacement accepts +traffic; use the user's successful connection verification as that evidence. `remove ` is +positional, not `--id`, and refuses without `--yes`. + +To cancel a pending rotation, with authority to discard the replacement: + +```bash +ocx access key rotate abort --json +``` + +Abort retains the old credential and removes the pending replacement. Re-list to inspect pending +state. On stale, mismatched, or expired rotation IDs, or an uncertain commit result, inspect +non-secret state and report the refusal or uncertainty. Do not start another rotation, delete the +entry, or retrieve a secret as automatic recovery. Missing pending state alone is not proof of a +successful commit: expiry and abort also clear it. -The list carries per-key usage, so a key whose count stops advancing is genuinely unused. The -plaintext key appears once, in the `create` response, and is never retrievable again. +The list carries per-key usage. A count that stops advancing shows no recorded new usage in that +observation window; it does not prove no client still needs the key. Creation and rotation-start +return the plaintext once; list does not return the full plaintext. An `ambiguous` footer on the list means two configured keys share an id, so per-key totals do not exist for them — do not attribute usage to either. @@ -205,3 +247,23 @@ Two absences are also expected and are not defects: provider sets `liveModels: false` deliberately — its authenticated roster includes image and voice models this Responses-agent provider cannot drive — so the absence of a live probe is a design decision, not a broken connection. + +## Aside profiles + +These commands and the Aside refresh in `ocx sync` require a compatible running ocx proxy. +There is no local profile-file fallback when the server is unavailable or too old. Follow +the [proxy upgrade, restart, and retry sequence](https://opencodex.me/guides/integrations/#aside-profile-controls), +then fully quit and reopen Aside after its profile files update successfully. + +```bash +ocx integration client status --client aside --json +ocx integration client enable --client aside +ocx integration client disable --client aside --profile 1 +ocx integration client history --client aside --profile 1 +ocx integration client restore --client aside --profile 1 --op +``` + +Read `profiles[]` to find numeric profile IDs. No profile selector means a bulk toggle; an +explicit selector affects only that registered profile. Sync intent and actual file state +are distinct, so inspect each result after a partial bulk operation. The CLI returns nonzero +for a partial refusal. Never use the overwrite or drift flags merely to suppress a refusal. diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 46b846d443..0c0596be07 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -114,6 +114,12 @@ The ordering is not ceremony. If the old key died at issuance, a client that had received the new key would be disconnected — and a disconnected client cannot be given a new key. So the contract is: apply the new key, verify the connection, then commit. +Raw access-key creation and rotation-start return plaintext and belong outside the agent +session; follow [recipe 5](03_recipes.md#5-prepare-an-access-key-rotation-without-exposing-the-new-key) +for the human handoff and separate revocation approval. The managed `ocx connect rotate` +flow returns non-secret status and is a distinct command, not permission to invoke the raw +secret-returning endpoint from an agent tool. + The token backup (`.prev`) is not deleted while a rotation is in flight, and commits only once both sides are confirmed to have accepted. diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 04d20a9ac6..c20dc88be6 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -13,8 +13,6 @@ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { parseDataUrl } from "./image"; -import { redactSecretString } from "../lib/redact"; -import { testProviderFetch } from "../lib/test-provider-fetch"; // Retain the short ids emitted by the first local integration. New requests use the live catalog's // provider-native IDs directly; this map is compatibility-only and is not a model fallback list. @@ -29,23 +27,6 @@ function canonicalCommandCodeModelId(modelId: string): string { return Object.hasOwn(COMMAND_CODE_MODEL_ALIASES, modelId) ? COMMAND_CODE_MODEL_ALIASES[modelId]! : modelId; } -/** Surface Command Code's JSON error message to sidecar callers instead of a bare HTTP status. */ -export function formatCommandCodeErrorBody(_status: number, _headers: Headers, payloadText: string): string { - try { - const payload = JSON.parse(payloadText) as unknown; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return ""; - const error = (payload as Record).error; - const message = (error && typeof error === "object" && !Array.isArray(error) - ? (error as Record).message - : undefined) ?? (payload as Record).message; - return typeof message === "string" && message.trim() - ? redactSecretString(message.trim()).slice(0, 400) - : ""; - } catch { - return ""; - } -} - /** Flatten tool-result content for the text-only wire output, keeping an `[image]` marker per image part in content order. */ function toolResultText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; @@ -179,17 +160,13 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] { if (choice === "none") return []; const tools = parsed.context.tools ?? []; if (isAllowedToolChoice(choice)) { - return tools - .filter(toolChoiceToolPredicate(choice, tools)) - .sort((left, right) => namespacedToolName(left.namespace, left.name).localeCompare(namespacedToolName(right.namespace, right.name))); + return tools.filter(toolChoiceToolPredicate(choice, tools)); } if (choice && typeof choice !== "string") { const selected = resolveToolChoiceWireName(tools, choice.name); - return tools - .filter(tool => namespacedToolName(tool.namespace, tool.name) === selected) - .sort((left, right) => namespacedToolName(left.namespace, left.name).localeCompare(namespacedToolName(right.namespace, right.name))); + return tools.filter(tool => namespacedToolName(tool.namespace, tool.name) === selected); } - return [...tools].sort((left, right) => namespacedToolName(left.namespace, left.name).localeCompare(namespacedToolName(right.namespace, right.name))); + return tools; } function toolChoiceInstruction(parsed: OcxParsedRequest): string | undefined { @@ -220,9 +197,6 @@ function currentWorkingDirectory(): string | undefined { /** Cap the workspace listing so a large directory does not ship every entry name upstream. */ const MAX_WORKSPACE_STRUCTURE_ENTRIES = 64; -/** Cap directory entries scanned while selecting the stable workspace prefix. */ -export const MAX_WORKSPACE_STRUCTURE_SCAN_ENTRIES = 4096; -// ponytail: the bounded scan trades complete directory coverage for request latency; raise only with measured need. /** Cap how many recent commit subjects the config carries. */ const MAX_RECENT_COMMITS = 8; /** Cap each recent commit entry to keep the request bounded even for long subjects. */ @@ -239,44 +213,25 @@ function projectSlug(cwd: string): string { return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 64) || "workspace"; } -function firstUserText(parsed: OcxParsedRequest): string | undefined { - for (const msg of parsed.context.messages) { - if (msg.role !== "user") continue; - if (typeof msg.content === "string") return msg.content; - const first = msg.content.find(part => part.type === "text" && typeof part.text === "string"); - if (first && first.type === "text") return first.text; - } - return undefined; -} - export function commandCodeSessionId(parsed: OcxParsedRequest): string { - if (parsed._commandCodeSessionId) return parsed._commandCodeSessionId; - // Shared prompt-cache cohorts intentionally do not identify one conversation. Keep them out - // of upstream session affinity or unrelated conversations can pin to the same worker. + // Shared prompt-cache cohorts identify a cache population, not one conversation. Using one + // for session affinity would pin unrelated conversations to the same upstream worker. const threadId = parsed._clientThreadId?.trim(); const replayId = parsed._reasoningReplayScope?.clientThreadId?.trim(); - const cursorId = parsed._cursorConversationId?.trim(); - const cacheKey = !parsed._promptCacheKeyIsSharedCohort ? parsed.options.promptCacheKey?.trim() : undefined; - const rootText = firstUserText(parsed); + const cacheKey = parsed._promptCacheKeyIsSharedCohort === false + ? parsed.options.promptCacheKey?.trim() + : undefined; const identity = threadId ? ["thread", threadId] : replayId ? ["replay", replayId] - : cursorId - ? ["cursor", cursorId] - : cacheKey - ? ["cache", cacheKey] - : rootText - ? ["root", rootText] - : undefined; - const sessionId = !identity - ? randomUUID() - : (() => { - const hex = createHash("sha256").update(`command-code:${identity[0]}\0${identity[1]}`).digest("hex"); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; - })(); - parsed._commandCodeSessionId = sessionId; - return sessionId; + : cacheKey + ? ["cache", cacheKey] + : undefined; + if (!identity) return randomUUID(); + const hex = createHash("sha256").update(`command-code:${identity[0]}\0${identity[1]}`).digest("hex"); + // Replace the digest nibbles at the UUID version and variant positions; the skipped hex characters are intentional. + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; } interface GitWorkspaceInfo { @@ -288,8 +243,6 @@ interface GitWorkspaceInfo { } export const workspaceMetadataCache = new Map(); -export const workspaceConfigCache = new Map; sessionId?: string }>(); -export const SESSION_WORKSPACE_CONFIG_TTL_MS = 60 * 60_000; /** * Evict expired entries first, then the oldest live entry if at capacity. @@ -316,34 +269,6 @@ export function pruneWorkspaceMetadataCache(now: number): void { } } -export function pruneWorkspaceConfigCache(now: number): void { - for (const [key, entry] of workspaceConfigCache) { - if (!entry.sessionId && now - entry.collectedAt >= WORKSPACE_METADATA_TTL_MS) { - workspaceConfigCache.delete(key); - } - } - if (workspaceConfigCache.size >= MAX_WORKSPACE_METADATA_ENTRIES) { - let oldestKey: string | null = null; - let oldestAt = Infinity; - for (const [key, entry] of workspaceConfigCache) { - if (entry.sessionId) continue; - if (entry.collectedAt < oldestAt) { - oldestAt = entry.collectedAt; - oldestKey = key; - } - } - if (oldestKey === null) { - for (const [key, entry] of workspaceConfigCache) { - if (entry.collectedAt < oldestAt) { - oldestAt = entry.collectedAt; - oldestKey = key; - } - } - } - if (oldestKey !== null) workspaceConfigCache.delete(oldestKey); - } -} - const execFile = promisify(execFileCallback); /** Best-effort git metadata for the upstream config contract; every read fails safe and stays off the event loop. */ @@ -380,53 +305,32 @@ async function gitWorkspaceInfo(cwd: string | undefined): Promise> { - const cacheKey = sessionId ? `${sessionId}:${cwd ?? ""}` : (cwd ?? ""); - const now = Date.now(); - const cached = cacheKey ? workspaceConfigCache.get(cacheKey) : undefined; - if (cacheKey) { - if (cached && (sessionId || now - cached.collectedAt < WORKSPACE_METADATA_TTL_MS)) return cached.value; - } +async function commandCodeConfig(cwd: string | undefined): Promise> { let structure: string[] = []; if (cwd) { try { - // Keep only the lexicographically smallest entries within the bounded scan so filesystem - // enumeration order cannot change the selected prefix for the scanned portion. + // Iterate and stop after the cap instead of materializing every entry: a directory with a + // huge number of names must not stall the request path for 64 metadata rows. const dir = await opendir(cwd); try { - const entries = dir[Symbol.asyncIterator](); - for (let scanned = 0; scanned < MAX_WORKSPACE_STRUCTURE_SCAN_ENTRIES; scanned += 1) { - const next = await entries.next(); - if (next.done) break; - const entry = next.value; + for await (const entry of dir) { if (entry.name.startsWith(".")) continue; structure.push(entry.name); - if (structure.length > MAX_WORKSPACE_STRUCTURE_ENTRIES) { - structure.sort(); - structure.pop(); - } + if (structure.length >= MAX_WORKSPACE_STRUCTURE_ENTRIES) break; } } finally { await dir.close().catch(() => undefined); } } catch { /* workspace metadata is optional */ } } - structure.sort(); const git = await gitWorkspaceInfo(cwd); - const value = { + return { ...(cwd ? { workingDir: cwd } : {}), - date: sessionId && typeof cached?.value.date === "string" - ? cached.value.date - : new Date(now).toISOString().slice(0, 10), + date: new Date().toISOString().slice(0, 10), environment: process.platform, structure, ...git, }; - if (cacheKey) { - if (!workspaceConfigCache.has(cacheKey)) pruneWorkspaceConfigCache(now); - workspaceConfigCache.set(cacheKey, { collectedAt: now, value, ...(sessionId ? { sessionId } : {}) }); - } - return value; } function usage(value: unknown): OcxUsage | undefined { @@ -603,14 +507,12 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string } export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderAdapter { - const executor = testProviderFetch(provider) ?? globalThis.fetch; + const executor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; return { name: "command-code", - formatErrorBody: formatCommandCodeErrorBody, async buildRequest(parsed: OcxParsedRequest): Promise { if (!provider.apiKey) throw new Error("Command Code credential missing — run ocx login command-code"); const cwd = currentWorkingDirectory(); - const sessionId = commandCodeSessionId(parsed); const tools = visibleTools(parsed); const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice); const choiceInstruction = toolChoiceInstruction(parsed); @@ -621,7 +523,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA ].join("\n\n"), parsed.modelId); const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning); const body = { - config: await commandCodeConfig(cwd, sessionId), memory: "", taste: null, skills: null, + config: await commandCodeConfig(cwd), memory: "", taste: null, skills: null, permissionMode: "standard", mode: "agent", params: { model: canonicalCommandCodeModelId(parsed.modelId), @@ -644,7 +546,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA "x-cli-environment": "production", "x-taste-learning": "false", "x-co-flag": "false", - "x-session-id": sessionId, + "x-session-id": commandCodeSessionId(parsed), }; if (cwd) headers["x-project-slug"] = projectSlug(cwd); return { diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index 7a02b170e5..d702fc1e43 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -148,6 +148,10 @@ let blobOldestEvictableAt: number | null = null; let rejectedEntryTooLarge = 0; let rejectedPinnedSaturation = 0; let blobExpiryAccountingTimer: ReturnType | undefined; +/** Earliest unpinned storedAt+ttl; skip the write-time TTL walk while this is in the future. */ +let blobNextUnpinnedExpiryAt: number | null = null; +/** Earliest unpinned remote expiry strictly in the future; drives the single reclassify timer. */ +let blobNextRemoteExpiryAt: number | null = null; function isExpired(entry: CursorBlobEntry, now: number): boolean { return now - entry.storedAt >= blobLimits.ttlMs; @@ -165,6 +169,8 @@ function recomputeBlobClassAccounting(): void { let pinnedBytes = 0; let evictableBytes = 0; let oldestAt: number | null = null; + let nextUnpinnedExpiry = Number.POSITIVE_INFINITY; + let nextRemoteExpiry = Number.POSITIVE_INFINITY; for (const [k, entry] of blobs) { const requestPinned = entry.requestPins.size > 0; const provenancePinned = entry.provenance === "remote-setBlobArgs" && !isExpired(entry, now); @@ -177,11 +183,20 @@ function recomputeBlobClassAccounting(): void { evictableBytes += entry.sizeBytes + k.length; oldestAt = oldestAt === null ? entry.storedAt : Math.min(oldestAt, entry.storedAt); } + if (!requestPinned) { + const expiresAt = entry.storedAt + blobLimits.ttlMs; + nextUnpinnedExpiry = Math.min(nextUnpinnedExpiry, expiresAt); + if (entry.provenance === "remote-setBlobArgs" && expiresAt > now) { + nextRemoteExpiry = Math.min(nextRemoteExpiry, expiresAt); + } + } } blobLocalBytes = localBytes; blobPinnedBytes = pinnedBytes; blobEvictableBytes = evictableBytes; blobOldestEvictableAt = oldestAt; + blobNextUnpinnedExpiryAt = Number.isFinite(nextUnpinnedExpiry) ? nextUnpinnedExpiry : null; + blobNextRemoteExpiryAt = Number.isFinite(nextRemoteExpiry) ? nextRemoteExpiry : null; scheduleBlobExpiryAccounting(now); } @@ -193,13 +208,8 @@ function reconcileBlobClassAccountingAndEnforce(): void { function scheduleBlobExpiryAccounting(now: number): void { if (blobExpiryAccountingTimer) clearTimeout(blobExpiryAccountingTimer); blobExpiryAccountingTimer = undefined; - let nextExpiry = Number.POSITIVE_INFINITY; - for (const entry of blobs.values()) { - if (entry.provenance !== "remote-setBlobArgs" || entry.requestPins.size > 0) continue; - const expiresAt = entry.storedAt + blobLimits.ttlMs; - if (expiresAt > now) nextExpiry = Math.min(nextExpiry, expiresAt); - } - if (!Number.isFinite(nextExpiry)) return; + const nextExpiry = blobNextRemoteExpiryAt; + if (nextExpiry === null || nextExpiry <= now || !Number.isFinite(nextExpiry)) return; blobExpiryAccountingTimer = setTimeout(() => { blobExpiryAccountingTimer = undefined; reconcileBlobClassAccountingAndEnforce(); @@ -207,6 +217,41 @@ function scheduleBlobExpiryAccounting(now: number): void { blobExpiryAccountingTimer.unref?.(); } +/** + * O(1) class/timer update for a newly admitted key when no other row changed. + * Full-map recompute stays on replacement, eviction, pin changes, and TTL fire — + * the 4096-entry ceiling fill must not walk the store on every remote admit. + */ +function accountAdmittedBlob(k: string, entry: CursorBlobEntry, now: number): void { + const requestPinned = entry.requestPins.size > 0; + const expired = isExpired(entry, now); + const provenancePinned = entry.provenance === "remote-setBlobArgs" && !expired; + const logicalBytes = entry.sizeBytes + k.length; + if (entry.provenance === "local-regenerated") blobLocalBytes += entry.sizeBytes; + if (requestPinned || provenancePinned) blobPinnedBytes += logicalBytes; + if (!requestPinned && (entry.provenance === "local-regenerated" || expired)) { + blobEvictableBytes += logicalBytes; + blobOldestEvictableAt = blobOldestEvictableAt === null ? entry.storedAt : Math.min(blobOldestEvictableAt, entry.storedAt); + } + if (requestPinned) return; + const expiresAt = entry.storedAt + blobLimits.ttlMs; + blobNextUnpinnedExpiryAt = blobNextUnpinnedExpiryAt === null + ? expiresAt + : Math.min(blobNextUnpinnedExpiryAt, expiresAt); + if (entry.provenance !== "remote-setBlobArgs" || expiresAt <= now) return; + const previousRemoteExpiry = blobNextRemoteExpiryAt; + blobNextRemoteExpiryAt = previousRemoteExpiry === null + ? expiresAt + : Math.min(previousRemoteExpiry, expiresAt); + if ( + !blobExpiryAccountingTimer + || previousRemoteExpiry === null + || expiresAt < previousRemoteExpiry + ) { + scheduleBlobExpiryAccounting(now); + } +} + function deleteBlob(k: string, recompute = true): number { const entry = blobs.get(k); if (!entry) return 0; @@ -254,9 +299,11 @@ function setBlob( } const removals = new Set(); - for (const [candidateKey, entry] of blobs) { - if (candidateKey === k && sameData) continue; - if (entry.requestPins.size === 0 && isExpired(entry, now)) removals.add(candidateKey); + if (blobNextUnpinnedExpiryAt !== null && now >= blobNextUnpinnedExpiryAt) { + for (const [candidateKey, entry] of blobs) { + if (candidateKey === k && sameData) continue; + if (entry.requestPins.size === 0 && isExpired(entry, now)) removals.add(candidateKey); + } } const existingRemovedByTtl = existing !== undefined && removals.has(k); @@ -334,7 +381,12 @@ function setBlob( blobBytes += entry.sizeBytes; blobKeyBytes += k.length; for (const scope of entry.requestPins) blobRequestScopes.get(scope)?.keys.add(k); - reconcileBlobClassAccountingAndEnforce(); + if (removals.size > 0 || existing !== undefined) { + reconcileBlobClassAccountingAndEnforce(); + } else { + accountAdmittedBlob(k, entry, now); + enforceAppOwnedMemoryBudget(); + } return { admitted: true, replaced: existing !== undefined }; } diff --git a/src/adapters/cursor/tool-schemas.ts b/src/adapters/cursor/tool-schemas.ts index 7415c996da..cbd6b0b300 100644 --- a/src/adapters/cursor/tool-schemas.ts +++ b/src/adapters/cursor/tool-schemas.ts @@ -22,24 +22,42 @@ export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = { prefix_rule: { type: "array", items: { type: "string" }, - description: "Reusable approval prefix for cmd, only with sandbox_permissions: require_escalated; for example [\"git\", \"pull\"].", + description: "Reusable approval prefix for cmd, only with sandbox_permissions: require_escalated.", }, login: { type: "boolean", - description: "True runs the shell with -l/-i semantics; false disables them. Defaults to true.", + description: "True runs the shell with login semantics; false disables them. Defaults to true.", }, }, required: ["cmd"], additionalProperties: false, } as const; -/** Cursor requires freeform custom tools to advertise their body as one string input. */ +/** Cursor represents a Responses freeform tool body as one string-valued input field. */ export const CURSOR_FREEFORM_INPUT_SCHEMA = { type: "object", properties: { input: { type: "string" } }, required: ["input"], + additionalProperties: false, } as const; +function cursorFreeformInputSchema(tool: OcxTool): unknown { + const properties = tool.parameters?.properties; + const input = properties && typeof properties === "object" && !Array.isArray(properties) + ? (properties as Record).input + : undefined; + const description = input && typeof input === "object" && !Array.isArray(input) + ? (input as Record).description + : undefined; + if (typeof description !== "string") return CURSOR_FREEFORM_INPUT_SCHEMA; + return { + ...CURSOR_FREEFORM_INPUT_SCHEMA, + properties: { + input: { ...CURSOR_FREEFORM_INPUT_SCHEMA.properties.input, description }, + }, + }; +} + /** * Structured single-replacement schema advertised to Cursor models in addition to the freeform * `apply_patch` tool. Cursor-trained models reliably emit exact-match replacements (the native @@ -96,9 +114,9 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = { yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." }, max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." }, max_output_chars: { type: "number", description: "Output character budget when the Responses tool uses chars instead of tokens." }, - sandbox_permissions: { type: "string" }, + sandbox_permissions: { type: "string", enum: ["use_default", "require_escalated"] }, justification: { type: "string" }, - prefix_rule: { type: "array" }, + prefix_rule: { type: "array", items: { type: "string" } }, login: { type: "boolean" }, }, required: ["command"], @@ -107,7 +125,12 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = { /** Schema advertised to Cursor for this tool (may use Cursor-preferred field names like `cmd`). */ export function cursorToolInputSchema(tool: OcxTool): unknown { - if (tool.freeform) return CURSOR_FREEFORM_INPUT_SCHEMA; + if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return cursorFreeformInputSchema(tool); + } return isBareCodexExecCommandTool(tool) ? CURSOR_EXEC_COMMAND_INPUT_SCHEMA : (tool.parameters ?? {}); } @@ -117,7 +140,12 @@ export function cursorToolInputSchema(tool: OcxTool): unknown { * treating `cmd` as canonical prevents the `cmd` → `command` rewrite Codex requires (#399). */ export function cursorToolArgNormalizeSchema(tool: OcxTool): unknown { - if (tool.freeform) return CURSOR_FREEFORM_INPUT_SCHEMA; + if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return cursorFreeformInputSchema(tool); + } if (isBareCodexShellBridgeTool(tool)) { return shellBridgeArgNormalizeSchema(tool); } diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index fb83572d35..200b1edb77 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -44,7 +44,7 @@ import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-i import { sniffImageDimensions } from "./anthropic-image-guard"; import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry"; import { convertKiroToolContext } from "./kiro-tools"; -import { normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize"; +import { EMPTY_EXEC_OUTPUT_MESSAGE, normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize"; import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge"; import { @@ -663,7 +663,7 @@ export function buildKiroPayload( } const systemPrefix = systemParts.length > 0 ? `${systemParts.join("\n\n")}\n\n` : ""; const turns: KiroTurn[] = []; - const priorCalls = new Map(); + const priorCalls = new Map(); const pushUser = (content: string, images: KiroImage[] = [], toolResults: KiroToolResult[] = []): void => { const last = turns.at(-1); if (last?.kind === "user") { @@ -695,7 +695,27 @@ export function buildKiroPayload( } }; + let adjacentResult: { + rawId: string; + result: KiroToolResult; + texts: string[]; + count: number; + hasImages: boolean; + } | undefined; + const finishAdjacentResult = (): void => { + if (adjacentResult && adjacentResult.count > 1) { + if (adjacentResult.texts.some(text => text.trim())) { + adjacentResult.result.content = adjacentResult.texts.map(text => ({ text })); + } else if (adjacentResult.hasImages || adjacentResult.result.status === "error") { + adjacentResult.result.content = [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }]; + } + } + adjacentResult = undefined; + }; + for (const msg of kiroPayloadMessages(parsed)) { + // Original-message adjacency matters even when a turn is collapsed or skipped below. + if (msg.role !== "toolResult") finishAdjacentResult(); if (msg.role === "user" || msg.role === "developer") { const text = userContentText((msg as { content: string | OcxContentPart[] }).content); const images = extractKiroImages((msg as { content: string | OcxContentPart[] }).content); @@ -714,7 +734,7 @@ export function buildKiroPayload( if (priorCalls.has(toolUseId)) throw new Error(`Kiro history contains duplicate tool call id ${JSON.stringify(tc.id)}`); const wireName = namespacedToolName(tc.namespace, tc.name); const name = registry.alias(wireName); - priorCalls.set(toolUseId, { wireName }); + priorCalls.set(toolUseId, { wireName, rawId: tc.id }); return { name, input: (tc.arguments ?? {}) as Record, toolUseId }; }); if (!text && toolUses.length === 0) { @@ -735,26 +755,52 @@ export function buildKiroPayload( // the task instead of calling text()/notify(). Checked before `text.trim()` because the // wrapper form ("Script completed\nWall time ...\nOutput:\n") is non-blank and would // otherwise pass through as if it were real output. - const resultText = normalizeEmptyExecToolResultText(text, { + const normalizedExecText = normalizeEmptyExecToolResultText(text, { toolName: tr.toolName, toolNamespace: tr.toolNamespace, - }) ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE); + }); + const resultText = normalizedExecText ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE); const images = extractKiroImages(tr.content); const toolUseId = normalizeToolId(tr.toolCallId); - if (!priorCalls.has(toolUseId)) { + const call = priorCalls.get(toolUseId); + if (!call || call.rawId !== tr.toolCallId) { throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`); } + // Keep real whitespace and failed wrappers, but no empty-success wrapper boilerplate. + const rawGroupText = text.length > 0 && (!text.trim() || normalizedExecText !== EMPTY_EXEC_OUTPUT_MESSAGE) + ? text : undefined; + const last = turns.at(-1); + if ( + adjacentResult?.rawId === tr.toolCallId + && last?.kind === "user" + && last.toolResults.at(-1) === adjacentResult.result + ) { + adjacentResult.count += 1; + adjacentResult.hasImages ||= images.length > 0; + if (rawGroupText !== undefined) adjacentResult.texts.push(rawGroupText); + last.images.push(...images); + if (tr.isError) adjacentResult.result.status = "error"; + continue; + } + finishAdjacentResult(); // Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix. // Passing it here would push proxy filler AHEAD of a human instruction that Claude Code // sends in the same turn (mid-turn steering / queued_command, issue #543), burying the // newest user intent behind boilerplate. Backfill below only when nothing else speaks. - pushUser("", images, [{ + const result: KiroToolResult = { content: [{ text: resultText }], status: tr.isError ? "error" : "success", toolUseId, - }]); + }; + pushUser("", images, [result]); + adjacentResult = { + rawId: tr.toolCallId, result, + texts: rawGroupText === undefined ? [] : [rawGroupText], + count: 1, hasImages: images.length > 0, + }; } } + finishAdjacentResult(); if (turns.length === 0 || turns[0].kind === "assistant") { turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] }); diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index b97e468ab7..b6284027b6 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1669,6 +1669,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd let bufferBytes = 0; interface PendingToolCall { key: string; + indexKey?: string; id: string; name: string; args: string; @@ -1857,17 +1858,29 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const rawId = rawToolCall.id; const idDelta = typeof rawId === "string" ? rawId : ""; const rawIndex = rawToolCall.index; + // Only missing/null indexes are absent; every claimed index must be valid. + // Unsafe integers can collapse distinct wire indexes onto the same JS number. + // Reject before an alias can bind or any pending call can consume the fragment. + if (rawIndex !== undefined && rawIndex !== null + && (typeof rawIndex !== "number" + || !Number.isSafeInteger(rawIndex) + || rawIndex < 0)) { + return yield* terminateWithError({ + ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), + message: "upstream response contained invalid tool calls (invalid index)", + }); + } - // Resolve the pending call BEFORE judging the fields. Some OpenAI-compatible + // Resolve the pending call BEFORE judging repeated string fields. Some OpenAI-compatible // streamers repeat an already-sent field as a non-string placeholder on a // continuation delta; judging first meant the whole stream died with a 502 even // though the value being repeated was already held in canonical form. - const key = typeof rawIndex === "number" - ? `i:${rawIndex}` - : idDelta - ? `id:${idDelta}` - : pendingToolCalls[pendingToolCalls.length - 1]?.key; + const indexKey = typeof rawIndex === "number" ? `i:${rawIndex}` : undefined; + const key = indexKey ?? (idDelta + ? `id:${idDelta}` + : pendingToolCalls[pendingToolCalls.length - 1]?.key); let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined; + if (!call && indexKey !== undefined) call = pendingToolCalls.find(c => c.indexKey === indexKey); if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta); if (!call) { call = { @@ -1881,6 +1894,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd pendingToolCalls.push(call); budget.openCall(call.key); } + // An ID-only call may learn its index from a later ID+index fragment. Retain that + // alias without changing the key that owns its argument budget. Only the first + // observed index binds: a repeated ID on a different index must not alias both. + if (indexKey !== undefined && call.indexKey === undefined) call.indexKey = indexKey; // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name // says nothing about whether `arguments` was ever sent as a string, so it cannot diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 06c653a304..07c0556d5e 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,3 +1,4 @@ +import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; @@ -47,7 +48,6 @@ export const FORWARD_HEADERS = [ "x-codex-beta-features", "x-codex-installation-id", "x-codex-parent-thread-id", - "x-session-id", "x-codex-turn-metadata", "x-codex-turn-state", "x-codex-window-id", @@ -617,23 +617,6 @@ function isPlainObject(v: unknown): v is Record { /** Codex's reserved client-tool group on Responses Lite; carries no wire prefix. */ const SPARK_RESERVED_FUNCTIONS_NAMESPACE = "functions"; -function isLiteSparkRequestBody(body: unknown): boolean { - if (!isPlainObject(body)) return false; - const model = typeof body.model === "string" ? body.model : ""; - if (!model.includes("codex-spark")) return false; - const input = (body as Record).input; - if (!Array.isArray(input)) return false; - for (const item of input) { - if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue; - for (const tool of item.tools) { - if (isPlainObject(tool) && tool.type === "namespace" && tool.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) { - return true; - } - } - } - return false; -} - /** * Apply the routed provider's real effort ladder to an existing Responses reasoning field. * Native forward requests keep the server-owned native clamp; unknown third-party ladders stay @@ -2368,13 +2351,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let routedCustomToolRepairNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; let convertedRoutedNamespaceToolAliases: Map | undefined; - const canonicalSpark = isCanonicalOpenAiForwardProvider(provider) - && parsed.modelId.includes("codex-spark"); const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); + if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. @@ -2427,7 +2409,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripInternalChatMessageMetadataPassthrough(outBody); outBody = promoteClientLoadedTools(outBody); } - if ((!isCanonicalOpenAiForwardProvider(provider) || canonicalSpark) && !isLiteSparkRequestBody(outBody)) { + if (!isCanonicalOpenAiForwardProvider(provider)) { const rewritten = rewriteRoutedCustomToolsForUpstream( outBody, provider.supportsResponsesCustomTools, @@ -2436,23 +2418,20 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): convertedRoutedCustomToolNames = rewritten.names; routedCustomToolRepairNames = rewritten.repairNames; } - if ((!isCanonicalOpenAiForwardProvider(provider) || canonicalSpark) && !isLiteSparkRequestBody(outBody)) { + if (!isCanonicalOpenAiForwardProvider(provider)) { // Run after custom-tool lowering so the search compatibility layer can choose a // collision-free public function name against the final routed function catalog. const rewritten = rewriteRoutedToolSearchForUpstream(outBody); outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; } - if ((!isCanonicalOpenAiForwardProvider(provider) || canonicalSpark) && !isLiteSparkRequestBody(outBody)) { + if (!isCanonicalOpenAiForwardProvider(provider)) { // Codex 0.147 emits private namespace tool groups, while public/third-party Responses - // gateways accept only flat tool variants. Spark keeps the reserved `functions` group intact - // (#3217) via stripSparkCompatibility, so routed namespace lowering is skipped for spark. - // lowering so namespace children already carry their final public kind before promotion. - const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); + // gateways accept only flat tool variants. Run after custom/tool-search lowering so + // namespace children already carry their final public kind before they are promoted. + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; - } - if (!isCanonicalOpenAiForwardProvider(provider)) { // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the // generic capability fallback removes the private OpenAI fields. outBody = normalizeXaiResponsesWebSearch(outBody, provider); @@ -2567,6 +2546,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let snapshot = ""; let usage: OcxUsage | undefined; let compactionEncryptedContent: string | undefined; + let completedSeen = false; for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) { let payload: unknown; try { payload = JSON.parse(event.data); } catch { continue; } @@ -2601,6 +2581,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): return; case "response.completed": { + completedSeen = true; const responsePayload = isPlainObject(payload.response) ? payload.response : undefined; const output = Array.isArray(responsePayload?.output) ? responsePayload.output : []; const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); @@ -2641,6 +2622,18 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } break; } + // Buffered text is still upstream progress, but gateway keepalives are not. + // Yield after accounting, directly to the consumer: no progress queue or content leak. + if ( + !completedSeen + && (payload.type === "response.output_text.delta" + || payload.type === "response.reasoning_summary_text.delta" + || payload.type === "response.reasoning_text.delta") + && typeof payload.delta === "string" + && payload.delta.length > 0 + ) { + yield { type: "heartbeat" }; + } } // Gateways differ in which of these they emit; prefer the authoritative // completed snapshot so text is never double-counted. diff --git a/src/adapters/opencode-go.ts b/src/adapters/opencode-go.ts new file mode 100644 index 0000000000..94055a292a --- /dev/null +++ b/src/adapters/opencode-go.ts @@ -0,0 +1,35 @@ +/** Match the Go destination, including user-renamed provider entries. */ +export function isOpenCodeGo(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1"; + } catch { return false; } +} + +/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */ +export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + if (!Array.isArray(record.input)) return body; + let changed = false; + const input = record.input.map((item: unknown) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const message = item as Record; + if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item; + // Genuine ciphertext and unknown part types must retain their existing fail-closed path. + if (!message.content.every(part => part && typeof part === "object" + && ["input_text", "input_image", "input_file"].includes(part.type))) return item; + const identities = Object.fromEntries(["author", "recipient"] + .filter(key => typeof message[key] === "string") + .map(key => [key, message[key]])); + changed = true; + return { + type: "message", role: "user", + content: [ + ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), + ...message.content, + ], + }; + }); + return changed ? { ...record, input } : body; +} diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 03e133bb90..e69d03f499 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -8,7 +8,11 @@ type Rec = Record; import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder"; -import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget"; +import { + isTranslatorBudgetExceededError, + type TranslatorBudget, + type TranslatorTransientReservation, +} from "../lib/translator-budget"; import { classifyError, cyberPolicyErrorType, @@ -156,6 +160,14 @@ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: st return nextBytes; } +function refusalTranslationError(): ChatCompletionsStreamError { + // Never include provider-controlled refusal text or correlation IDs in diagnostics. + return new ChatCompletionsStreamError("upstream refusal representations are inconsistent", { + type: "upstream_error", + code: "invalid_refusal", + }); +} + /** * Streaming: Responses SSE bytes -> Chat Completions SSE bytes. */ @@ -188,6 +200,125 @@ export function responsesSseToChatCompletionsSse( let emittedFrames = 0; let stepping = false; let decoderStarted = false; + // Raw output/content positions are the ordering authority; IDs only constrain identity. + // Charge a fixed entry allowance as well as keys/IDs so empty parts remain bounded. + const refusalEntryBytes = 64; + const refusalItems = new Map; + }>(); + const refusalIndexById = new Map(); + let refusalMetadataBytes = 0; + let refusalTextBytes = 0; + const releaseRefusals = () => { + refusalItems.clear(); + refusalIndexById.clear(); + translatorBudget.releaseRetained(refusalMetadataBytes, { kind: "item_ids" }); + translatorBudget.releaseRetained(refusalTextBytes, { kind: "retained_collectors" }); + refusalMetadataBytes = 0; + refusalTextBytes = 0; + }; + const position = (value: unknown): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw refusalTranslationError(); + } + return value; + }; + const chargeRefusalMetadata = (bytes: number) => { + translatorBudget.chargeRetained(bytes, { kind: "item_ids" }); + refusalMetadataBytes += bytes; + }; + const refusalItem = (outputIndex: unknown, source: Rec, idField: string) => { + const index = position(outputIndex); + const hasId = Object.hasOwn(source, idField); + const candidate = source[idField]; + if (hasId && typeof candidate !== "string") throw refusalTranslationError(); + let item = refusalItems.get(index); + if (!item) { + chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index))); + item = { parts: new Map() }; + refusalItems.set(index, item); + } + if (hasId && typeof candidate === "string") { + const knownIndex = refusalIndexById.get(candidate); + if (knownIndex !== undefined && knownIndex !== index) throw refusalTranslationError(); + if (item.id !== undefined && item.id !== candidate) throw refusalTranslationError(); + if (item.id === undefined) { + chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(candidate)); + item.id = candidate; + refusalIndexById.set(candidate, index); + } + } + return item; + }; + const retainRefusal = (outputIndex: unknown, contentIndex: unknown, source: Rec, + idField: string, evidence: Rec, field: string, delta = false) => { + const item = refusalItem(outputIndex, source, idField); + const index = position(contentIndex); + let part = item.parts.get(index); + if (!part) { + chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index))); + part = { text: "", bytes: 0, present: false }; + item.parts.set(index, part); + } + if (!Object.hasOwn(evidence, field)) { + if (delta) throw refusalTranslationError(); + return; + } + const candidate = evidence[field]; + if (typeof candidate !== "string") throw refusalTranslationError(); + part.present = true; + if (!delta) { + // Equal, empty, and stale-prefix snapshots add no evidence; never erase deltas. + if (part.text.startsWith(candidate)) return; + if (!candidate.startsWith(part.text)) throw refusalTranslationError(); + } + const nextBytes = delta ? appendedUtf8Bytes(part.text, part.bytes, candidate) : Buffer.byteLength(candidate); + const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "retained_collectors" }); + try { + const next = delta ? part.text + candidate : candidate; + reservation.commitRetained(); + translatorBudget.releaseRetained(part.bytes, { kind: "retained_collectors" }); + refusalTextBytes += nextBytes - part.bytes; + part.text = next; + part.bytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } + }; + const snapshotRefusalItem = (outputIndex: unknown, item: Rec) => { + const existing = typeof outputIndex === "number" ? refusalItems.get(outputIndex) : undefined; + // Sparse final snapshots may omit type/content but cannot change a known ID. + if (Object.hasOwn(item, "id") + && (existing || (typeof item.id === "string" && refusalIndexById.has(item.id)))) { + refusalItem(outputIndex, item, "id"); + } + if (item.type !== "message") { + if (existing && existing.parts.size > 0 && item.type !== undefined) throw refusalTranslationError(); + return; + } + // Unrelated sparse text messages historically need no position metadata. + if (outputIndex === undefined && (!Array.isArray(item.content) + || !item.content.some(part => isRec(part) && part.type === "refusal"))) return; + const known = refusalItem(outputIndex, item, "id"); + if (!Array.isArray(item.content)) return; + item.content.forEach((part: unknown, contentIndex: number) => { + if (!isRec(part)) return; + if (part.type === "refusal") { + retainRefusal(outputIndex, contentIndex, item, "id", part, "refusal"); + } else if (part.type !== undefined && known.parts.has(contentIndex)) { + throw refusalTranslationError(); + } + }); + }; + const snapshotRefusals = (response: Rec) => { + if (!Array.isArray(response.output)) return; + response.output.forEach((item: unknown, outputIndex: number) => { + if (isRec(item)) snapshotRefusalItem(outputIndex, item); + }); + }; + let terminalBatch: Array<{ frame: Uint8Array; reservation: TranslatorTransientReservation }> | undefined; const queuedLiveFrameBytes: number[] = []; const enqueueLiveFrame = (frame: Uint8Array) => { const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" }); @@ -235,8 +366,20 @@ export function responsesSseToChatCompletionsSse( }; const emit = (payload: Rec | "[DONE]") => { if (failed) return; - enqueueLiveFrame(encoder.encode(dataFrame(payload))); - emittedFrames++; + if (terminalBatch) { + const serialized = dataFrame(payload); + const stringReservation = translatorBudget.reserveTransient(Buffer.byteLength(serialized), { kind: "live_transient" }); + try { + const frame = encoder.encode(serialized); + const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" }); + terminalBatch.push({ frame, reservation }); + } finally { + stringReservation.release(); + } + } else { + enqueueLiveFrame(encoder.encode(dataFrame(payload))); + emittedFrames++; + } }; const ensureRole = () => { if (started) return; @@ -298,38 +441,68 @@ export function responsesSseToChatCompletionsSse( }; const finish = (finishReason: string, usage: unknown) => { if (terminated) return; - // A valid completed/incomplete terminal frame may arrive without output_item.done. - // Preserve any known tool call before emitting its finish reason. - flushPendingToolCalls(); + // Admit every pending role/tool/refusal/finish/DONE frame before exposing any + // of this terminal batch. Serialization and encoded bytes coexist and both count. + const batch: NonNullable = []; + terminalBatch = batch; + try { + flushPendingToolCalls(); + ensureRole(); + for (const [, item] of [...refusalItems.entries()].sort(([a], [b]) => a - b)) { + for (const [, part] of [...item.parts.entries()].sort(([a], [b]) => a - b)) { + if (!part.present) continue; + const refusal = chunkBase(id, model, created); + refusal.choices = [{ index: 0, delta: { refusal: part.text }, finish_reason: null }]; + emit(refusal); + } + } + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }]; + if (usage) frame.usage = chatCompletionsUsage(usage); + emit(frame); + emit("[DONE]"); + } catch (error) { + for (const staged of batch) staged.reservation.release(); + throw error; + } finally { + terminalBatch = undefined; + } + for (const staged of batch) { + controller.enqueue(staged.frame); + staged.reservation.commitRetained(); + queuedLiveFrameBytes.push(staged.frame.byteLength); + emittedFrames++; + } terminated = true; - ensureRole(); - const frame = chunkBase(id, model, created); - frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }]; - if (usage) frame.usage = chatCompletionsUsage(usage); - emit(frame); - emit("[DONE]"); + releaseRefusals(); }; const fail = (message: string, details?: { code?: string | null; type?: string; status?: number }) => { if (terminated) return; terminated = true; failed = true; + releaseRefusals(); + closeToolCalls(); + upstreamAbort.abort(new Error("upstream chat translation failed")); + try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ } // OpenAI-compatible clients need a real error event, not a success completion // that embeds `[error] ...` text followed by a clean [DONE]. // Deliver the error frame then close the stream abnormally (no [DONE]). // Do not controller.error() — that can drop already-enqueued bytes from consumers // like response.text(). - const safeMessage = redactSecretString(message); + const translatorOverflow = details?.code === "translation_buffer_limit"; + const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit" + : details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent" + : redactSecretString(message); const statusHint = details?.status ?? streamErrorStatus(safeMessage); const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage); - const translatorOverflow = details?.code === "translation_buffer_limit"; if (translatorOverflow) { - upstreamAbort.abort(new Error("upstream translation buffer exceeded the safe limit")); - closeToolCalls(); - try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ } classified.code = "translation_buffer_limit"; // Provider-controlled overflow is an upstream failure on every path: // streaming frame, collector, and defensive JSON agree on 502. classified.type = "upstream_error"; + } else if (details?.code === "invalid_refusal") { + classified.code = details.code; + classified.type = "upstream_error"; } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(details?.type); @@ -345,9 +518,9 @@ export function responsesSseToChatCompletionsSse( code: classified.code, }, })); - // The budget is already exhausted. This bounded emergency frame is the sole - // typed overflow closure and therefore cannot reserve from that budget again. - if (translatorOverflow) controller.enqueue(frame); + // These fixed, bounded failures must survive even when decoder-owned input + // still fills the budget. They contain no provider text or IDs. + if (translatorOverflow || details?.code === "invalid_refusal") controller.enqueue(frame); else enqueueLiveFrame(frame); emittedFrames++; } catch { @@ -371,8 +544,29 @@ export function responsesSseToChatCompletionsSse( if (typeof data.delta === "string") emitReasoning(data.delta); break; } + case "response.refusal.delta": + case "response.refusal.done": { + const delta = eventName === "response.refusal.delta"; + retainRefusal(data.output_index, data.content_index, data, "item_id", data, delta ? "delta" : "refusal", delta); + break; + } + case "response.content_part.added": + case "response.content_part.done": { + const part = isRec(data.part) ? data.part : null; + if (part?.type === "refusal") { + retainRefusal(data.output_index, data.content_index, data, "item_id", part, "refusal"); + } else if (typeof data.output_index === "number" && refusalItems.has(data.output_index)) { + const item = refusalItem(data.output_index, data, "item_id"); + if (part?.type !== undefined && item.parts.has(position(data.content_index))) throw refusalTranslationError(); + } + break; + } case "response.output_item.added": { const item = isRec(data.item) ? data.item : null; + if (item?.type === "message") { + snapshotRefusalItem(data.output_index, item); + if (Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id"); + } if (!item || item.type !== "function_call") break; ensureRole(); sawToolUse = true; @@ -416,6 +610,8 @@ export function responsesSseToChatCompletionsSse( case "response.output_item.done": { const item = isRec(data.item) ? data.item : null; if (!item) break; + snapshotRefusalItem(data.output_index, item); + if (item.type === "message" && Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id"); if (item.type === "function_call") { sawToolUse = true; const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -445,6 +641,7 @@ export function responsesSseToChatCompletionsSse( } case "response.completed": { const response = isRec(data.response) ? data.response : {}; + snapshotRefusals(response); finish(sawToolUse ? "tool_calls" : "stop", response.usage); break; } @@ -456,6 +653,7 @@ export function responsesSseToChatCompletionsSse( : undefined; if (reason !== undefined) { // Truthful OpenAI-compatible finish reasons: the turn ended, just early. + snapshotRefusals(response); finish(reason, response.usage); } else { // upstream_stall_timeout / adapter_eof / proxy-synthesized incompletes are @@ -500,6 +698,7 @@ export function responsesSseToChatCompletionsSse( while (!cancelled && emittedFrames === emittedAtStart) { decoderStarted = true; const next = await sseIterator!.next(); + if (cancelled) break; if (next.done) { if (!cancelled && !terminated) { fail("upstream stream ended before a terminal frame (truncated response)"); @@ -525,6 +724,8 @@ export function responsesSseToChatCompletionsSse( upstreamAbort.abort(err); closeToolCalls(); fail(err.message, { status: 502, type: "upstream_error", code: err.code }); + } else if (isChatCompletionsStreamError(err)) { + fail(err.message, { status: err.status, type: err.type, code: err.code }); } else { fail(err instanceof Error ? err.message : String(err)); } @@ -545,6 +746,7 @@ export function responsesSseToChatCompletionsSse( }, cancel(reason) { cancelled = true; + releaseRefusals(); while (queuedLiveFrameBytes.length > 0) releaseDeliveredFrame(); closeToolCalls(); // Abort first: it cancels the decoder's underlying reader, settling any in-flight @@ -560,55 +762,99 @@ export function responsesSseToChatCompletionsSse( } /** Non-streaming: /v1/responses JSON -> Chat Completions message JSON. */ -export function responsesJsonToChatCompletion(json: unknown, model: string): Rec { +export function responsesJsonToChatCompletion(json: unknown, model: string, translatorBudget?: TranslatorBudget): Rec { const body = isRec(json) ? json : {}; + const incomplete = isRec(body.incomplete_details) ? body.incomplete_details : {}; + let incompleteFinish: "length" | "content_filter" | undefined; + if (body.status === "incomplete") { + if (incomplete.reason === "max_output_tokens") incompleteFinish = "length"; + else if (incomplete.reason === "content_filter") incompleteFinish = "content_filter"; + else throw new ChatCompletionsStreamError("upstream response ended without a supported completion boundary", { + code: "upstream_incomplete", type: "upstream_error", + }); + } const output = Array.isArray(body.output) ? body.output : []; let content = ""; + let refusal: string | null = null; + let refusalBytes = 0; let reasoning = ""; + let contentBytes = 0; + let reasoningBytes = 0; const toolCalls: Rec[] = []; + const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => { + if (!fragment) return { text: previous, bytes: previousBytes }; + const scope = { kind: "retained_collectors" as const }; + const nextBytes = appendedUtf8Bytes(previous, previousBytes, fragment); + const reservation = translatorBudget?.reserveTransient(nextBytes, scope); + try { + const next = previous + fragment; + reservation?.commitRetained(); + translatorBudget?.releaseRetained(previousBytes, scope); + return { text: next, bytes: nextBytes }; + } catch (error) { + reservation?.release(); + throw error; + } + }; for (const raw of output) { if (!isRec(raw)) continue; if (raw.type === "message" && Array.isArray(raw.content)) { for (const part of raw.content) { if (isRec(part) && part.type === "output_text" && typeof part.text === "string") { - content += part.text; + ({ text: content, bytes: contentBytes } = append(content, contentBytes, part.text)); + } else if (isRec(part) && part.type === "refusal" && Object.hasOwn(part, "refusal")) { + if (typeof part.refusal !== "string") throw refusalTranslationError(); + const next = append(refusal ?? "", refusalBytes, part.refusal); + refusal = next.text; + refusalBytes = next.bytes; } } } else if (raw.type === "reasoning") { if (Array.isArray(raw.summary)) { for (const part of raw.summary) { if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") { - reasoning += part.text; + ({ text: reasoning, bytes: reasoningBytes } = append(reasoning, reasoningBytes, part.text)); } } } if (Array.isArray(raw.content)) { for (const part of raw.content) { if (isRec(part) && part.type === "reasoning_text" && typeof part.text === "string") { - reasoning += part.text; + ({ text: reasoning, bytes: reasoningBytes } = append(reasoning, reasoningBytes, part.text)); } } } } else if (raw.type === "function_call") { - toolCalls.push({ + const call = { id: typeof raw.call_id === "string" ? raw.call_id : `call_${uuid().slice(0, 16)}`, type: "function", function: { name: typeof raw.name === "string" ? raw.name : "", arguments: typeof raw.arguments === "string" ? raw.arguments : "{}", }, + }; + // A complete buffered call still obeys the same per-call cap as live deltas. + // Reserve before serializing, then transfer ownership to the complete call. + // The internal scope stays nonempty even when an upstream call_id is empty. + const argumentsReservation = translatorBudget?.reserveTransient(Buffer.byteLength(call.function.arguments), { + kind: "tool_args", callId: `chat_json_${toolCalls.length}`, }); + try { + translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(call)), { kind: "retained_collectors" }); + toolCalls.push(call); + } finally { + argumentsReservation?.release(); + } } } - const finishReason = toolCalls.length > 0 ? "tool_calls" - : body.status === "incomplete" ? "length" - : "stop"; + const finishReason = incompleteFinish ?? (toolCalls.length > 0 ? "tool_calls" : "stop"); const message: Rec = { role: "assistant", content: content || null, + refusal, }; if (reasoning) message.reasoning_content = reasoning; if (toolCalls.length > 0) message.tool_calls = toolCalls; @@ -637,6 +883,7 @@ export async function collectChatCompletion( const decoder = new TextDecoder(); let buffer = ""; let content = ""; + let refusal: string | null = null; let reasoning = ""; const toolCalls = new Map(); // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the @@ -644,7 +891,6 @@ export async function collectChatCompletion( const callScope = (index: number) => `chat_collect_${index}`; let finishReason = "stop"; let usage: unknown; - let streamError: ChatCompletionsStreamError | null = null; const replaceRetained = (previous: string, next: string, kind: "live_transient" | "retained_collectors") => { const reservation = translatorBudget.reserveTransient(Buffer.byteLength(next), { kind }); reservation.commitRetained(); @@ -697,12 +943,12 @@ export async function collectChatCompletion( : code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message) ? 400 : streamErrorStatus(message); - streamError = new ChatCompletionsStreamError(message, { + const streamError = new ChatCompletionsStreamError(message, { status, type: code === "translation_buffer_limit" ? "upstream_error" : type, code, }); - continue; + throw streamError; } if (parsed.usage) usage = parsed.usage; const choices = Array.isArray(parsed.choices) ? parsed.choices : []; @@ -712,6 +958,10 @@ export async function collectChatCompletion( const delta = isRec(choice.delta) ? choice.delta : null; if (!delta) continue; if (typeof delta.content === "string") content = replaceRetained(content, content + delta.content, "retained_collectors"); + if (delta.refusal !== undefined && delta.refusal !== null) { + if (typeof delta.refusal !== "string") throw refusalTranslationError(); + refusal = replaceRetained(refusal ?? "", (refusal ?? "") + delta.refusal, "retained_collectors"); + } if (typeof delta.reasoning_content === "string") reasoning = replaceRetained(reasoning, reasoning + delta.reasoning_content, "retained_collectors"); if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) { @@ -749,6 +999,10 @@ export async function collectChatCompletion( } } } catch (error) { + // Processing may fail between reads; cancel while we still own the lock so the + // upstream translator releases its maps and stops any pending provider read. + try { await reader.cancel(error); } catch { /* preserve the original failure */ } + translatorBudget.releaseRetained(Buffer.byteLength(refusal ?? ""), { kind: "retained_collectors" }); // Never leak an open call scope on the error path; the turn budget's // dispose is a backstop, not the owner of this transfer. for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index)); @@ -765,14 +1019,11 @@ export async function collectChatCompletion( } finally { reader.releaseLock(); } - if (streamError) { - for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index)); - throw streamError; - } const message: Rec = { role: "assistant", content: content || null, + refusal, }; if (reasoning) message.reasoning_content = reasoning; if (toolCalls.size > 0) { diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index 3fe5bbe47e..44ee17c875 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -22,6 +22,7 @@ import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneM import { claudeConfigDir } from "./gateway-cache"; import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config"; import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound"; +import { AnthropicRequestError } from "./inbound-records"; import { knownModelIdsForProvider } from "../router"; import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec"; @@ -99,11 +100,17 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record { const unmarked = stripOneMillionMarker(model); - const nativePassthrough = config.claudeCode?.nativePassthrough !== false - && !unmarked.includes("/") - && /^(claude|anthropic)(?:-|$)/i.test(unmarked) - && resolveInboundModel(unmarked, config.claudeCode) === unmarked; - return nativePassthrough ? [] : blockedSkills; + try { + const nativePassthrough = config.claudeCode?.nativePassthrough !== false + && !unmarked.includes("/") + && /^(claude|anthropic)(?:-|$)/i.test(unmarked) + && resolveInboundModel(unmarked, config.claudeCode) === unmarked; + return nativePassthrough ? [] : blockedSkills; + } catch (error) { + // A stale Desktop selector must not break the roster or acquire native exemptions. + if (error instanceof AnthropicRequestError) return blockedSkills; + throw error; + } }; const defs: ClaudeAgentDef[] = []; const usedNames = new Set(); diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts index 08c79b5442..75ba3c1355 100644 --- a/src/claude/compatibility.ts +++ b/src/claude/compatibility.ts @@ -1,521 +1,192 @@ -/** - * Claude compatibility analyzer (pure, no Lab imports). - * - * Detects Anthropic-specific feature usage in the sanitized source envelope - * and decides whether a routed adapter can safely serve the request. Feature - * codes are stable identifiers used in the bounded debug ring and (in enforce - * mode) as a pre-network gate. - * - * Feature codes (Milestone 2 precise set): - * - cache_control: any block with a cache_control field (positional prompt caching) - * - thinking_block: thinking param or thinking/redacted_thinking blocks (unsigned/ocxr1 continuity) - * - signed_thinking: genuine Anthropic signed thinking (thinking.signature non-empty not ocxr1: or redacted_thinking with non-empty data) — incompatible on routed adapters, fail-closed even in shadow - * - documents: document content blocks in messages - * - unknown_content_block: content block type not in known Anthropic vocabulary - * - web_search_tool: hosted web_search tool/block (has lossless Responses mapping) - * - code_execution: code_execution tool/block (no lossless routed mapping) - * - computer_use: computer tool/block (no lossless routed mapping) - * - mcp_tool: mcp tool declarations (no lossless routed mapping) - * - server_tool: generic fallback for other hosted/server tool types - * - tool_search: tool_search declaration/call (lossless via tool_search) - * - deferred_tools: tools with defer/defer_loading or deferred beta markers - * - structured_output: output_config.format json_schema (lossless via text.format) - * - service_tier: top-level service_tier (lossless via Responses option) - * - context_management: top-level context_management field (no lossless routed mapping) - * - input_examples: tool input_examples (Anthropic-only, preserved via source envelope) - * - beta_*: each anthropic-beta token as beta_ - */ - +/** Opt-in admission for the translated Messages path; no adapter or credential state. */ export type ClaudeCompatibilityMode = "shadow" | "enforce"; -export const CLAUDE_COMPATIBILITY_MODES = ["shadow", "enforce"] as const; - -export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode { - return typeof value === "string" && (CLAUDE_COMPATIBILITY_MODES as readonly string[]).includes(value); -} - -export function resolveClaudeCompatibilityMode( - cc?: { compatibility?: unknown }, -): ClaudeCompatibilityMode { - return isClaudeCompatibilityMode(cc?.compatibility) ? cc.compatibility : "enforce"; -} - -export type ClaudeCompatibilityDecision = "allow" | "reject" | "shadow"; - -export interface ClaudeCompatibilityResult { - featureCodes: string[]; - compatible: boolean; - decision: ClaudeCompatibilityDecision; - /** Human-readable reason when rejected, otherwise undefined. */ - reason?: string; -} - +// False means deliberately tolerated degradation, not lossless representation. +const FEATURES = { + cache_control: false, + input_examples: false, + thinking_settings: false, + unknown_beta: false, + thinking_replay: true, + documents: true, + web_search_tool: true, + tool_search: true, + tool_reference: true, + deferred_tools: true, + strict_tools: true, + caller_mode: true, + structured_output: true, + service_tier: true, + mcp_tool: true, + code_execution: true, + computer_use: true, + server_tool: true, + context_management: true, + container: true, + inference_geo: true, + user_profile: true, + unknown_body_field: true, + unknown_content_block: true, +} as const; + +export type ClaudeFeatureCode = keyof typeof FEATURES; +const FEATURE_CODES = Object.keys(FEATURES) as ClaudeFeatureCode[]; +const MAX_FEATURE_CODES = 32; +const MAX_REASON_LENGTH = 512; type Rec = Record; +const isRec = (value: unknown): value is Rec => + value !== null && typeof value === "object" && !Array.isArray(value); -function isRec(v: unknown): v is Rec { - return !!v && typeof v === "object" && !Array.isArray(v); -} - -function sanitizeBetaToken(raw: string): string { - const trimmed = raw.trim().toLowerCase(); - if (!trimmed) return ""; - return trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); -} - -function walkForCacheControl(value: unknown): boolean { - if (!value || typeof value !== "object") return false; - if (Array.isArray(value)) return value.some(walkForCacheControl); - const rec = value as Rec; - if (Object.prototype.hasOwnProperty.call(rec, "cache_control")) return true; - return Object.values(rec).some(walkForCacheControl); +export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode { + return value === "shadow" || value === "enforce"; } -function hasThinkingBlock(body: Rec): boolean { - if (isRec(body.thinking)) return true; - const msgs = body.messages; - if (!Array.isArray(msgs)) return false; - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (Array.isArray(content)) { - for (const b of content) { - if (!isRec(b)) continue; - if (b.type === "thinking" || b.type === "redacted_thinking") return true; - } - } +/** Project only closed codes, including when reading an untrusted persisted row. */ +export function normalizeClaudeFeatureCodes(value: unknown): ClaudeFeatureCode[] { + if (!Array.isArray(value)) return []; + const codes = new Set(); + for (const code of value) { + if (typeof code === "string" && Object.hasOwn(FEATURES, code)) codes.add(code as ClaudeFeatureCode); } - return false; + return FEATURE_CODES.filter(code => codes.has(code)).sort().slice(0, MAX_FEATURE_CODES); } -function hasGenuineSignedThinking(body: Rec): boolean { - const msgs = body.messages; - if (!Array.isArray(msgs)) return false; - for (const m of msgs) { - if (!isRec(m)) continue; - const content = (m as Rec).content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (b.type === "thinking") { - const signature = b.signature; - if (typeof signature === "string") { - if (signature.length > 0 && !signature.startsWith("ocxr1:")) return true; - } else if (signature != null) { - return true; - } - } else if (b.type === "redacted_thinking") { - const data = (b as Rec).data; - if (typeof data === "string") { - if (data.length > 0) return true; - } else if (data != null && String(data).length > 0) { - return true; - } - } - } - } - return false; +/** Never accept a caller-supplied reason, header value, model name or tool name. */ +export function claudeCompatibilityReason(codes: readonly ClaudeFeatureCode[], shadow: boolean): string | undefined { + const unsupported = codes.filter(code => FEATURES[code]); + if (unsupported.length === 0) return undefined; + return `${shadow ? "shadow: would reject" : "unsupported translated Claude features"}: ${unsupported.join(", ")}` + .slice(0, MAX_REASON_LENGTH); } -const KNOWN_CONTENT_TYPES = new Set([ - "text", "image", "tool_use", "tool_result", "thinking", "redacted_thinking", - "document", "server_tool_use", "web_search_tool_result", "code_execution_tool_result", - "tool_search_tool_result", "mcp_tool_use", "mcp_tool_result", +const BODY_FIELDS = new Set([ + "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking", + "output_config", "metadata", "service_tier", "stop_sequences", "stream", + "temperature", "top_p", "top_k", "cache_control", "context_management", + "container", "inference_geo", "user_profile_id", "mcp_servers", "defer_tools", "deferred_tools", ]); -function hasDocuments(body: Rec): boolean { - const msgs = body.messages; - if (!Array.isArray(msgs)) return false; - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (b.type === "document") return true; - if (b.type === "tool_result" && Array.isArray(b.content)) { - for (const nested of b.content) { - if (isRec(nested) && nested.type === "document") return true; - } - } - } - } - return false; -} - -function hasUnknownContentBlock(body: Rec): boolean { - const sys = body.system; - if (Array.isArray(sys)) { - for (const b of sys) { - if (!isRec(b)) continue; - const t = typeof b.type === "string" ? b.type : ""; - if (t && t !== "text") return true; - } - } - const msgs = body.messages; - if (!Array.isArray(msgs)) return false; - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - const t = typeof b.type === "string" ? b.type : ""; - if (t && !KNOWN_CONTENT_TYPES.has(t)) return true; - } - } - return false; -} - -function hasCodeExecution(body: Rec): boolean { - const tools = body.tools; - if (Array.isArray(tools)) { - for (const t of tools) { - if (!isRec(t)) continue; - const type = typeof t.type === "string" ? t.type : ""; - if (type.includes("code_execution")) return true; - } - } - const msgs = body.messages; - if (Array.isArray(msgs)) { - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (b.type === "code_execution_tool_result") return true; - if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("code_execution")) return true; - } - } - } - return false; -} - -function hasComputerUse(body: Rec): boolean { - const tools = body.tools; - if (Array.isArray(tools)) { - for (const t of tools) { - if (!isRec(t)) continue; - const type = typeof t.type === "string" ? t.type : ""; - if (type.includes("computer")) return true; - } - } - const msgs = body.messages; - if (!Array.isArray(msgs)) return false; - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (typeof b.type === "string" && b.type.includes("computer")) return true; - } - } - return false; -} - -function hasMcpTool(body: Rec): boolean { - const tools = body.tools; - if (Array.isArray(tools)) { - for (const t of tools) { - if (!isRec(t)) continue; - const type = typeof t.type === "string" ? t.type : ""; - if (type === "mcp_toolset") return true; - } - } - const msgs = body.messages; - if (!Array.isArray(msgs)) return false; - for (const m of msgs) { - if (!isRec(m) || !Array.isArray(m.content)) continue; - for (const b of m.content) { - if (isRec(b) && (b.type === "mcp_tool_use" || b.type === "mcp_tool_result")) return true; - } - } - return false; -} - -function hasWebSearchTool(body: Rec): boolean { - const tools = body.tools; - if (Array.isArray(tools)) { - for (const t of tools) { - if (!isRec(t)) continue; - const type = typeof t.type === "string" ? t.type : ""; - if (type.includes("web_search")) return true; - } - } - const msgs = body.messages; - if (Array.isArray(msgs)) { - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (b.type === "web_search_tool_result") return true; - if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("web_search")) return true; - } - } - } - return false; -} - -function hasGenericServerTool(body: Rec): boolean { - const tools = body.tools; - if (!Array.isArray(tools)) { - const msgs = body.messages; - if (Array.isArray(msgs)) { - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (b.type !== "server_tool_use") continue; - const name = typeof b.name === "string" ? b.name : ""; - if (name.includes("web_search") || name.includes("code_execution") || name.includes("computer") || name.startsWith("tool_search_tool_")) continue; - return true; +function activeDeferred(value: unknown): boolean { + return value === true || (Array.isArray(value) ? value.length > 0 : isRec(value) && Object.keys(value).length > 0); +} + +function nonDirectCaller(value: unknown): boolean { + return value !== undefined && !(Array.isArray(value) && value.length === 1 && value[0] === "direct"); +} + +/** Complete finite detection. Only protocol content positions are visited, never schemas/arguments. */ +function detectFeatures(body: unknown, anthropicBeta?: string): Set { + const codes = new Set(); + // Header-only beta semantics are outside this policy. No header bytes become codes. + if (anthropicBeta?.trim()) codes.add("unknown_beta"); + if (!isRec(body)) return codes; // The existing Messages parser owns malformed top-level input. + if (Object.keys(body).some(key => !BODY_FIELDS.has(key))) codes.add("unknown_body_field"); + for (const [field, code] of [ + ["cache_control", "cache_control"], ["context_management", "context_management"], + ["container", "container"], ["inference_geo", "inference_geo"], + ["user_profile_id", "user_profile"], ["mcp_servers", "mcp_tool"], + ] as const) { + if (Object.hasOwn(body, field)) codes.add(code); + } + if (body.service_tier !== undefined && body.service_tier !== null) codes.add("service_tier"); + if (isRec(body.thinking)) codes.add("thinking_settings"); + if (isRec(body.output_config) + && (body.output_config.format != null || body.output_config.output_format != null)) codes.add("structured_output"); + if (activeDeferred(body.defer_tools) || activeDeferred(body.deferred_tools)) codes.add("deferred_tools"); + + if (Array.isArray(body.tools)) { + for (const tool of body.tools) { + if (!isRec(tool)) continue; + if (Object.hasOwn(tool, "cache_control")) codes.add("cache_control"); + if (Object.hasOwn(tool, "input_examples")) codes.add("input_examples"); + if (tool.strict === true) codes.add("strict_tools"); + if (tool.defer === true || tool.defer_loading === true) codes.add("deferred_tools"); + if (nonDirectCaller(tool.allowed_callers)) codes.add("caller_mode"); + const type = tool.type; + // Ordinary client function names do not convey hosted execution semantics. + if (type === undefined || type === "function" || type === "custom") continue; + if (type === "mcp_toolset") codes.add("mcp_tool"); + else if (typeof type === "string" && /^web_search_\d{8}$/.test(type)) codes.add("web_search_tool"); + else if (typeof type === "string" && /^tool_search(?:_tool_(?:regex|bm25))?(?:_\d{8})?$/.test(type)) codes.add("tool_search"); + else if (typeof type === "string" && /^code_execution_\d{8}$/.test(type)) codes.add("code_execution"); + else if (typeof type === "string" && /^computer(?:_toolset)?_\d{8}$/.test(type)) codes.add("computer_use"); + else codes.add("server_tool"); + } + } + + const scanBlock = (block: unknown, position: "message" | "system" | "result") => { + if (!isRec(block)) return; + if (Object.hasOwn(block, "cache_control")) codes.add("cache_control"); + if (position === "system" && block.type !== "text") codes.add("unknown_content_block"); + if (position === "result" && (typeof block.type !== "string" || !["text", "image", "document", "tool_reference"].includes(block.type))) { + codes.add("unknown_content_block"); + } + switch (block.type) { + case "text": + case "image": break; + case "document": codes.add("documents"); break; + case "thinking": + case "redacted_thinking": codes.add("thinking_replay"); break; + case "tool_reference": codes.add("tool_reference"); break; + case "tool_search_tool_result": codes.add("tool_search"); break; + case "web_search_tool_result": codes.add("web_search_tool"); break; + case "code_execution_tool_result": + case "bash_code_execution_tool_result": + case "text_editor_code_execution_tool_result": codes.add("code_execution"); break; + case "mcp_tool_use": + case "mcp_tool_result": codes.add("mcp_tool"); break; + case "tool_use": + if (block.caller !== undefined && (!isRec(block.caller) || block.caller.type !== "direct")) codes.add("caller_mode"); + break; + case "server_tool_use": + switch (block.name) { + case "tool_search": + case "tool_search_tool_regex": + case "tool_search_tool_bm25": codes.add("tool_search"); break; + case "web_search": codes.add("web_search_tool"); break; + case "code_execution": codes.add("code_execution"); break; + case "computer": codes.add("computer_use"); break; + default: codes.add("server_tool"); } - } - } - return false; - } - for (const t of tools) { - if (!isRec(t)) continue; - // Exclude known function tools, tool_search, and already-classified server tools - const type = typeof t.type === "string" ? t.type : ""; - if (type === "tool_search" || type.startsWith("tool_search_tool_")) continue; - if (type.includes("web_search") || type.includes("code_execution") || type.includes("computer") || type === "mcp_toolset") continue; - if (type && type !== "function" && type !== "custom") return true; - if (type && typeof t.name !== "string") return true; - } - const msgs2 = body.messages; - if (Array.isArray(msgs2)) { - for (const m of msgs2) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (b.type === "server_tool_use") { - const n = typeof b.name === "string" ? b.name : ""; - if (n.includes("web_search") || n.includes("code_execution") || n.includes("computer") || n.startsWith("tool_search_tool_")) continue; - return true; + break; + case "tool_result": break; // Children are visited below at the one supported nesting level. + default: codes.add("unknown_content_block"); + } + }; + if (Array.isArray(body.system)) for (const block of body.system) scanBlock(block, "system"); + if (Array.isArray(body.messages)) { + for (const message of body.messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const block of message.content) { + scanBlock(block, "message"); + if (isRec(block) && block.type === "tool_result" && Array.isArray(block.content)) { + for (const child of block.content) scanBlock(child, "result"); } } } } - return false; -} - -function hasToolSearch(body: Rec): boolean { - const tools = body.tools; - if (Array.isArray(tools)) { - for (const t of tools) { - if (!isRec(t)) continue; - if (typeof t.type === "string" && (t.type === "tool_search" || t.type.startsWith("tool_search_tool_"))) return true; - } - } - const msgs = body.messages; - if (Array.isArray(msgs)) { - for (const m of msgs) { - if (!isRec(m)) continue; - const content = m.content; - if (!Array.isArray(content)) continue; - for (const b of content) { - if (!isRec(b)) continue; - if (b.type === "tool_search_tool_result") return true; - if (b.type === "server_tool_use" - && typeof b.name === "string" - && (b.name === "tool_search" || b.name.startsWith("tool_search_tool_"))) return true; - } - } - } - return false; -} - -function hasDeferredTools(body: Rec): boolean { - const tools = body.tools; - if (Array.isArray(tools)) { - for (const t of tools) { - if (!isRec(t)) continue; - if (t.defer === true) return true; - if ((t as Rec).defer_loading === true) return true; - if (Object.hasOwn(t, "defer") || Object.hasOwn(t, "defer_loading")) { - // presence with truthy already handled; presence with explicit true is deferred - } - } - } - if (Object.hasOwn(body, "defer_tools") || Object.hasOwn(body, "deferred_tools")) return true; - return false; -} - -function hasInputExamples(body: Rec): boolean { - const tools = body.tools; - if (!Array.isArray(tools)) return false; - for (const t of tools) { - if (!isRec(t)) continue; - if (Object.hasOwn(t, "input_examples")) return true; - } - return false; -} - -function hasStructuredOutput(body: Rec): boolean { - const oc = isRec(body.output_config) ? (body.output_config as Rec) : null; - const nestedFormat = isRec(oc?.format) ? (oc!.format as Rec) : null; - const topFormat = isRec(body.output_format) ? (body.output_format as Rec) : null; - const fmt = nestedFormat ?? topFormat; - if (!fmt) return false; - const f = fmt as Rec; - if (f.type === "json_schema") return true; - return false; -} - -function hasServiceTier(body: Rec): boolean { - return typeof body.service_tier === "string" && (body.service_tier as string).length > 0; -} - -function hasContextManagement(body: Rec): boolean { - return Object.prototype.hasOwnProperty.call(body, "context_management"); -} - -/** Claude Code 2.1.201 sends this cache-preserving no-op on ordinary routed turns. */ -function isNoopContextManagement(body: unknown): boolean { - if (!isRec(body) || !isRec(body.context_management)) return false; - const contextManagement = body.context_management; - if (Object.keys(contextManagement).some(key => key !== "edits")) return false; - const edits = contextManagement.edits; - if (edits === undefined) return true; - if (Array.isArray(edits)) { - if (edits.length === 0) return true; - if (edits.length === 1 && isRec(edits[0])) { - const edit = edits[0]; - return edit.type === "clear_thinking_20251015" - && edit.keep === "all" - && Object.keys(edit).every(key => key === "type" || key === "keep"); - } - } - return false; -} - -const KNOWN_BODY_FIELDS = new Set([ - "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking", - "output_config", "output_format", "metadata", "service_tier", "stop_sequences", "stream", - "temperature", "top_p", "top_k", "cache_control", "context_management", - "container", "inference_geo", "user_profile_id", "defer_tools", "deferred_tools", -]); - -const KNOWN_OUTPUT_CONFIG_FIELDS = new Set(["effort", "format"]); - -function hasUnknownBodyField(body: Rec): boolean { - if (Object.keys(body).some(field => !KNOWN_BODY_FIELDS.has(field))) return true; - if (isRec(body.output_config) && Object.keys(body.output_config).some(field => !KNOWN_OUTPUT_CONFIG_FIELDS.has(field))) return true; - return false; + return codes; } -/** - * Collect feature codes from a sanitized Anthropic body and anthropic-beta header. - * Pure — no config or Lab state. - */ -export function collectClaudeFeatureCodes( - body: unknown, - anthropicBeta?: string, -): string[] { - const codes: string[] = []; - const rec = isRec(body) ? (body as Rec) : null; - if (rec) { - if (walkForCacheControl(body)) codes.push("cache_control"); - if (hasContextManagement(rec)) codes.push("context_management"); - if (Object.hasOwn(rec, "container")) codes.push("container"); - if (Object.hasOwn(rec, "inference_geo")) codes.push("inference_geo"); - if (Object.hasOwn(rec, "user_profile_id")) codes.push("user_profile"); - if (hasUnknownBodyField(rec)) codes.push("unknown_body_field"); - if (hasThinkingBlock(rec)) codes.push("thinking_block"); - if (hasGenuineSignedThinking(rec)) codes.push("signed_thinking"); - if (hasDocuments(rec)) codes.push("documents"); - if (hasUnknownContentBlock(rec)) codes.push("unknown_content_block"); - if (hasWebSearchTool(rec)) codes.push("web_search_tool"); - if (hasCodeExecution(rec)) codes.push("code_execution"); - if (hasComputerUse(rec)) codes.push("computer_use"); - if (hasMcpTool(rec)) codes.push("mcp_tool"); - if (hasGenericServerTool(rec)) codes.push("server_tool"); - if (hasToolSearch(rec)) codes.push("tool_search"); - if (hasDeferredTools(rec)) codes.push("deferred_tools"); - if (hasInputExamples(rec)) codes.push("input_examples"); - if (hasStructuredOutput(rec)) codes.push("structured_output"); - if (hasServiceTier(rec)) codes.push("service_tier"); - } - if (typeof anthropicBeta === "string" && anthropicBeta.trim().length > 0) { - for (const raw of anthropicBeta.split(",")) { - const sanitized = sanitizeBetaToken(raw); - if (!sanitized) continue; - codes.push(`beta_${sanitized}`); - } - } - return [...new Set(codes)].sort(); +export interface ClaudeCompatibilityResult { + featureCodes: ClaudeFeatureCode[]; + compatible: boolean; + decision: "allow" | "shadow" | "reject"; + reason?: string; } -/** - * Analyze compatibility for a given body/header/adapter/mode. - * Enforce rejects when incompatible features require native Anthropic. - * Shadow never rejects — it only records. - */ +/** All translated targets share this policy; native passthrough never calls it. */ export function analyzeClaudeCompatibility( body: unknown, - opts: { mode: ClaudeCompatibilityMode; adapter?: string; anthropicBeta?: string }, + opts: { mode: ClaudeCompatibilityMode; anthropicBeta?: string }, ): ClaudeCompatibilityResult { - const featureCodes = collectClaudeFeatureCodes(body, opts.anthropicBeta); - if (opts.adapter === "anthropic") { - return { featureCodes, compatible: true, decision: "allow" }; - } - // Incompatible set for routed adapters (non-anthropic native). - // Compatible (translated or lossless): cache_control, thinking_block, web_search_tool, - // tool_search, structured_output, service_tier, input_examples (deferred? no), beta_*. - // Incompatible: features without lossless Responses mapping — they require Anthropic - // source preservation and must be rejected on routed targets. - const INCOMPATIBLE = new Set([ - "context_management", - "container", - "inference_geo", - "user_profile", - "unknown_body_field", - "documents", - "unknown_content_block", - "code_execution", - "computer_use", - "mcp_tool", - "server_tool", - "input_examples", - "signed_thinking", - ]); - if (opts.adapter !== "openai-responses") INCOMPATIBLE.add("deferred_tools"); - const incompatible = featureCodes.filter(c => - INCOMPATIBLE.has(c) && (c !== "context_management" || !isNoopContextManagement(body)) - ); - // Safety invariant: genuine signed thinking is incompatible on every non-Anthropic adapter and fails closed even in shadow. - // Anthropic adapter already returned allow above. - if (incompatible.includes("signed_thinking")) { - return { - featureCodes, - compatible: false, - decision: "reject", - reason: `unsupported features for routed adapter ${opts.adapter ?? "unknown"}: ${incompatible.join(", ")}. Select an Anthropic route, remove the feature, or begin a fresh reasoning turn`, - }; - } - if (opts.mode === "shadow") { - return { - featureCodes, - compatible: true, - decision: incompatible.length > 0 ? "shadow" : "allow", - ...(incompatible.length > 0 ? { reason: `shadow: would reject for ${incompatible.join(", ")}` } : {}), - }; - } - if (incompatible.length > 0) { - return { - featureCodes, - compatible: false, - decision: "reject", - reason: `unsupported features for routed adapter ${opts.adapter ?? "unknown"}: ${incompatible.join(", ")}. Select an Anthropic route, remove the feature, or begin a fresh reasoning turn`, - }; - } - return { featureCodes, compatible: true, decision: "allow" }; + const detected = detectFeatures(body, opts.anthropicBeta); + const compatible = !FEATURE_CODES.some(code => detected.has(code) && FEATURES[code]); + const featureCodes = normalizeClaudeFeatureCodes([...detected]); + return { + featureCodes, + compatible, + decision: compatible ? "allow" : opts.mode === "shadow" ? "shadow" : "reject", + ...(!compatible ? { reason: claudeCompatibilityReason(featureCodes, opts.mode === "shadow") } : {}), + }; } diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts new file mode 100644 index 0000000000..4deccbbc66 --- /dev/null +++ b/src/claude/desktop-3p-library.ts @@ -0,0 +1,89 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths"; + +export interface Desktop3pConfigLibraryOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + homeDir?: string; +} + +/** + * Resolve the config library from the same user-data root Claude Desktop uses. Keeping this in one + * helper prevents the writer and dashboard status probe from agreeing on a path Desktop never reads. + * + * The resolution itself lives in `./desktop-3p-paths`, which ports Claude Desktop's own `GE()` + * branch for branch — including the `-3p` suffix the app appends to its userData root. Dropping + * that suffix points us at a directory Desktop never reads (GitHub #539). + */ +export function resolveDesktop3pConfigLibraryPath( + options: Desktop3pConfigLibraryOptions = {}, +): string { + if (options.env === undefined && options.platform === undefined && options.homeDir === undefined) { + return claudeDesktopConfigLibraryDir(); + } + return resolveConfigLibraryDir({ + env: options.env ?? process.env, + platform: options.platform ?? process.platform, + home: options.homeDir ?? homedir(), + }); +} + +export interface Desktop3pMetadataEntry { + id: string; + name: string; + [key: string]: unknown; +} + +export interface Desktop3pMetadata { + appliedId?: string; + entries: Desktop3pMetadataEntry[]; + [key: string]: unknown; +} + +export function parseMetadata(path: string): Desktop3pMetadata { + if (!existsSync(path)) return { entries: [] }; + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array"); + return { ...parsed, entries: parsed.entries }; +} + +export const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean { + return entry?.name === "opencodex" || entry?.name === "opencodex-standard"; +} + +/** A gateway row is removable; the selected standard row must always remain. */ +export function isOwnedDesktopGatewayEntry(entry: Desktop3pMetadataEntry | undefined): boolean { + return entry?.name === "opencodex"; +} + +export function profilePath(libraryPath: string, id: string): string { + if (!SAFE_DESKTOP_PROFILE_ID.test(id)) throw new Error("desktop_profile_id_unsafe"); + return join(libraryPath, `${id}.json`); +} + +export const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([ + "inferenceProvider", + "inferenceCredentialKind", + "inferenceGatewayBaseUrl", + "inferenceGatewayApiKey", + "modelDiscoveryEnabled", + "inferenceModels", +]); + +export function readDesktopProfileForeignKeys(path: string): Record { + if (!existsSync(path)) return {}; + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object"); + return Object.fromEntries( + Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)), + ); +} + diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index c5e0172ad1..7163ca2fa1 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -1,13 +1,24 @@ +import { readClientConnectionState } from "../client/state"; +import { readServiceApiTokenState, readTokenBackupState } from "../lib/service-secrets"; +import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesktopStore } from "./desktop-remote-store"; +import { canonicalDirectory } from "./desktop-remote-store-io"; +import { + resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord, + isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys, + type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry, +} from "./desktop-3p-library"; +export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library"; import { createHash, randomUUID } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; -import { atomicWriteFile } from "../config"; +import { atomicWriteFile, readConfigDiagnostics, withConfigMutationLockSync } from "../config"; +import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; import type { OcxClaudeDesktopProfile } from "../types"; -import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths"; import { reconcileDesktopProfile, renderDesktopProfile, + validDateAlias, type DesktopProfileModel, } from "./desktop-profile"; import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog"; @@ -50,33 +61,6 @@ export interface Desktop3pRoutedModel { */ export const DESKTOP_SUPPORTS_1M_THRESHOLD = 1_000_000; -export interface Desktop3pConfigLibraryOptions { - env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; - homeDir?: string; -} - -/** - * Resolve the config library from the same user-data root Claude Desktop uses. Keeping this in one - * helper prevents the writer and dashboard status probe from agreeing on a path Desktop never reads. - * - * The resolution itself lives in `./desktop-3p-paths`, which ports Claude Desktop's own `GE()` - * branch for branch — including the `-3p` suffix the app appends to its userData root. Dropping - * that suffix points us at a directory Desktop never reads (GitHub #539). - */ -export function resolveDesktop3pConfigLibraryPath( - options: Desktop3pConfigLibraryOptions = {}, -): string { - if (options.env === undefined && options.platform === undefined && options.homeDir === undefined) { - return claudeDesktopConfigLibraryDir(); - } - return resolveConfigLibraryDir({ - env: options.env ?? process.env, - platform: options.platform ?? process.platform, - home: options.homeDir ?? homedir(), - }); -} - /** CLI arg parsing for `ocx claude desktop` mode flags (mutually exclusive). */ export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfigMode } | { error: string } { const known = new Map([ @@ -91,18 +75,6 @@ export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfig return { mode: picked[0] ?? "static" }; } -interface Desktop3pMetadataEntry { - id: string; - name: string; - [key: string]: unknown; -} - -interface Desktop3pMetadata { - appliedId?: string; - entries: Desktop3pMetadataEntry[]; - [key: string]: unknown; -} - export type Desktop3pLibraryKind = | "not_installed" | "standard" @@ -144,6 +116,7 @@ export interface Desktop3pRemovalResult { let desktop3pRegistry = new Map(); let desktop3pAliasesByRoute = new Map(); +let desktop3pRealAnthropicIds = new Set(); /** Derive a stable letter-first, three-character base36 code from a route key. */ export function deriveDesktop3pCode(route: string): string { @@ -192,7 +165,7 @@ function collectDesktop3pModels( routedModels: Array, profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, -): { models: Desktop3pModelEntry[]; registry: Map } { +): { models: Desktop3pModelEntry[]; registry: Map; realAnthropicIds: Set } { const registry = new Map(); const models: Desktop3pModelEntry[] = []; const candidates: Desktop3pRoutedModel[] = [ @@ -205,6 +178,9 @@ function collectDesktop3pModels( }), ...routedModels, ]; + const realAnthropicIds = new Set(candidates + .filter(model => model.provider === "anthropic" && model.id.startsWith("claude-")) + .map(model => model.id)); if (profile) { const profileModels = candidates.map(({ provider, id, contextWindow }) => ({ @@ -242,7 +218,7 @@ function collectDesktop3pModels( registry.set(legacy, model.route); } desktop3pAliasesByRoute = aliasesByRoute; - return { models, registry }; + return { models, registry, realAnthropicIds }; } for (const { provider, id, contextWindow } of candidates) { @@ -285,7 +261,7 @@ function collectDesktop3pModels( if (models[0]) models[0].isFamilyDefault = true; desktop3pAliasesByRoute = new Map(candidates.map(({ provider, id }) => [`${provider}/${id}`, desktop3pAlias(provider, id)])); - return { models, registry }; + return { models, registry, realAnthropicIds }; } /** Build and install the registry used to decode Desktop aliases. */ @@ -295,8 +271,9 @@ export function buildDesktop3pRegistry( profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, ): Map { - const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const { registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; + desktop3pRealAnthropicIds = realAnthropicIds; return registry; } @@ -307,8 +284,9 @@ export function generateDesktop3pModels( profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, ): Desktop3pModelEntry[] { - const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const { models, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; + desktop3pRealAnthropicIds = realAnthropicIds; return models; } @@ -317,6 +295,21 @@ export function resolveDesktop3pAlias(alias: string): string | null { return desktop3pRegistry.get(alias) ?? null; } +/** Exact registered and identity-preserving catalog IDs precede synthetic syntax. */ +export function isKnownDesktop3pModelId(id: string): boolean { + return desktop3pRegistry.has(id) || desktop3pRealAnthropicIds.has(id); +} + +/** Only missing IDs in the emitted Desktop namespaces are managed-alias errors. */ +export function isUnresolvedDesktop3pAlias(id: string): boolean { + if (isKnownDesktop3pModelId(id)) return false; + // Validate the managed base even when Fast routing is disabled. This checks + // identity only; it neither enables a tier nor strips an exact full catalog ID. + const base = id.endsWith("--fast") ? id.slice(0, -"--fast".length) : id; + if (isKnownDesktop3pModelId(base)) return false; + return validDateAlias(base) || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base); +} + /** Alias selected by the installed profile registry, falling back to the legacy hash shape. */ export function activeDesktop3pAlias(provider: string, modelId: string): string { return desktop3pAliasesByRoute.get(`${provider}/${modelId}`) ?? desktop3pAlias(provider, modelId); @@ -363,32 +356,6 @@ export function generateDesktop3pConfig( }; } -function parseMetadata(path: string): Desktop3pMetadata { - if (!existsSync(path)) return { entries: [] }; - const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; - if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array"); - return { ...parsed, entries: parsed.entries }; -} - -const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean { - return entry?.name === "opencodex" || entry?.name === "opencodex-standard"; -} - -/** A gateway row is removable; the selected standard row must always remain. */ -function isOwnedDesktopGatewayEntry(entry: Desktop3pMetadataEntry | undefined): boolean { - return entry?.name === "opencodex"; -} - -function profilePath(libraryPath: string, id: string): string { - return join(libraryPath, `${id}.json`); -} - /** * Read Desktop's selected config without changing its library. * @@ -481,7 +448,76 @@ export function inspectDesktop3pConfigLibrary( * (or any other benign mismatch), while the Integrations card still showed the * leftover profile as applied/stale. */ +/** Keep failures diagnosable without reflecting paths, config bytes or arbitrary errors. */ +function desktopMutationFailureReason(error: unknown): string { + const code = error !== null && typeof error === "object" && "code" in error ? error.code : undefined; + switch (code) { + case "client_lifecycle_busy": return "client_lifecycle_busy"; + case "client_lifecycle_lock_failed": return "client_lifecycle_lock_failed"; + case "CONFIG_MUTATION_LOCK_UNAVAILABLE": return "config_mutation_lock_unavailable"; + case "EACCES": + case "EPERM": return "desktop_filesystem_denied"; + case "ENOENT": return "desktop_path_missing"; + case "EBUSY": return "desktop_filesystem_busy"; + default: return "desktop_mutation_failed"; + } +} + export function removeDesktop3pStandardPivot( + options: Desktop3pConfigLibraryOptions & { + appliedFingerprint?: string | null; unlink?: (path: string) => void; + lifecycleLockDeps?: ClientLifecycleLockDeps; + } = {}, +): Desktop3pRemovalResult { + const libraryPath = resolveDesktop3pConfigLibraryPath(options); + try { + return withClientLifecycleSync(held => withConfigMutationLockSync(() => { + const connection = readClientConnectionState(); + if (connection.kind === "invalid" || connection.kind === "mismatched") { + return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: `desktop_client_state_${connection.kind}` }; + } + if (connection.kind === "connected" + && canonicalDirectory(libraryPath) !== canonicalDirectory(resolveDesktop3pConfigLibraryPath())) { + return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_library_identity_changed" }; + } + const latest = readConfigDiagnostics(); + if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" }; + if (claudeDesktopIntegrationEnabled(latest.config)) { + const observed = inspectDesktop3pConfigLibrary(options); + if (observed.kind === "not_installed" || observed.kind === "no_owned_state") { + return { ok: true, changed: false, kind: "noop", libraryPath }; + } + return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desired_state_changed" }; + } + const cleanup = inspectRemoteDesktopCleanup(); + if (cleanup.kind === "absent") { + if (connection.kind === "disconnected") return removeDesktop3pStandardPivotLocal(options); + const client = connection.value; + const known = [readServiceApiTokenState(), readTokenBackupState()].flatMap(token => token.kind === "present" ? [token.fingerprint] : []); + const result = restoreRemoteDesktopStore(held, { + owner: { serverUrl: new URL(client.serverUrl).origin, apiKeyId: client.apiKeyId, connectedAt: client.connectedAt }, + knownTokenFingerprints: known, + }); + return result.ok + ? { ok: true, changed: result.changed, kind: result.changed ? "removed" : "noop", libraryPath } + : { ok: false, changed: result.changed, kind: "unsafe", reason: result.reason, libraryPath }; + } + if (cleanup.kind === "unsafe" || canonicalDirectory(libraryPath) !== canonicalDirectory(resolveDesktop3pConfigLibraryPath())) { + return { ok: false, changed: false, kind: "unsafe", libraryPath }; + } + const known = [readServiceApiTokenState(), readTokenBackupState()] + .flatMap(token => token.kind === "present" ? [token.fingerprint] : []); + const result = restoreRemoteDesktopStore(held, { owner: cleanup.owner, knownTokenFingerprints: known }); + return result.ok + ? { ok: true, changed: result.changed, kind: result.changed ? "removed" : "noop", libraryPath } + : { ok: false, changed: result.changed, kind: result.reason === "cleanup_pending" ? "cleanup_incomplete" : "unsafe", reason: result.reason, libraryPath }; + }), options.lifecycleLockDeps); + } catch (error) { + return { ok: false, changed: false, kind: "write_failed", libraryPath, reason: desktopMutationFailureReason(error) }; + } +} + +function removeDesktop3pStandardPivotLocal( options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null; unlink?: (path: string) => void; @@ -554,8 +590,8 @@ export function removeDesktop3pStandardPivot( JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n", ); return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath }; - } catch { - return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath }; + } catch (error) { + return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath, reason: desktopMutationFailureReason(error) }; } } @@ -568,6 +604,59 @@ export function writeDesktop3pConfig( mode: Desktop3pConfigMode = "static", profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, + lifecycleLockDeps?: ClientLifecycleLockDeps, +): { written: boolean; path: string; reason?: string; fingerprint?: string } { + try { + return withClientLifecycleSync(() => withConfigMutationLockSync(() => { + const connection = readClientConnectionState(); + if (connection.kind === "invalid" || connection.kind === "mismatched") { + return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: `desktop_client_state_${connection.kind}` }; + } + const latest = readConfigDiagnostics(); + if (latest.source === "fallback") return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_config_invalid" }; + if (!claudeDesktopIntegrationEnabled(latest.config)) { + return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desired_state_changed" }; + } + if (connection.kind === "connected" || inspectRemoteDesktopCleanup().kind !== "absent") { + return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_remote_store_active" }; + } + return writeDesktop3pConfigWithGenerator(() => ( + generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap) + )); + }), lifecycleLockDeps); + } catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; } +} + +/** Write the hub's exact entries without constructing a client-local alias registry. */ +export function writeRemoteDesktop3pConfig(options: { + baseUrl: string; + apiKey: string; + mode: Desktop3pConfigMode; + models: Desktop3pModelEntry[]; + lifecycleLockDeps?: ClientLifecycleLockDeps; +}): { written: boolean; path: string; reason?: string; fingerprint?: string } { + try { + return withClientLifecycleSync(held => { + const connection = readClientConnectionState(); + const token = readServiceApiTokenState(); + if (connection.kind !== "connected" || token.kind !== "present") { + return { written: false, path: "", reason: "desktop_remote_connection_required" }; + } + const client = connection.value; + const result = applyRemoteDesktopStore(held, { + baseUrl: options.baseUrl, apiKey: options.apiKey, mode: options.mode, models: options.models, + owner: { serverUrl: new URL(client.serverUrl).origin, apiKeyId: client.apiKeyId, connectedAt: client.connectedAt }, + expectedTokenFingerprint: token.fingerprint, + }); + return result.ok + ? { written: result.status === "applied", path: result.path ?? "", fingerprint: result.fingerprint } + : { written: false, path: "", reason: result.reason }; + }, options.lifecycleLockDeps); + } catch { return { written: false, path: "", reason: "desktop_lifecycle_busy_or_unsafe" }; } +} + +function writeDesktop3pConfigWithGenerator( + generate: () => object, ): { written: boolean; path: string; reason?: string; fingerprint?: string } { const libraryPath = resolveDesktop3pConfigLibraryPath(); const metadataPath = join(libraryPath, "_meta.json"); @@ -579,13 +668,13 @@ export function writeDesktop3pConfig( const selected = metadata.entries.find(entry => entry?.id === metadata.appliedId && isOwnedDesktopGatewayEntry(entry)); const existing = selected ?? metadata.entries.find(entry => isOwnedDesktopGatewayEntry(entry) && typeof entry.id === "string"); const id = existing?.id ?? randomUUID(); - configPath = join(libraryPath, `${id}.json`); + configPath = profilePath(libraryPath, id); const entry: Desktop3pMetadataEntry = existing ? { ...existing, id, name: "opencodex" } : { id, name: "opencodex" }; const entries = existing ? metadata.entries.map(current => current === existing ? entry : current) : [...metadata.entries, entry]; - const generated = generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap); + const generated = generate(); const preserved = readDesktopProfileForeignKeys(configPath); const configJson = JSON.stringify({ ...preserved, ...generated }, null, 2) + "\n"; const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16); @@ -604,24 +693,6 @@ export function writeDesktop3pConfig( } } -const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([ - "inferenceProvider", - "inferenceCredentialKind", - "inferenceGatewayBaseUrl", - "inferenceGatewayApiKey", - "modelDiscoveryEnabled", - "inferenceModels", -]); - -function readDesktopProfileForeignKeys(path: string): Record { - if (!existsSync(path)) return {}; - const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; - if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object"); - return Object.fromEntries( - Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)), - ); -} - /** Backup an existing owned config then atomically replace it. Exported for failure-path tests. */ export function atomicReplaceDesktopConfig( path: string, diff --git a/src/claude/desktop-discovery-inputs.ts b/src/claude/desktop-discovery-inputs.ts new file mode 100644 index 0000000000..2ab91a32cc --- /dev/null +++ b/src/claude/desktop-discovery-inputs.ts @@ -0,0 +1,44 @@ +import type { OcxConfig } from "../types"; +import { + filterCatalogVisibleModels, + nativeContextLimits, + orderForSubagents, + type CatalogModel, + type NativeContextLimits, +} from "../codex/catalog"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../codex/catalog/native-models"; +import { + availableAccountGatedNativeModels, + type CodexModelEntitlementSnapshot, +} from "../codex/model-entitlements"; +import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; +import { providerCodexAccountMode } from "../providers/registry"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; + +export interface DesktopDiscoveryInputs { + nativeSlugs: string[]; + routedModels: CatalogModel[]; + nativeContextCap: NativeContextLimits; +} + +/** Project captured discovery state without reading caches or installing aliases. */ +export function buildDesktopDiscoveryInputs(options: { + config: OcxConfig; + models: readonly CatalogModel[]; + modelEntitlements: CodexModelEntitlementSnapshot; + desktopNativeCandidates: readonly string[]; +}): DesktopDiscoveryInputs { + const { config, models, modelEntitlements, desktopNativeCandidates } = options; + const eligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const available = availableAccountGatedNativeModels(modelEntitlements, eligibleAccountIds); + return { + nativeSlugs: desktopNativeCandidates.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || available.has(slug) + )), + routedModels: orderForSubagents(filterCatalogVisibleModels([...models], config), config.subagentModels), + nativeContextCap: nativeContextLimits(config), + }; +} diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 2bd0edf59a..d9e40ae364 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -83,7 +83,7 @@ function isRealAnthropicRoute(route: string): boolean { return route.startsWith("anthropic/claude-"); } -function validDateAlias(alias: string): boolean { +export function validDateAlias(alias: string): boolean { const match = DATE_ALIAS.exec(alias); if (!match) return false; const year = Number(match[1]!.slice(0, 4)); diff --git a/src/claude/desktop-remote-store-artifact.ts b/src/claude/desktop-remote-store-artifact.ts new file mode 100644 index 0000000000..ecefac46cc --- /dev/null +++ b/src/claude/desktop-remote-store-artifact.ts @@ -0,0 +1,185 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { readConfigDiagnostics } from "../config"; +import { readServiceApiTokenState, readTokenBackupState, serviceApiTokenFingerprint } from "../lib/service-secrets"; +import { profilePath, type Desktop3pMetadata } from "./desktop-3p-library"; +import { StoreIO, metadata, storePaths, MAX_DESKTOP_METADATA_ENTRIES, type JsonFile } from "./desktop-remote-store-io"; +import { + DesktopStoreError, digest, origin, parseBaseline, parseDisconnect, parseOwner, parseState, projection, projectionHash, mergeProjection, + sameOwner, type ArtifactPending, type Baseline, type DesktopRemoteOwner, type Projection, type StoreState, +} from "./desktop-remote-store-state"; + +export function currentConnection(owner: DesktopRemoteOwner) { + parseOwner(owner); + const { config, source } = readConfigDiagnostics(); + const client = config.client; + if (source !== "file" || config.runtimeRole !== "client" || !client + || origin(client.serverUrl) !== owner.serverUrl || client.apiKeyId !== owner.apiKeyId || client.connectedAt !== owner.connectedAt) throw new DesktopStoreError("conflict"); + return { config, client }; +} +export function serviceGenerations(): string[] { + const token = readServiceApiTokenState(); + const backup = readTokenBackupState(); + if (token.kind === "unsafe" || backup.kind === "unsafe") throw new DesktopStoreError("unsafe"); + return [token, backup].flatMap(t => t.kind === "present" ? [t.fingerprint] : []); +} +export function profileCredential(value: Record | null): { origin: string; fingerprint: string } | null { + if (!value || value.inferenceProvider !== "gateway" || value.inferenceCredentialKind !== "static") return null; + if (typeof value.inferenceGatewayApiKey !== "string") throw new DesktopStoreError("unsafe"); + return { origin: origin(value.inferenceGatewayBaseUrl), fingerprint: serviceApiTokenFingerprint(value.inferenceGatewayApiKey) }; +} +export function loadBundle(io: StoreIO) { + const paths = storePaths(); + const stateFile = io.read(paths.state, 64 * 1024, true); + const baselineFile = io.read(paths.baseline, 1024 * 1024, true); + const state = stateFile ? parseState(stateFile.value) : null; + const baseline = baselineFile ? parseBaseline(baselineFile.value) : null; + if (!state && baseline) throw new DesktopStoreError("unsafe"); + if (state) { + if (state.home !== paths.home || state.library !== paths.library) throw new DesktopStoreError("unsafe"); + if (!baseline && state.phase !== "prepared" && state.phase !== "cleaned") throw new DesktopStoreError("unsafe"); + if (baseline && (baselineFile!.hash !== state.baselineHash || baseline.home !== paths.home + || baseline.library !== paths.library || baseline.targetId !== state.targetId || baseline.kind !== state.baselineKind + || !sameOwner(baseline.owner, state.owner))) throw new DesktopStoreError("unsafe"); + } + return { paths, stateFile, baselineFile, state, baseline }; +} +export type Bundle = ReturnType; +export function requireOwner(bundle: Bundle, owner: DesktopRemoteOwner): void { + if (bundle.state && !sameOwner(bundle.state.owner, owner)) throw new DesktopStoreError("conflict"); +} +export function selectedFile(io: StoreIO, library: string, meta: Desktop3pMetadata): JsonFile | null { + if (!meta.appliedId) return null; + const file = io.read(profilePath(library, meta.appliedId)); + if (!file) throw new DesktopStoreError("unsafe"); + return file; +} +export function locateLegacy(io: StoreIO, owner: DesktopRemoteOwner, known: readonly string[]) { + const paths = storePaths(); + const meta = metadata(io, paths.library); + selectedFile(io, paths.library, meta.value); + let found: { id: string; file: JsonFile } | null = null; + for (const entry of meta.value.entries) { + const file = io.read(profilePath(paths.library, entry.id)); + if (!file) { if (entry.id === meta.value.appliedId) throw new DesktopStoreError("unsafe"); continue; } + const credential = profileCredential(file.value); + if (!credential || credential.origin !== owner.serverUrl) continue; + if (entry.name !== "opencodex" || !known.includes(credential.fingerprint) || found) throw new DesktopStoreError("conflict"); + found = { id: entry.id, file }; + } + return found; +} +export function saveState(io: StoreIO, bundle: Bundle, state: StoreState): void { + parseState(state); + io.write(bundle.paths.state, state, bundle.stateFile, 64 * 1024); + bundle.state = state; + bundle.stateFile = io.read(bundle.paths.state, 64 * 1024, true); +} +export function establish(io: StoreIO, owner: DesktopRemoteOwner, known: readonly string[], onlyLegacy: boolean): Bundle | null { + let bundle = loadBundle(io); + const terminalFile = io.read(bundle.paths.disconnect, 64 * 1024, true); + const terminal = terminalFile ? parseDisconnect(terminalFile.value) : null; + if (terminal?.phase === "complete" && !sameOwner(terminal.owner, owner)) { + currentConnection(owner); + if (bundle.baselineFile || (bundle.state && (bundle.state.phase !== "cleaned" || !sameOwner(bundle.state.owner, terminal.owner)))) throw new DesktopStoreError("conflict"); + if (bundle.stateFile) io.remove(bundle.paths.state, bundle.stateFile); + io.remove(bundle.paths.disconnect, terminalFile!); + bundle = loadBundle(io); + } + requireOwner(bundle, owner); + if (bundle.state && bundle.baseline) return bundle; + if (bundle.state?.phase === "cleaned") throw new DesktopStoreError("conflict"); + const meta = metadata(io, bundle.paths.library); + const selected = selectedFile(io, bundle.paths.library, meta.value); + const legacy = locateLegacy(io, owner, known); + if (onlyLegacy && !legacy && !bundle.state) return null; + const entry = meta.value.entries.find(e => e.id === meta.value.appliedId && e.name === "opencodex") + ?? meta.value.entries.find(e => e.name === "opencodex"); + const targetId = bundle.state?.targetId ?? legacy?.id ?? entry?.id ?? randomUUID(); + if (!meta.value.entries.some(e => e.id === targetId) && meta.value.entries.length >= MAX_DESKTOP_METADATA_ENTRIES) { + throw new DesktopStoreError("conflict"); + } + const target = io.read(profilePath(bundle.paths.library, targetId)); + const credential = profileCredential(target?.value ?? null); + if (credential && known.includes(credential.fingerprint) && credential.origin !== owner.serverUrl) throw new DesktopStoreError("conflict"); + const fallback = credential?.origin === owner.serverUrl; + if (fallback && (!known.includes(credential.fingerprint) || (entry?.name !== "opencodex" && legacy?.id !== targetId))) throw new DesktopStoreError("conflict"); + const priorSelection = meta.value.appliedId && selected && !(fallback && meta.value.appliedId === targetId) + ? { id: meta.value.appliedId, hash: selected.hash } : null; + const baseline: Baseline = { + version: 1, owner, home: bundle.paths.home, library: bundle.paths.library, targetId, + kind: fallback ? "standard_fallback" : "known", targetExisted: target !== null, + projection: fallback ? {} : projection(target?.value ?? {}), priorSelection, + }; + const baselineHash = digest(JSON.stringify(baseline, null, 2) + "\n"); + if (bundle.state && (bundle.state.phase !== "prepared" || bundle.state.baselineHash !== baselineHash + || bundle.state.lastProjectionHash !== projectionHash(target?.value ?? null))) throw new DesktopStoreError("conflict"); + if (!bundle.state) saveState(io, bundle, { + version: 1, owner, home: bundle.paths.home, library: bundle.paths.library, targetId, + baselineRef: "baseline.json", baselineHash, baselineKind: baseline.kind, phase: "prepared", + lastProjectionHash: projectionHash(target?.value ?? null), tokenFingerprint: known[0]!, + }); + io.write(bundle.paths.baseline, baseline, null); + bundle = loadBundle(io); + saveState(io, bundle, { ...bundle.state!, phase: "active" }); + return bundle; +} + +export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: ArtifactPending["kind"], tokenFingerprint: string, desiredSelection?: string | null): void { + const state = bundle.state!; + const targetPath = profilePath(bundle.paths.library, state.targetId); + let current = io.read(targetPath); + const meta = metadata(io, bundle.paths.library); + const existing = meta.value.entries.find(e => e.id === state.targetId); + // An absent row is valid only while a recorded first creation is unfinished. + // Keeping the original null projection receipt across apply -> restore proves it. + const uncommittedCreation = bundle.baseline?.targetExisted === false && state.lastProjectionHash === projectionHash(null); + if ((existing && existing.name !== "opencodex") || (!existing && !uncommittedCreation)) throw new DesktopStoreError("conflict"); + if (!existing && meta.value.entries.length >= MAX_DESKTOP_METADATA_ENTRIES) throw new DesktopStoreError("conflict"); + const before = projectionHash(current?.value ?? null), after = projectionHash(next); + const selection = meta.value.appliedId ?? null; + const pending = state.pending; + if (pending) { + if (pending.kind !== kind || pending.after !== after || pending.tokenFingerprint !== tokenFingerprint + || (before !== pending.before && before !== pending.after)) throw new DesktopStoreError("conflict"); + if (kind === "apply" && selection !== pending.beforeSelection && selection !== pending.afterSelection) throw new DesktopStoreError("conflict"); + } else if (before !== state.lastProjectionHash) throw new DesktopStoreError("conflict"); + const afterSelection = desiredSelection === undefined ? selection : desiredSelection; + const settledPhase = kind === "restore" ? "restored" : "active"; + if (!pending && existing && before === after && afterSelection === selection + && state.phase === settledPhase && state.tokenFingerprint === tokenFingerprint) return; + if (!pending) saveState(io, bundle, { ...state, pending: { kind, before, after, beforeSelection: selection, afterSelection, tokenFingerprint } }); + if (before !== after) { + io.write(targetPath, mergeProjection(current?.value ?? {}, next), current); + current = io.read(targetPath); + } + // Build from fresh metadata, so unrelated rows/fields are never restored from baseline bytes. + const fresh = metadata(io, bundle.paths.library); + const freshEntry = fresh.value.entries.find(e => e.id === state.targetId); + if ((fresh.value.appliedId ?? null) !== selection || freshEntry?.name !== existing?.name) throw new DesktopStoreError("conflict"); + if (!existing || afterSelection !== selection) { + if (!existing && fresh.value.entries.length >= MAX_DESKTOP_METADATA_ENTRIES) throw new DesktopStoreError("conflict"); + if (afterSelection && !io.read(profilePath(bundle.paths.library, afterSelection))) throw new DesktopStoreError("unsafe"); + const entries = existing ? fresh.value.entries : [...fresh.value.entries, { id: state.targetId, name: "opencodex" }]; + const value = { ...fresh.value, entries, ...(afterSelection ? { appliedId: afterSelection } : {}) }; + io.write(join(bundle.paths.library, "_meta.json"), value, fresh.file); + } + const { pending: _pending, ...settled } = bundle.state!; + saveState(io, bundle, { ...settled, phase: kind === "restore" ? "restored" : "active", lastProjectionHash: after, tokenFingerprint }); +} +export function cleanBackup(io: StoreIO, bundle: Bundle, known: readonly string[]): void { + const path = `${profilePath(bundle.paths.library, bundle.state!.targetId)}.bak`; + const file = io.read(path); + if (!file) return; + const credential = profileCredential(file.value); + if (!credential || credential.origin !== bundle.state!.owner.serverUrl) return; + if (!known.includes(credential.fingerprint)) throw new DesktopStoreError("conflict"); + const foreign = mergeProjection(file.value, {}); + try { + if (Object.keys(foreign).length) io.write(path, foreign, file); + else io.remove(path, file); + } catch (error) { + if (error instanceof DesktopStoreError) throw error; + throw new DesktopStoreError("cleanup_pending"); + } +} diff --git a/src/claude/desktop-remote-store-io.ts b/src/claude/desktop-remote-store-io.ts new file mode 100644 index 0000000000..3c73aaa4a1 --- /dev/null +++ b/src/claude/desktop-remote-store-io.ts @@ -0,0 +1,91 @@ +import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { atomicWriteFile, getConfigDir } from "../config"; +import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { isRecord, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library"; +import { DesktopStoreError, digest } from "./desktop-remote-store-state"; + +export const MAX_DESKTOP_METADATA_ENTRIES = 256; + +export interface JsonFile { value: Record; hash: string; identity: string } +const identity = (s: NonNullable>): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`; +function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; } +export function canonicalDirectory(path: string): string { + const absolute = resolve(path); + try { + const stat = lstatSync(absolute); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new DesktopStoreError("unsafe"); + return realpathSync(absolute); + } catch (error) { + if (!missing(error)) throw error; + const parent = dirname(absolute); + return parent === absolute ? absolute : join(canonicalDirectory(parent), basename(absolute)); + } +} +export function storePaths() { + const home = canonicalDirectory(getConfigDir()); + const library = canonicalDirectory(resolveDesktop3pConfigLibraryPath()); + const root = canonicalDirectory(join(home, "desktop-remote")); + return { home, library, root, state: join(root, "state.json"), baseline: join(root, "baseline.json"), disconnect: join(root, "disconnect.json") }; +} +export class StoreIO { + changed = false; + private bytes = 0; + private readonly seenSizes = new Map(); + read(path: string, maxBytes = 1024 * 1024, privateFile = false): JsonFile | null { + let stat: ReturnType; + try { stat = lstatSync(path); } + catch (error) { if (missing(error)) return null; throw new DesktopStoreError("unsafe"); } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes + || (privateFile && process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new DesktopStoreError("unsafe"); + const counted = this.seenSizes.get(path) ?? 0; + this.bytes += Math.max(0, stat.size - counted); + this.seenSizes.set(path, Math.max(counted, stat.size)); + if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe"); + try { + const bytes = readFileSync(path); + if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict"); + const value: unknown = JSON.parse(bytes.toString("utf8")); + if (!isRecord(value)) throw new DesktopStoreError("unsafe"); + return { value, hash: digest(bytes.toString("utf8")), identity: identity(stat) }; + } catch (error) { if (error instanceof DesktopStoreError) throw error; throw new DesktopStoreError("unsafe"); } + } + ensureDirectory(path: string): void { + canonicalDirectory(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + canonicalDirectory(path); + chmodSync(path, 0o700); + if (process.platform === "win32") hardenSecretDir(path, { required: true }); + } + compare(path: string, expected: JsonFile | null): void { + const fresh = this.read(path); + if (fresh?.hash !== expected?.hash || fresh?.identity !== expected?.identity) throw new DesktopStoreError("conflict"); + } + write(path: string, value: unknown, expected: JsonFile | null, maxBytes = 1024 * 1024): void { + const bytes = JSON.stringify(value, null, 2) + "\n"; + if (Buffer.byteLength(bytes) > maxBytes) throw new DesktopStoreError("unsafe"); + this.compare(path, expected); + this.ensureDirectory(dirname(path)); + atomicWriteFile(path, bytes, undefined, { validateBeforeRename: () => this.compare(path, expected) }); + this.changed = true; + } + remove(path: string, expected: JsonFile): void { + this.compare(path, expected); + unlinkSync(path); + this.changed = true; + } +} +export function metadata(io: StoreIO, library: string): { file: JsonFile | null; value: Desktop3pMetadata } { + const file = io.read(join(library, "_meta.json")); + if (!file) return { file, value: { entries: [] } }; + const value = file.value; + if (!Array.isArray(value.entries) || value.entries.length > MAX_DESKTOP_METADATA_ENTRIES) throw new DesktopStoreError("unsafe"); + const seen = new Set(); + for (const entry of value.entries) { + if (!isRecord(entry) || typeof entry.id !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(entry.id) + || seen.has(entry.id) || typeof entry.name !== "string") throw new DesktopStoreError("unsafe"); + seen.add(entry.id); + } + if (value.appliedId !== undefined && (typeof value.appliedId !== "string" || !seen.has(value.appliedId))) throw new DesktopStoreError("unsafe"); + return { file, value: value as unknown as Desktop3pMetadata }; +} diff --git a/src/claude/desktop-remote-store-state.ts b/src/claude/desktop-remote-store-state.ts new file mode 100644 index 0000000000..befec97f3c --- /dev/null +++ b/src/claude/desktop-remote-store-state.ts @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { OPENCODEX_DESKTOP_PROFILE_KEYS, SAFE_DESKTOP_PROFILE_ID, isRecord } from "./desktop-3p-library"; + +export type DesktopRemoteOwner = { serverUrl: string; apiKeyId: string; connectedAt: string }; +export type DesktopStoreResult = + | { ok: true; changed: boolean; status: "absent" | "applied" | "updated" | "restored"; + baselineKind?: "known" | "standard_fallback"; + restoration?: "owned_projection" | "standard_fallback" | "selection_preserved"; + retainedForeignData?: boolean; restartRequired: boolean; path?: string; fingerprint?: string } + | { ok: false; changed: boolean; reason: "busy" | "conflict" | "unsafe" | "recovery_required" | "cleanup_pending" | "desired_disabled" }; +export type StoreReason = Extract["reason"]; +export class DesktopStoreError extends Error { + constructor(readonly reason: StoreReason) { super(`desktop_store_${reason}`); } +} +export type Projection = Record; +export interface Baseline { + version: 1; owner: DesktopRemoteOwner; home: string; library: string; targetId: string; + kind: "known" | "standard_fallback"; targetExisted: boolean; projection: Projection; + priorSelection: { id: string; hash: string } | null; +} +export interface ArtifactPending { + kind: "apply" | "rotate" | "restore"; before: string; after: string; + beforeSelection: string | null; afterSelection: string | null; + tokenFingerprint: string; +} +export interface StoreState { + version: 1; owner: DesktopRemoteOwner; home: string; library: string; targetId: string; + baselineRef: "baseline.json"; baselineHash: string; baselineKind: Baseline["kind"]; + phase: "prepared" | "active" | "restored" | "cleaned"; + lastProjectionHash: string; tokenFingerprint: string; pending?: ArtifactPending; +} +export interface DesktopDisconnectReceipt { + version: 1; owner: DesktopRemoteOwner; tokenFingerprint: string; keepCatalog: boolean; + phase: "prepared" | "desktop_restored" | "catalog_settled" | "removing_token" | "token_removed" + | "clearing_connection" | "connection_cleared" | "complete"; + desktopAfterFingerprint?: string; + catalogAfter?: { kind: "absent" } | { kind: "file"; fingerprint: string }; +} +export const DISCONNECT_PHASES = ["prepared", "desktop_restored", "catalog_settled", "removing_token", "token_removed", "clearing_connection", "connection_cleared", "complete"] as const; +export function canonical(value: unknown): string { + const sort = (v: unknown): unknown => Array.isArray(v) ? v.map(sort) : isRecord(v) + ? Object.fromEntries(Object.keys(v).sort().map(k => [k, sort(v[k])])) : v; + const text = JSON.stringify(sort(value)); + if (text === undefined) throw new DesktopStoreError("unsafe"); + return text; +} +export function digest(value: string): string { return createHash("sha256").update(value).digest("hex"); } +export function projection(value: Record): Projection { + return Object.fromEntries(Object.entries(value).filter(([key]) => OPENCODEX_DESKTOP_PROFILE_KEYS.has(key))); +} +export function projectionHash(value: Record | null): string { + return digest(canonical(value === null ? null : projection(value))); +} +export function mergeProjection(current: Record, owned: Projection): Record { + return { ...Object.fromEntries(Object.entries(current).filter(([k]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(k))), ...owned }; +} +export function sameOwner(a: DesktopRemoteOwner, b: DesktopRemoteOwner): boolean { return canonical(a) === canonical(b); } +export function exact(value: Record, keys: string[]): void { + if (Object.keys(value).some(k => !keys.includes(k))) throw new DesktopStoreError("unsafe"); +} +export function origin(value: unknown): string { + if (typeof value !== "string") throw new DesktopStoreError("unsafe"); + let url: URL; + try { url = new URL(value); } catch { throw new DesktopStoreError("unsafe"); } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash + || !['/', '/v1', '/v1/'].includes(url.pathname)) throw new DesktopStoreError("unsafe"); + return url.origin; +} +export function parseOwner(value: unknown): DesktopRemoteOwner { + if (!isRecord(value)) throw new DesktopStoreError("unsafe"); + exact(value, ["serverUrl", "apiKeyId", "connectedAt"]); + if (typeof value.apiKeyId !== "string" || !value.apiKeyId || value.apiKeyId.length > 256 + || typeof value.connectedAt !== "string" || !Number.isFinite(Date.parse(value.connectedAt)) + || origin(value.serverUrl) !== value.serverUrl) throw new DesktopStoreError("unsafe"); + return value as DesktopRemoteOwner; +} +export function isHash(value: unknown): value is string { return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); } +export function parseState(value: unknown): StoreState { + if (!isRecord(value)) throw new DesktopStoreError("unsafe"); + exact(value, ["version", "owner", "home", "library", "targetId", "baselineRef", "baselineHash", "baselineKind", "phase", "lastProjectionHash", "tokenFingerprint", "pending"]); + parseOwner(value.owner); + if (value.version !== 1 || typeof value.home !== "string" || typeof value.library !== "string" + || typeof value.targetId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(value.targetId) + || value.baselineRef !== "baseline.json" || !isHash(value.baselineHash) || !isHash(value.lastProjectionHash) + || !isHash(value.tokenFingerprint) || !["known", "standard_fallback"].includes(String(value.baselineKind)) + || !["prepared", "active", "restored", "cleaned"].includes(String(value.phase))) throw new DesktopStoreError("unsafe"); + if (value.pending !== undefined) { + const p = value.pending; + if (!isRecord(p)) throw new DesktopStoreError("unsafe"); + exact(p, ["kind", "before", "after", "beforeSelection", "afterSelection", "tokenFingerprint"]); + if (!['apply', 'rotate', 'restore'].includes(String(p.kind)) || !isHash(p.before) || !isHash(p.after) || !isHash(p.tokenFingerprint) + || [p.beforeSelection, p.afterSelection].some(id => id !== null && (typeof id !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(id)))) throw new DesktopStoreError("unsafe"); + } + return value as unknown as StoreState; +} +export function parseBaseline(value: unknown): Baseline { + if (!isRecord(value)) throw new DesktopStoreError("unsafe"); + exact(value, ["version", "owner", "home", "library", "targetId", "kind", "targetExisted", "projection", "priorSelection"]); + parseOwner(value.owner); + if (value.version !== 1 || typeof value.home !== "string" || typeof value.library !== "string" + || typeof value.targetId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(value.targetId) + || !["known", "standard_fallback"].includes(String(value.kind)) || typeof value.targetExisted !== "boolean" + || !isRecord(value.projection) || Object.keys(value.projection).some(k => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(k))) throw new DesktopStoreError("unsafe"); + if (value.kind === "standard_fallback" && Object.keys(value.projection).length) throw new DesktopStoreError("unsafe"); + if (value.priorSelection !== null) { + const p = value.priorSelection; + if (!isRecord(p)) throw new DesktopStoreError("unsafe"); + exact(p, ["id", "hash"]); + if (typeof p.id !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(p.id) || !isHash(p.hash)) throw new DesktopStoreError("unsafe"); + } + return value as unknown as Baseline; +} +export function parseDisconnect(value: unknown): DesktopDisconnectReceipt { + if (!isRecord(value)) throw new DesktopStoreError("unsafe"); + exact(value, ["version", "owner", "tokenFingerprint", "keepCatalog", "phase", "desktopAfterFingerprint", "catalogAfter"]); + parseOwner(value.owner); + if (value.version !== 1 || !isHash(value.tokenFingerprint) || typeof value.keepCatalog !== "boolean" + || !DISCONNECT_PHASES.includes(value.phase as DesktopDisconnectReceipt['phase']) + || (value.desktopAfterFingerprint !== undefined && !isHash(value.desktopAfterFingerprint))) throw new DesktopStoreError("unsafe"); + if (value.catalogAfter !== undefined) { + const c = value.catalogAfter; + if (!isRecord(c)) throw new DesktopStoreError("unsafe"); + exact(c, c.kind === "absent" ? ["kind"] : ["kind", "fingerprint"]); + if (c.kind !== "absent" && (c.kind !== "file" || !isHash(c.fingerprint))) throw new DesktopStoreError("unsafe"); + } + return value as unknown as DesktopDisconnectReceipt; +} diff --git a/src/claude/desktop-remote-store.ts b/src/claude/desktop-remote-store.ts new file mode 100644 index 0000000000..3e18307c98 --- /dev/null +++ b/src/claude/desktop-remote-store.ts @@ -0,0 +1,269 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { readConfigDiagnostics, withConfigMutationLockSync } from "../config"; +import { assertClientLifecycleHeld, type ClientLifecycleHeld } from "../client/lifecycle-lock"; +import { readServiceApiTokenState, serviceApiTokenFingerprint } from "../lib/service-secrets"; +import { assertDesktop3pModelsValid } from "./desktop-3p-guard"; +import { profilePath } from "./desktop-3p-library"; +import type { Desktop3pConfigMode, Desktop3pModelEntry } from "./desktop-3p"; +import { StoreIO, metadata, storePaths } from "./desktop-remote-store-io"; +import { + DesktopStoreError, DISCONNECT_PHASES, canonical, origin, parseDisconnect, parseOwner, + projection, projectionHash, sameOwner, type DesktopDisconnectReceipt, type DesktopRemoteOwner, type DesktopStoreResult, +} from "./desktop-remote-store-state"; +import { + artifact, cleanBackup, currentConnection, establish, loadBundle, locateLegacy, profileCredential, + requireOwner, saveState, selectedFile, serviceGenerations, type Bundle, +} from "./desktop-remote-store-artifact"; +export type { DesktopDisconnectReceipt, DesktopRemoteOwner, DesktopStoreResult } from "./desktop-remote-store-state"; + +function mutation(held: ClientLifecycleHeld, operation: (io: StoreIO) => DesktopStoreResult): DesktopStoreResult { + assertClientLifecycleHeld(held); + const io = new StoreIO(); + try { return withConfigMutationLockSync(() => operation(io)); } + catch (error) { return { ok: false, changed: io.changed, reason: error instanceof DesktopStoreError ? error.reason : io.changed ? "recovery_required" : "unsafe" }; } +} +function absent(io?: StoreIO): DesktopStoreResult { return { ok: true, changed: io?.changed ?? false, status: "absent", restartRequired: false }; } +function success(io: StoreIO, bundle: Bundle, status: "applied" | "updated" | "restored", extra: Partial> = {}): DesktopStoreResult { + return { ok: true, changed: io.changed, status, baselineKind: bundle.state!.baselineKind, + restartRequired: io.changed, path: profilePath(bundle.paths.library, bundle.state!.targetId), + fingerprint: bundle.state!.lastProjectionHash, ...extra }; +} +function receipt(io: StoreIO): DesktopDisconnectReceipt | null { + const file = io.read(storePaths().disconnect, 64 * 1024, true); + return file ? parseDisconnect(file.value) : null; +} +function noDisconnect(io: StoreIO): void { + const value = receipt(io); + if (value && value.phase !== "complete") throw new DesktopStoreError("conflict"); +} +function knownGeneration(expected: string, candidates: readonly string[]): void { + if (!candidates.includes(expected)) throw new DesktopStoreError("conflict"); +} +function recordedGenerations(io: StoreIO, owner: DesktopRemoteOwner): string[] { + const bundle = loadBundle(io); + const terminal = receipt(io); + if (bundle.state?.phase === "cleaned" && terminal?.phase === "complete" + && sameOwner(bundle.state.owner, terminal.owner) && !sameOwner(owner, terminal.owner)) return serviceGenerations(); + requireOwner(bundle, owner); + return [...serviceGenerations(), ...(bundle.state ? [bundle.state.tokenFingerprint] : []), + ...(bundle.state?.pending ? [bundle.state.pending.tokenFingerprint] : [])]; +} + +export function inspectRemoteDesktopStore(owner: DesktopRemoteOwner): { + kind: "absent" | "active" | "pending" | "restored" | "legacy_current_connection" | "conflict" | "unsafe"; +} { + try { + parseOwner(owner); + const io = new StoreIO(), bundle = loadBundle(io); + if (bundle.state?.phase === "cleaned") { + const terminal = receipt(io); + if (terminal?.phase === "complete" && sameOwner(bundle.state.owner, terminal.owner) && !sameOwner(owner, terminal.owner)) return { kind: "absent" }; + } + requireOwner(bundle, owner); + if (bundle.state) { + if (bundle.state.phase === "prepared" || bundle.state.pending) return { kind: "pending" }; + if (bundle.state.phase === "cleaned") return { kind: "restored" }; + const meta = metadata(io, bundle.paths.library); + selectedFile(io, bundle.paths.library, meta.value); + if (!meta.value.entries.some(e => e.id === bundle.state!.targetId && e.name === "opencodex")) return { kind: "conflict" }; + const file = io.read(profilePath(bundle.paths.library, bundle.state.targetId)); + if (projectionHash(file?.value ?? null) !== bundle.state.lastProjectionHash) return { kind: "conflict" }; + return { kind: bundle.state.phase === "restored" ? "restored" : "active" }; + } + if (existsSync(bundle.paths.root) && !receipt(io)) throw new DesktopStoreError("unsafe"); + const legacy = locateLegacy(io, owner, serviceGenerations()); + if (legacy) currentConnection(owner); + return { kind: legacy ? "legacy_current_connection" : "absent" }; + } catch (error) { return { kind: error instanceof DesktopStoreError && error.reason === "conflict" ? "conflict" : "unsafe" }; } +} + +export function applyRemoteDesktopStore(held: ClientLifecycleHeld, options: { + owner: DesktopRemoteOwner; expectedTokenFingerprint: string; + baseUrl: string; apiKey: string; mode: Desktop3pConfigMode; models: Desktop3pModelEntry[]; +}): DesktopStoreResult { + return mutation(held, io => { + const { config, client } = currentConnection(options.owner); + noDisconnect(io); + if (client.pendingOperation) throw new DesktopStoreError("conflict"); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== options.expectedTokenFingerprint || token.fingerprint !== client.tokenFingerprint + || serviceApiTokenFingerprint(options.apiKey) !== token.fingerprint || origin(options.baseUrl) !== options.owner.serverUrl) throw new DesktopStoreError("conflict"); + if (config.clientIntegrations?.["claude-desktop"] === false) throw new DesktopStoreError("desired_disabled"); + if (!options.models.length || !["static", "hybrid", "discovery"].includes(options.mode)) throw new DesktopStoreError("unsafe"); + assertDesktop3pModelsValid(options.models); + const bundle = establish(io, options.owner, [token.fingerprint, ...serviceGenerations()], false)!; + const value = { + inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: options.owner.serverUrl, + inferenceGatewayApiKey: options.apiKey, modelDiscoveryEnabled: options.mode !== "static", + ...(options.mode === "discovery" ? {} : { inferenceModels: options.models }), + }; + artifact(io, bundle, value, "apply", token.fingerprint, bundle.state!.targetId); + cleanBackup(io, bundle, [token.fingerprint, ...serviceGenerations()]); + return success(io, bundle, "applied"); + }); +} + +export function replaceRemoteDesktopCredential(held: ClientLifecycleHeld, options: { + owner: DesktopRemoteOwner; expectedTokenFingerprint: string; replacementKey: string; +}): DesktopStoreResult { + return mutation(held, io => { + const { config, client } = currentConnection(options.owner); + noDisconnect(io); + const token = readServiceApiTokenState(); + const generations = recordedGenerations(io, options.owner); + const replacement = serviceApiTokenFingerprint(options.replacementKey); + if (token.kind !== "present" || replacement !== token.fingerprint + || (token.fingerprint !== client.tokenFingerprint && !client.pendingOperation)) throw new DesktopStoreError("conflict"); + knownGeneration(options.expectedTokenFingerprint, generations); + const bundle = establish(io, options.owner, [options.expectedTokenFingerprint, ...generations], true); + if (!bundle) return absent(io); + if (bundle.state!.phase === "restored") return restoreArtifact(io, bundle, generations); + if (config.clientIntegrations?.["claude-desktop"] === false) return restoreArtifact(io, bundle, generations); + const file = io.read(profilePath(bundle.paths.library, bundle.state!.targetId)); + if (!file) throw new DesktopStoreError("unsafe"); + const credential = profileCredential(file.value); + if (!credential || credential.origin !== options.owner.serverUrl + || ![options.expectedTokenFingerprint, replacement].includes(credential.fingerprint)) throw new DesktopStoreError("conflict"); + artifact(io, bundle, { ...projection(file.value), inferenceGatewayApiKey: options.replacementKey }, "rotate", replacement); + cleanBackup(io, bundle, [options.expectedTokenFingerprint, ...generations]); + return success(io, bundle, "updated"); + }); +} + +function restoreArtifact(io: StoreIO, bundle: Bundle, known: readonly string[]): DesktopStoreResult { + let state = bundle.state!; + const baseline = bundle.baseline; + if (!baseline) throw new DesktopStoreError("recovery_required"); + const meta = metadata(io, bundle.paths.library); + selectedFile(io, bundle.paths.library, meta.value); + let selection = meta.value.appliedId ?? state.targetId; + let restoration: "owned_projection" | "standard_fallback" | "selection_preserved" = baseline.kind === "standard_fallback" ? "standard_fallback" : "owned_projection"; + if (selection !== state.targetId) restoration = "selection_preserved"; + else if (baseline.priorSelection && baseline.priorSelection.id !== state.targetId) { + const priorSelection = baseline.priorSelection; + const prior = io.read(profilePath(bundle.paths.library, priorSelection.id)); + if (!prior || prior.hash !== priorSelection.hash || !meta.value.entries.some(e => e.id === priorSelection.id)) throw new DesktopStoreError("conflict"); + const key = profileCredential(prior.value); + if (key && known.includes(key.fingerprint)) throw new DesktopStoreError("conflict"); + selection = priorSelection.id; + } + const originalKey = profileCredential(baseline.projection); + if (originalKey && known.includes(originalKey.fingerprint)) throw new DesktopStoreError("conflict"); + const current = io.read(profilePath(bundle.paths.library, state.targetId)); + const retainedForeignData = current !== null && Object.keys(current.value).length > Object.keys(projection(current.value)).length; + if (state.pending && state.pending.kind !== "restore") { + const observed = projectionHash(current?.value ?? null); + if (observed !== state.pending.before && observed !== state.pending.after) throw new DesktopStoreError("conflict"); + const targetEntry = meta.value.entries.find(e => e.id === state.targetId); + const uncommittedCreation = !baseline.targetExisted && state.lastProjectionHash === projectionHash(null); + if ((targetEntry && targetEntry.name !== "opencodex") || (!targetEntry && !uncommittedCreation)) throw new DesktopStoreError("conflict"); + const tokenFingerprint = observed === state.pending.after ? state.pending.tokenFingerprint : state.tokenFingerprint; + // Transition the intent, not the last committed projection. A profile may + // already exist while its new metadata row has never been committed. + saveState(io, bundle, { ...state, tokenFingerprint, pending: { + kind: "restore", before: observed, after: projectionHash(baseline.projection), + beforeSelection: meta.value.appliedId ?? null, afterSelection: selection, tokenFingerprint, + } }); + state = bundle.state!; + } + artifact(io, bundle, baseline.projection, "restore", state.tokenFingerprint, selection); + cleanBackup(io, bundle, [...known, state.tokenFingerprint]); + return success(io, bundle, "restored", { restoration, retainedForeignData: !baseline.targetExisted && retainedForeignData }); +} + +export function restoreRemoteDesktopStore(held: ClientLifecycleHeld, options: { + owner: DesktopRemoteOwner; knownTokenFingerprints: readonly string[]; +}): DesktopStoreResult { + return mutation(held, io => { + currentConnection(options.owner); + const generations = recordedGenerations(io, options.owner); + if (options.knownTokenFingerprints.some(hash => !generations.includes(hash))) throw new DesktopStoreError("conflict"); + const bundle = establish(io, options.owner, generations, true); + if (!bundle) return absent(io); + return restoreArtifact(io, bundle, [...generations, ...options.knownTokenFingerprints]); + }); +} + +export function readDesktopDisconnectReceipt(): + | { kind: "absent" } | { kind: "valid"; value: DesktopDisconnectReceipt } | { kind: "unsafe" } { + try { const value = receipt(new StoreIO()); return value ? { kind: "valid", value } : { kind: "absent" }; } + catch { return { kind: "unsafe" }; } +} +export function writeDesktopDisconnectReceipt(held: ClientLifecycleHeld, expected: DesktopDisconnectReceipt | null, next: DesktopDisconnectReceipt): void { + assertClientLifecycleHeld(held); + try { + withConfigMutationLockSync(() => { + parseDisconnect(next); + if (expected) parseDisconnect(expected); + const io = new StoreIO(), paths = storePaths(); + const file = io.read(paths.disconnect, 64 * 1024, true); + const current = file ? parseDisconnect(file.value) : null; + if (canonical(current) !== canonical(expected)) throw new DesktopStoreError("conflict"); + const rollover = current?.phase === "complete" && next.phase === "prepared" && !sameOwner(current.owner, next.owner); + if (!current || rollover) { + if (next.phase !== "prepared") throw new DesktopStoreError("conflict"); + const { client } = currentConnection(next.owner); + const token = readServiceApiTokenState(); + if (client.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint || client.tokenFingerprint !== token.fingerprint) throw new DesktopStoreError("conflict"); + if (rollover) { + const bundle = loadBundle(io); + if (bundle.baselineFile || (bundle.state && (bundle.state.phase !== "cleaned" || !sameOwner(bundle.state.owner, current!.owner)))) throw new DesktopStoreError("conflict"); + if (bundle.stateFile) io.remove(paths.state, bundle.stateFile); + } + } else { + if (!sameOwner(current.owner, next.owner) || current.tokenFingerprint !== next.tokenFingerprint || current.keepCatalog !== next.keepCatalog) throw new DesktopStoreError("conflict"); + const delta = DISCONNECT_PHASES.indexOf(next.phase) - DISCONNECT_PHASES.indexOf(current.phase); + if (delta !== 0 && delta !== 1) throw new DesktopStoreError("conflict"); + if ((current.desktopAfterFingerprint && current.desktopAfterFingerprint !== next.desktopAfterFingerprint) + || (current.catalogAfter && canonical(current.catalogAfter) !== canonical(next.catalogAfter))) throw new DesktopStoreError("conflict"); + } + io.write(paths.disconnect, next, file, 64 * 1024); + }); + } catch (error) { + throw new Error(error instanceof DesktopStoreError && error.reason === "conflict" + ? "desktop_disconnect_receipt_conflict" : error instanceof DesktopStoreError && error.reason === "unsafe" + ? "desktop_disconnect_receipt_unsafe" : "desktop_disconnect_receipt_write_failed"); + } +} + +export function inspectRemoteDesktopCleanup(): + | { kind: "absent" } | { kind: "active" | "restored" | "pending"; owner: DesktopRemoteOwner } | { kind: "unsafe" } { + try { + const io = new StoreIO(), bundle = loadBundle(io), r = receipt(io); + if (r && bundle.state && !sameOwner(r.owner, bundle.state.owner)) throw new DesktopStoreError("unsafe"); + if (bundle.state) { + const state = bundle.state; + if (state.phase === "cleaned" && r?.phase === "complete") { + return bundle.baselineFile ? { kind: "pending", owner: state.owner } : { kind: "absent" }; + } + if (state.pending || state.phase === "prepared") return { kind: "pending", owner: state.owner }; + if (r && r.phase !== "complete" && state.phase === "cleaned") return { kind: "pending", owner: state.owner }; + return { kind: state.phase === "restored" || state.phase === "cleaned" ? "restored" : "active", owner: state.owner }; + } + if (r && r.phase !== "complete") return { kind: "pending", owner: r.owner }; + if (!r && existsSync(bundle.paths.root)) throw new DesktopStoreError("unsafe"); + return { kind: "absent" }; + } catch { return { kind: "unsafe" }; } +} + +export function finishRemoteDesktopCleanup(held: ClientLifecycleHeld, owner: DesktopRemoteOwner): DesktopStoreResult { + return mutation(held, io => { + const r = receipt(io); + if (!r || !sameOwner(r.owner, owner) || !["connection_cleared", "complete"].includes(r.phase)) throw new DesktopStoreError("conflict"); + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback" || diagnostics.config.client || diagnostics.config.runtimeRole === "client" + || readServiceApiTokenState().kind !== "absent") throw new DesktopStoreError("conflict"); + const bundle = loadBundle(io); + requireOwner(bundle, owner); + if (!bundle.state) return absent(io); + if (bundle.state.phase !== "restored" && bundle.state.phase !== "cleaned") throw new DesktopStoreError("conflict"); + const target = io.read(profilePath(bundle.paths.library, bundle.state.targetId)); + if (projectionHash(target?.value ?? null) !== bundle.state.lastProjectionHash) throw new DesktopStoreError("conflict"); + cleanBackup(io, bundle, [r.tokenFingerprint, bundle.state.tokenFingerprint]); + // Mark cleanup before unlink so a crash between the two remains recoverable. + if (bundle.state.phase !== "cleaned") saveState(io, bundle, { ...bundle.state, phase: "cleaned" }); + if (bundle.baselineFile) io.remove(bundle.paths.baseline, bundle.baselineFile); + return success(io, bundle, "restored", { restartRequired: false }); + }); +} diff --git a/src/claude/inbound-model-options.ts b/src/claude/inbound-model-options.ts index 9f4a354382..c3eabdf476 100644 --- a/src/claude/inbound-model-options.ts +++ b/src/claude/inbound-model-options.ts @@ -2,8 +2,9 @@ import type { OcxClaudeCodeConfig } from "../types"; import { isAnthropicOutputSchema } from "../adapters/anthropic-output-schema"; import { resolveAlias } from "./alias"; import { stripOneMillionMarker } from "./context-windows"; -import { resolveDesktop3pAlias } from "./desktop-3p"; -import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; +import { isUnresolvedDesktop3pAlias, resolveDesktop3pAlias } from "./desktop-3p"; +import { validDateAlias } from "./desktop-profile"; +import { AnthropicRequestError, DesktopModelMappingUnavailableError, isRec, type Rec } from "./inbound-records"; function isClaudeClassifierModel(model: string): boolean { const stripped = model.replace(/-\d{8}$/, ""); @@ -54,6 +55,13 @@ export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): st const map = cc?.modelMap ?? {}; const exact = map[model]; if (typeof exact === "string" && exact.length > 0) return exact; + if (isUnresolvedDesktop3pAlias(model)) { + const base = model.endsWith("--fast") ? model.slice(0, -"--fast".length) : model; + // A missing date-shaped ID is ambiguous even after a successful but partial + // discovery. Never infer that a genuine native model is invalid or reroute it. + if (validDateAlias(base)) throw new DesktopModelMappingUnavailableError(); + throw new AnthropicRequestError("Unknown Claude Desktop alias; reapply the Desktop profile from the connected hub"); + } const stripped = model.replace(/-\d{8}$/, ""); const dateless = map[stripped]; if (typeof dateless === "string" && dateless.length > 0) return dateless; @@ -88,17 +96,9 @@ export function effortFromOutputConfig(outputConfig: unknown): string | undefine return typeof effort === "string" && OUTPUT_CONFIG_EFFORTS.has(effort) ? effort : undefined; } -export function formatFromOutputConfig(outputConfig: unknown, outputFormat?: unknown): Rec | undefined { - const hasNested = isRec(outputConfig) && isRec(outputConfig.format); - const hasTop = isRec(outputFormat); - if (hasNested && hasTop) { - throw new AnthropicRequestError( - "Both output_format and output_config.format were provided. Please use only output_config.format (output_format is deprecated).", - ); - } - const rawFormat = hasNested ? outputConfig.format : hasTop ? outputFormat : undefined; - if (!isRec(rawFormat)) return undefined; - const format = rawFormat; +export function formatFromOutputConfig(outputConfig: unknown): Rec | undefined { + if (!isRec(outputConfig) || !isRec(outputConfig.format)) return undefined; + const format = outputConfig.format; if ( format.type !== "json_schema" || !isRec(format.schema) diff --git a/src/claude/inbound-records.ts b/src/claude/inbound-records.ts index a39dd88c41..eaa139ddfa 100644 --- a/src/claude/inbound-records.ts +++ b/src/claude/inbound-records.ts @@ -1,5 +1,12 @@ export class AnthropicRequestError extends Error {} +/** A date-shaped Desktop ID can also name a genuine native model absent from discovery. */ +export class DesktopModelMappingUnavailableError extends AnthropicRequestError { + constructor() { + super("Claude Desktop model mapping is unavailable; refresh model discovery or reapply the connected hub profile"); + } +} + export type Rec = Record; export function isRec(v: unknown): v is Rec { diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 96caa998f6..3ac4731385 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -1,31 +1,22 @@ /** * Claude Code inbound: Anthropic Messages API request -> internal /v1/responses body. * - * Design (devlog/260711_claude_inbound/010, 003_evidence.md + hardening slice): + * Design (devlog/260711_claude_inbound/010, 003_evidence.md): * - translate-and-replay: the produced body MUST pass the real responsesRequestSchema * parse so routing/OAuth/pool/failover are inherited unchanged. - * - thinking/redacted_thinking blocks are preserved as Responses reasoning items via - * the existing ocxr1 envelope (src/responses/reasoning-envelope.ts), keeping - * multiple-block order and interleaving with tool_use; malformed ocxr1 signatures - * (value starting with ocxr1: but failing decode) return 400. + * - thinking/redacted_thinking blocks on replay are DROPPED (v1 policy) — routed + * providers carry reasoning in Responses items/ocxr1 envelopes instead. * - thinking.budget_tokens is NEVER forwarded raw; it maps to an effort tier. * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity). */ import type { OcxClaudeCodeConfig } from "../types"; -import { isClaudeWebSearchToolName } from "./outbound"; -import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; -import { verifyDirectiveSignature } from "./directive-sign"; import { createHash } from "node:crypto"; -export { AnthropicRequestError } from "./inbound-records"; +export { AnthropicRequestError, DesktopModelMappingUnavailableError } from "./inbound-records"; export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, extractOcxRouteDirective, extractOcxEffortDirective } from "./inbound-model-options"; import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; -import { systemToInstructions } from "./inbound-content-options"; - -function uuid(): string { - return crypto.randomUUID().replace(/-/g, ""); -} +import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; @@ -78,53 +69,6 @@ function pushUserMessage(input: Rec[], blocks: Rec[]): void { input.push({ type: "message", role: "user", content: blocks }); } -function isToolSearchName(value: unknown): value is string { - return value === "tool_search" - || (typeof value === "string" && value.startsWith("tool_search_tool_")); -} - -function functionToolToResponses(raw: Rec): Rec | null { - if (typeof raw.name !== "string" || raw.name.length === 0 || !isRec(raw.input_schema)) return null; - return { - type: "function", - name: raw.name, - ...(typeof raw.description === "string" ? { description: raw.description } : {}), - parameters: raw.input_schema, - ...(raw.defer_loading === true ? { defer_loading: true } : {}), - ...(typeof raw.strict === "boolean" ? { strict: raw.strict } : {}), - }; -} - -function toolDefinitionsByName(tools: unknown): ReadonlyMap { - const definitions = new Map(); - if (!Array.isArray(tools)) return definitions; - for (const raw of tools) { - if (!isRec(raw)) continue; - const mapped = functionToolToResponses(raw); - if (mapped && typeof mapped.name === "string") definitions.set(mapped.name, mapped); - } - return definitions; -} - -function toolSearchOutputItem(raw: Rec, definitions: ReadonlyMap): Rec | null { - if (typeof raw.tool_use_id !== "string" || raw.tool_use_id.length === 0) { - throw new AnthropicRequestError("tool_search_tool_result requires tool_use_id"); - } - const content = isRec(raw.content) ? raw.content : {}; - const failed = content.type === "tool_search_tool_result_error"; - const names = Array.isArray(content.tool_references) - ? content.tool_references.flatMap(ref => - isRec(ref) && typeof ref.tool_name === "string" ? [ref.tool_name] : []) - : []; - return { - type: "tool_search_output", - call_id: raw.tool_use_id, - status: failed ? "failed" : "completed", - execution: "client", - tools: names.flatMap(name => definitions.get(name) ?? []), - }; -} - /** * Bundled-skill elision for routed models (devlog 060). Claude Code loads a skill * by calling the `Skill` tool; the ~136k-token document bundle then rides the @@ -145,142 +89,6 @@ export function effectiveBlockedSkillNames(cc?: Pick isRec(b) && b.type === "text" && typeof b.text === "string") - .map(b => b.text as string); - } - return []; -} - -function scanSystemDirectives(body: unknown): ScannedDirectives { - const blocks = getSystemBlocks(body); - const routes: string[] = []; - const efforts: string[] = []; - const sigs: string[] = []; - const re = //g; - - for (const block of blocks) { - re.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = re.exec(block)) !== null) { - const kind = match[1]; - const val = match[2]?.trim() ?? ""; - if (kind === "route") routes.push(val); - else if (kind === "effort") efforts.push(val); - else if (kind === "sig") sigs.push(val); - } - } - return { routes, efforts, sigs }; -} - -export function extractSignedDirective(body: unknown): { - route: string | null; - effort: string | null; - signature: string | null; - version: string | null; -} { - const scanned = scanSystemDirectives(body); - const route = scanned.routes.length > 0 && scanned.routes[0] ? scanned.routes[0] : null; - const effort = scanned.efforts.length > 0 && scanned.efforts[0] ? scanned.efforts[0] : null; - let signature: string | null = null; - let version: string | null = null; - if (scanned.sigs.length > 0) { - const m = /^(v[0-9]+):([0-9a-fA-F]+)$/.exec(scanned.sigs[0].trim()); - if (m) { - version = m[1] ?? null; - signature = m[2] ?? null; - } - } - return { route, effort, signature, version }; -} - -export function verifyAndExtractDirectives( - body: unknown, - key: string, - allowLegacyDirective?: ( - route: string, - effort: NonNullable | null, - ) => boolean, -): { - route: string | null; - effort: NonNullable | null; - isSigned: boolean; - isLegacyMatch?: boolean; -} { - const scanned = scanSystemDirectives(body); - if (scanned.routes.length > 1 || scanned.efforts.length > 1 || scanned.sigs.length > 1) { - throw new AnthropicRequestError("conflicting subagent directives in system prompt"); - } - - if (scanned.sigs.length === 1) { - const rawSig = scanned.sigs[0].trim(); - const sigMatch = /^(v[0-9]+):([0-9a-fA-F]+)$/.exec(rawSig); - if (!sigMatch) { - throw new AnthropicRequestError("malformed signed subagent directive: invalid format"); - } - const version = sigMatch[1]; - const signature = sigMatch[2]; - if (version !== "v1") { - throw new AnthropicRequestError(`unsupported signed subagent directive version: ${version}`); - } - if (signature.length !== 64 || !/^[0-9a-f]{64}$/i.test(signature)) { - throw new AnthropicRequestError("malformed signed subagent directive: invalid signature length or encoding"); - } - if (scanned.routes.length === 0 || !scanned.routes[0].trim()) { - throw new AnthropicRequestError("malformed signed subagent directive: missing route"); - } - const route = scanned.routes[0].trim(); - const effort = scanned.efforts.length > 0 && scanned.efforts[0].trim() ? scanned.efforts[0].trim() : null; - const valid = verifyDirectiveSignature(route, effort, signature, key); - if (!valid) { - throw new AnthropicRequestError("invalid signed subagent directive: signature verification failed"); - } - const validEffort = effort && ["low", "medium", "high", "xhigh", "max"].includes(effort) - ? (effort as NonNullable) - : null; - return { - route, - effort: validEffort, - isSigned: true, - }; - } - - // Unsigned path - // If unsigned ocx-effort is present without ocx-route: it is ignored and does not override effort or routing. - if (scanned.routes.length === 0 || !scanned.routes[0].trim()) { - return { route: null, effort: null, isSigned: false, isLegacyMatch: false }; - } - - const route = scanned.routes[0].trim(); - const rawEffort = scanned.efforts.length > 0 && scanned.efforts[0].trim() ? scanned.efforts[0].trim() : null; - const effort = rawEffort && ["low", "medium", "high", "xhigh", "max"].includes(rawEffort) - ? (rawEffort as NonNullable) - : null; - - // Unsigned compatibility is opt-in and caller-authorized. Without the - // active-roster predicate, an untrusted prompt directive is ignored. - if (!allowLegacyDirective?.(route, effort)) { - return { route: null, effort: null, isSigned: false, isLegacyMatch: false }; - } - return { - route, - effort, - isSigned: false, - isLegacyMatch: true, - }; -} - /** Injected-skill payloads below this size are never stubbed (not worth it). */ const SKILL_ELISION_MIN_CHARS = 10_000; const SKILL_TEXT_MARKER = "Base directory for this skill: "; @@ -355,12 +163,7 @@ function systemMessageText(content: unknown): string { return parts.join("\n\n"); } -function userMessageToItems( - content: unknown, - input: Rec[], - elide: SkillElisionContext = NO_ELISION, - definitions: ReadonlyMap = new Map(), -): void { +function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionContext = NO_ELISION): void { if (typeof content === "string") { if (content.length > 0) pushUserMessage(input, [{ type: "input_text", text: content }]); return; @@ -394,13 +197,6 @@ function userMessageToItems( }); break; } - case "tool_search_tool_result": { - pushUserMessage(input, pending); - pending = []; - const item = toolSearchOutputItem(raw, definitions); - if (item) input.push(item); - break; - } case "document": // No Responses equivalent for raw document blocks; surface the title so the // model at least sees the attachment happened. @@ -413,11 +209,7 @@ function userMessageToItems( pushUserMessage(input, pending); } -function assistantMessageToItems( - content: unknown, - input: Rec[], - definitions: ReadonlyMap = new Map(), -): void { +function assistantMessageToItems(content: unknown, input: Rec[]): void { if (typeof content === "string") { if (content.length > 0) input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: content }] }); return; @@ -439,69 +231,12 @@ function assistantMessageToItems( if (typeof raw.id !== "string" || raw.id.length === 0 || typeof raw.name !== "string" || raw.name.length === 0) { throw new AnthropicRequestError("tool_use requires id and name"); } - // Lossless mapping for tool_search (Responses private tool_search_call) — reuse existing - // function_call wire where direct would collapse the tool identity. - if (isToolSearchName(raw.name) && !definitions.has(raw.name)) { - let args: string; - try { args = JSON.stringify(raw.input ?? {}); } catch { args = "{}"; } - input.push({ type: "tool_search_call", call_id: raw.id, arguments: args }); - break; - } input.push({ type: "function_call", call_id: raw.id, name: raw.name, arguments: JSON.stringify(raw.input ?? {}) }); break; } - case "server_tool_use": { - if (!isToolSearchName(raw.name)) break; - flush(); - if (typeof raw.id !== "string" || raw.id.length === 0) { - throw new AnthropicRequestError("server_tool_use requires id"); - } - let args: string; - try { args = JSON.stringify(raw.input ?? {}); } catch { args = "{}"; } - input.push({ type: "tool_search_call", call_id: raw.id, arguments: args }); - break; - } - case "tool_search_tool_result": { - flush(); - const item = toolSearchOutputItem(raw, definitions); - if (item) input.push(item); - break; - } - case "thinking": { - flush(); - const thinking = typeof raw.thinking === "string" ? raw.thinking : ""; - const signature = typeof raw.signature === "string" ? raw.signature : ""; - if (signature.startsWith(OCX_REASONING_PREFIX)) { - const owned = decodeReasoningEnvelope(signature); - if (!owned) throw new AnthropicRequestError("malformed ocxr1 reasoning signature"); - if (owned.sig) throw new AnthropicRequestError("OpenCodex reasoning continuity cannot be replayed as an Anthropic signature"); - } - // Preserve order with interleaved tool_use: each thinking block becomes its own reasoning item. - const encrypted = signature.length === 0 - ? undefined - : signature.startsWith(OCX_REASONING_PREFIX) - ? signature - : encodeReasoningEnvelope({ sig: signature }); - const summary = thinking.length > 0 ? [{ type: "summary_text", text: thinking }] : []; - // Always emit a reasoning item to preserve block order; empty thinking with a - // signature still carries replay continuity. Skip only fully empty blocks. - if (summary.length === 0 && !encrypted) break; - input.push({ - type: "reasoning", - id: `rs_${uuid()}`, - ...(summary.length > 0 ? { summary } : { summary: [] }), - ...(encrypted ? { encrypted_content: encrypted } : {}), - }); - break; - } - case "redacted_thinking": { - flush(); - const data = typeof raw.data === "string" ? raw.data : ""; - if (data.length === 0) break; - const encrypted = encodeReasoningEnvelope({ red: [data] } as any); - input.push({ type: "reasoning", id: `rs_${uuid()}`, summary: [], encrypted_content: encrypted }); - break; - } + case "thinking": + case "redacted_thinking": + break; // v1 policy: dropped on replay (003 evidence — safe for routed providers) default: break; } @@ -509,73 +244,6 @@ function assistantMessageToItems( flush(); } -function toolsToResponses(tools: unknown): Rec[] | undefined { - if (!Array.isArray(tools) || tools.length === 0) return undefined; - const out: Rec[] = []; - for (const raw of tools) { - if (!isRec(raw)) continue; - const type = typeof raw.type === "string" ? raw.type : ""; - if (type.startsWith("web_search")) { - out.push({ type: "web_search" }); // hosted sidecar path - continue; - } - if (type === "tool_search" || type.startsWith("tool_search_tool_")) { - out.push({ type: "tool_search" }); - continue; - } - const mapped = functionToolToResponses(raw); - if (mapped) { - out.push(mapped); - continue; - } - // Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop. - } - return out.length > 0 ? out : undefined; -} - -function findDeclaredTool(tools: unknown, name: string): Rec | undefined { - if (!Array.isArray(tools)) return undefined; - for (const raw of tools) { - if (!isRec(raw)) continue; - if (raw.name === name) return raw; - } - for (const raw of tools) { - if (!isRec(raw)) continue; - const type = typeof raw.type === "string" ? raw.type : ""; - if (type.startsWith("web_search") && isClaudeWebSearchToolName(name)) return raw; - if ((type === "tool_search" || type.startsWith("tool_search_tool_")) && isToolSearchName(name)) return raw; - } - return undefined; -} - -function toolChoiceToResponses(choice: unknown, body: Rec, rawTools?: unknown): void { - if (!isRec(choice)) return; - if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; - switch (choice.type) { - case "auto": body.tool_choice = "auto"; break; - case "none": body.tool_choice = "none"; break; - case "any": body.tool_choice = "required"; break; - case "tool": { - if (typeof choice.name !== "string" || choice.name.length === 0) { - throw new AnthropicRequestError("tool_choice.tool requires a name"); - } - // Anthropic represents hosted WebSearch as a named tool choice, while - // Responses requires the choice type to match the hosted declaration. - // Preserve forced-tool intent rather than weakening it to `auto`. - const declared = findDeclaredTool(rawTools, choice.name); - const declType = declared && typeof declared.type === "string" ? declared.type : ""; - if (declType.startsWith("web_search")) { - body.tool_choice = { type: "web_search" }; - } else if (declType === "tool_search" || declType.startsWith("tool_search_tool_")) { - body.tool_choice = { type: "tool_search" }; - } else { - body.tool_choice = { type: "function", name: choice.name }; - } - break; - } - default: break; - } -} /** Recursive canonical JSON (keys sorted at every depth) — stable cache-cohort input. */ function canonicalJson(value: unknown): string { @@ -626,11 +294,10 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode callIds: blockedSkillCallIds(raw.messages, blockedNames), names: blockedNames, }; - const definitions = toolDefinitionsByName(raw.tools); for (const msg of raw.messages) { if (!isRec(msg)) throw new AnthropicRequestError("each message must be an object"); - if (msg.role === "user") userMessageToItems(msg.content, input, elide, definitions); - else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, definitions); + if (msg.role === "user") userMessageToItems(msg.content, input, elide); + else if (msg.role === "assistant") assistantMessageToItems(msg.content, input); else if (msg.role === "system") { const text = systemMessageText(msg.content); if (text.length > 0) systemParts.push(text); @@ -649,9 +316,8 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode const tools = toolsToResponses(raw.tools); if (tools) body.tools = tools; - toolChoiceToResponses(raw.tool_choice, body, raw.tools); + toolChoiceToResponses(raw.tool_choice, body); - if (typeof raw.service_tier === "string" && raw.service_tier.length > 0) body.service_tier = raw.service_tier; if (typeof raw.max_tokens === "number") body.max_output_tokens = raw.max_tokens; if (typeof raw.temperature === "number") body.temperature = raw.temperature; if (typeof raw.top_p === "number") body.top_p = raw.top_p; @@ -659,7 +325,7 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode if (Array.isArray(raw.stop_sequences) && raw.stop_sequences.length > 0) { body.stop = raw.stop_sequences.filter((s): s is string => typeof s === "string"); } - const outputConfigFormat = formatFromOutputConfig(raw.output_config, raw.output_format); + const outputConfigFormat = formatFromOutputConfig(raw.output_config); if (outputConfigFormat) body.text = { format: outputConfigFormat }; let cacheKeySource: ClaudeCacheKeySource = null; if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index cbecf8c60a..665183c651 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -15,7 +15,7 @@ * - created_at is a fixed constant; max_input_tokens is authoritative-or-null; * max_tokens is always null (no authoritative output limit exists proxy-side). */ -import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog"; +import { orderForModelPicker, catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog"; import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias"; import { cursorFastIdFor } from "../adapters/cursor/catalog"; import { desktop3pAlias } from "./desktop-3p"; @@ -114,6 +114,7 @@ export function buildAnthropicModelInfos( // Presence is the feature gate: the caller passes undefined when `fastRows` is off, so a // default install publishes nothing. The predicate answers ELIGIBILITY, not enablement. fastRows?: (model: CatalogModel | { provider: string; id: string }) => boolean, + ordering?: { modelPickerOrder?: readonly string[]; featured?: readonly string[] }, ): AnthropicModelInfo[] { const out: AnthropicModelInfo[] = []; const seen = new Set(); @@ -143,14 +144,19 @@ export function buildAnthropicModelInfos( // the auto-context widening that let a 372K route carry the marker (and be // over-filled) is the #854 defect and does not come back. Guards (audit R1#11): // same dedupe set, never double-suffix. - const push1mVariant = (base: AnthropicModelInfo, contextWindow: number | undefined, maxInputTokens?: number) => { + const push1mVariant = ( + base: AnthropicModelInfo, + contextWindow: number | undefined, + maxInputTokens?: number, + selectorId?: string, + ) => { // The [1m] marker makes Claude Code account 1e6 tokens for the row, so it // may only name models whose AUTHORITATIVE effective window is >= 1M — // never the auto-context widening, which would mark a 372K route and have // Claude Code over-fill it (the #854 defect). if (contextWindow === undefined || contextWindow < ONE_MILLION) return; if (base.id.includes("[1m]")) return; - const id = `${base.id}[1m]`; + const id = selectorId ?? `${base.id}[1m]`; if (seen.has(id)) return; seen.add(id); // The marker fixes Claude Code's accounting at 1e6, but a model may accept less input @@ -193,6 +199,8 @@ export function buildAnthropicModelInfos( // omitting it would leave this surface without the model the feature exists for. if (fastRows?.({ provider: "native", id: slug }) === true) pushFastVariant(info); } + const nativeEnd = out.length; + const routedGroups = new Map(); for (const m of routedModels) { // Global Fast has no toggle on this surface, so the fast identity is what gets listed — // a client here can only pick a listed id. Limited to the readable CLI style: Desktop 3P @@ -206,6 +214,7 @@ export function buildAnthropicModelInfos( : aliasForRoute(m.provider, m.id); if (seen.has(id)) continue; seen.add(id); + const groupStart = out.length; const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : []; const imageInput = Array.isArray(m.inputModalities) ? m.inputModalities.includes("image") : false; // max_input_tokens is an input limit, so a row that publishes a lower input ceiling than @@ -220,11 +229,27 @@ export function buildAnthropicModelInfos( out.push(info); // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude // routes — only a genuine >=1M window earns the variant row there. - push1mVariant(info, m.contextWindow, routedMaxInput); + // Claude Code groups canonical Fable ids before it compares the [1m] marker. This + // reversible alias only separates picker families; it is not an OpenAI-native route. + // The Messages ingress restores the canonical Anthropic id before passthrough. + const oneMillionSelector = idStyle === "readable" + && m.provider === "anthropic" + && listedModelId.startsWith("claude-fable-") + ? `${claudeCodeNativeAlias(listedModelId)}[1m]` + : undefined; + push1mVariant(info, m.contextWindow, routedMaxInput, oneMillionSelector); // The whole model is passed, not a (provider, id) pair: a combo row lives in its own // namespace with no config.providers entry, so the caller classifies it from the // aggregated supportsServiceTier the row already carries. if (fastRows?.(m) === true) pushFastVariant(info); + routedGroups.set(m, out.slice(groupStart)); } - return out; + if (!ordering?.modelPickerOrder?.length) return out; + // Sort only after deduplication, preserving the registry's original collision winner + // and keeping each model's base/1M/Fast siblings together. + return [ + ...out.slice(0, nativeEnd), + ...orderForModelPicker([...routedGroups.keys()], ordering.modelPickerOrder, ordering.featured) + .flatMap(model => routedGroups.get(model)!), + ]; } diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index b21f72b85d..76c3456c9c 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -8,6 +8,7 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli"; import { isPublicOAuthProvider } from "../oauth/index"; import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry"; import type { OcxConfig } from "../types"; +import { projectCodexQuotaRefreshOutcome, type CodexQuotaRefreshOutcome } from "../codex/quota-refresh-outcome"; export type AccountType = "codex" | "oauth" | "api-key"; @@ -24,6 +25,7 @@ export interface AccountRow { /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ priority?: number; quota?: CodexQuotaDto | null; + quotaRefresh?: CodexQuotaRefreshOutcome; /** * Whether the pool is holding this account out of rotation. * @@ -237,6 +239,7 @@ interface CodexAccountDto { needsReauth?: boolean; priority?: number; quota?: CodexQuotaDto | null; + quotaRefresh?: unknown; paused?: boolean; } @@ -299,7 +302,10 @@ export async function fetchCodexRows( needsReauth: a.needsReauth, priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, - ...(includeQuota ? { quota: projectQuota(a.quota) } : {}), + ...(includeQuota ? { + quota: projectQuota(a.quota), + quotaRefresh: projectCodexQuotaRefreshOutcome(a.quotaRefresh), + } : {}), })); return { rows, activeId, autoSwitchThreshold, status: 200 }; } diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 82d6755e5b..38179aca42 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -367,6 +367,7 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< if (threshold !== undefined && (!Number.isInteger(threshold) || threshold < 0 || threshold > 100)) { return usage("Error: threshold must be an integer 0-100"); } + let settings: Record = {}; const baseUrl = await resolveBaseUrl(deps); if (!baseUrl) return proxyUnreachable(); if (action === "status") { @@ -378,13 +379,35 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< if (response.status !== 200 || (!genericPool && typeof response.json.autoSwitchThreshold !== "number")) { return apiError(response.json, "failed to read auto-switch status", response.status); } - threshold = typeof response.json.autoSwitchThreshold === "number" ? response.json.autoSwitchThreshold : 0; + settings = genericPool && (!response.json || typeof response.json !== "object" || Array.isArray(response.json)) + ? {} : response.json; + threshold = typeof settings.autoSwitchThreshold === "number" ? settings.autoSwitchThreshold : 0; } else { const response = genericPool ? await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/pool", { provider: name, autoSwitchThreshold: threshold }) : await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold }); if (response.status === 0) return proxyUnreachable(response.transportError); if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status); + settings = genericPool && (!response.json || typeof response.json !== "object" || Array.isArray(response.json)) + ? {} : response.json; + } + if (genericPool) { + // Generic thresholds are stored independently of the enabled override. The + // latter may inherit global preference and never disables reactive rotation. + const stored = settings.autoSwitchThreshold; + const storedThreshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100 + ? stored : null; + const poolEnabled = typeof settings.enabled === "boolean" ? settings.enabled : null; + const inert = settings.inert === true ? true : null; + // This CLI understands only the current inert generic threshold contract. + const enabled = false; + if (wantsJson) { + console.log(JSON.stringify({ provider: name, autoSwitchThreshold: storedThreshold, enabled, poolEnabled, inert }, null, 2)); + } else { + const value = storedThreshold === null ? "unset" : `${storedThreshold}%`; + console.log(`auto-switch: ${inert === true ? "inactive" : "unavailable"} (stored threshold ${value}; ${inert === true ? "not applied by this pool" : "threshold support is unknown"})`); + } + return 0; } const enabled = threshold! > 0; if (wantsJson) console.log(JSON.stringify({ provider: name, autoSwitchThreshold: threshold, enabled }, null, 2)); diff --git a/src/cli/aside-profiles.ts b/src/cli/aside-profiles.ts new file mode 100644 index 0000000000..5c16061c00 --- /dev/null +++ b/src/cli/aside-profiles.ts @@ -0,0 +1,17 @@ +import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh"; +import { runtimeRequest, RuntimeApiError, type RuntimeApiDeps } from "./runtime-api"; + +/** Aside policy and file writes share the running server's mutation owner. Never fall back locally. */ +export async function refreshAsideProfilesThroughServer( + deps: RuntimeApiDeps = {}, +): Promise { + const result = await runtimeRequest<{ results?: OwnedIntegrationRefreshOutcome[] }>( + "/api/client-integrations/aside/sync", + { method: "POST", body: "{}" }, + deps, + ); + if (!Array.isArray(result.results)) { + throw new RuntimeApiError("The running proxy does not support Aside profile synchronization", 502, result); + } + return result.results; +} diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 026c5f51e8..e1a589c3c2 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -542,6 +542,45 @@ export const CAPABILITIES: readonly Capability[] = [ "Each client has its own route because a toggle rewrites that client's own config file.", ], }, + { + command: ["integration", "client"], + summary: "Inspect and toggle Aside profile catalogs, read their history, and restore a selected profile operation.", + routes: [ + { method: "GET", path: "/api/client-integrations/aside/profiles" }, + { method: "PUT", path: "/api/client-integrations/aside/profiles" }, + { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}" }, + { method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}" }, + { method: "GET", path: "/api/client-integrations/aside/profiles/journal" }, + { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal" }, + { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore" }, + ], + flags: [ + { name: "--client", value: "string", summary: "Select the file integration; use aside for profile controls." }, + { name: "--profile", value: "number", summary: "Select one registered Aside account; omitted toggles affect all profiles." }, + { name: "--op", value: "string", summary: "Operation ID for restore." }, + { name: "--confirm-drift", value: "boolean", summary: "Explicitly allow restore to replace subsequent edits." }, + { name: "--overwrite-conflict", value: "boolean", summary: "Explicitly allow enable to replace a conflicting provider block." }, + { name: "--json", value: "boolean", summary: "Emit the profile state, history, or mutation result as JSON." }, + ], + mutates: true, + json: "payload", + details: [ + "Use status/show/list, history/journal, enable/disable, or restore after integration client.", + "These declarations cover the dedicated Aside profile paths; existing generic client routes retain their separate parity inventory.", + ], + }, + { + command: ["sync"], + summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.", + routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }], + flags: [ + { name: "--restart-codex", value: "boolean", summary: "Restart Codex app-servers after a catalog or cache write." }, + { name: "--restart-desktop-app", value: "boolean", summary: "Restart the Codex desktop app after a catalog or cache write." }, + ], + mutates: true, + json: "none", + details: ["The Aside refresh uses the live server; other catalog synchronization also performs local work."], + }, { command: ["agent", "request-user-input"], summary: "Show or set whether default mode may ask the operator a question mid-task.", diff --git a/src/cli/claude-agent-startup-sync.ts b/src/cli/claude-agent-startup-sync.ts index 772a88ddee..71acbb1911 100644 --- a/src/cli/claude-agent-startup-sync.ts +++ b/src/cli/claude-agent-startup-sync.ts @@ -10,7 +10,7 @@ export interface ClaudeAgentStartupSyncDeps { } /** - * Keep the public readiness gate pending until both startup reconciliations have settled. + * Keep readiness pending until the roster and optional Desktop registry have settled. * * The Codex sync remains the authority for ready versus failed. Claude roster repair is * deliberately best-effort (#2200), but readiness must not become observable between the @@ -22,6 +22,7 @@ export async function reconcileClientStartupBeforeReady( readinessGate: ReadinessGate, syncCodex: (deferredGate: ReadinessGate) => Promise, syncClaudeRoster: () => Promise, + syncDesktopRegistry?: () => Promise, ): Promise { let codexReady = false; const deferredGate: ReadinessGate = { @@ -32,6 +33,7 @@ export async function reconcileClientStartupBeforeReady( const result = await syncCodex(deferredGate); await syncClaudeRoster(); + await syncDesktopRegistry?.(); if (codexReady) readinessGate.markReady(); return result; } diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 3d4ad70852..7ff04dfe4e 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,7 +1,12 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; -import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; -import { setIntegrationEnabled } from "../codex/desired-state"; +import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config"; +import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state"; +import { readClientConnectionState, assertClientConnectionUnchanged, assertNoClientDisconnectPending, type ClientConnectionState } from "../client/state"; +import { downloadDesktop3pModels, HubClientError, normalizeHubOrigin } from "../client/hub-client"; +import { readServiceApiTokenState } from "../lib/service-secrets"; +import { applyRemoteDesktopStore, type DesktopStoreResult } from "../claude/desktop-remote-store"; +import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; import { DESKTOP_FAMILIES, moveDesktopRoute, @@ -11,6 +16,7 @@ import { type DesktopProfile, } from "../claude/desktop-profile"; import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy"; import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -33,6 +39,9 @@ function printDesktopHelp(): void { } export interface ApplyProfileDeps { + downloadDesktop3pModelsImpl?: typeof downloadDesktop3pModels; + applyRemoteDesktopStoreImpl?: typeof applyRemoteDesktopStore; + lifecycleLockDeps?: ClientLifecycleLockDeps; findLiveProxyImpl?: typeof findLiveProxy; postApplyImpl?: ( mode: Desktop3pConfigMode, @@ -41,20 +50,110 @@ export interface ApplyProfileDeps { probeClaudeDesktopPolicy?: typeof import("../claude/desktop-policy").probeClaudeDesktopPolicy; } -export async function applyProfile( +/** Persist only the requested local profile, never an await-old whole configuration. */ +function saveLocalDesktopProfile( profile: DesktopProfile, + expectedProfile: DesktopProfile | undefined, + expectedConnection: ClientConnectionState, + deps: ApplyProfileDeps, +): void { + withClientLifecycleSync(() => { + const outcome = mutatePersistedConfig(current => { + assertNoClientDisconnectPending(); + if (expectedConnection.kind === "connected") { + assertClientConnectionUnchanged(expectedConnection.value); + if (expectedConnection.value.pendingOperation) throw new Error("client_rotation_pending"); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== expectedConnection.value.tokenFingerprint) { + throw new Error("client_token_changed"); + } + } else if (expectedConnection.kind !== "disconnected" || readClientConnectionState().kind !== "disconnected") { + throw new Error("client_connection_changed"); + } + if (JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(expectedProfile)) { + throw new Error("desktop_profile_changed"); + } + const changed = JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(profile); + if (changed) current.claudeCode = { ...(current.claudeCode ?? {}), desktopProfile: structuredClone(profile) }; + return { changed, value: undefined }; + }); + if (outcome.status === "unavailable") throw new Error("desktop_profile_save_unavailable"); + }, deps.lifecycleLockDeps); +} + +async function applyConnectedDesktopProfile( + mode: Desktop3pConfigMode, + connection: Extract, + deps: ApplyProfileDeps, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + try { + const token = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection.value); + if (connection.value.pendingOperation) throw new Error("client_rotation_pending"); + const current = readServiceApiTokenState(); + if (current.kind === "absent") throw new Error("client_token_absent"); + if (current.kind === "unsafe") throw new Error("client_token_unsafe"); + if (current.fingerprint !== connection.value.tokenFingerprint) { + throw new Error("client_token_mismatch"); + } + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) throw new Error("desktop_desired_state_write_failed"); + return current; + }), deps.lifecycleLockDeps); + const baseUrl = normalizeHubOrigin(connection.value.serverUrl); + let snapshot: Awaited>; + try { + snapshot = await (deps.downloadDesktop3pModelsImpl ?? downloadDesktop3pModels)(baseUrl, token.token); + } catch (error) { + return { ok: false, path: "", reason: error instanceof HubClientError ? error.code : "desktop_download_failed" }; + } + const result: DesktopStoreResult = withClientLifecycleSync(held => withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection.value); + const currentToken = readServiceApiTokenState(); + if (currentToken.kind !== "present" || currentToken.fingerprint !== connection.value.tokenFingerprint) { + throw new Error("client_connection_changed"); + } + if (!claudeDesktopIntegrationEnabledNow()) throw new Error("desired_state_changed"); + if (snapshot.models.length === 0) throw new Error("desktop_unavailable"); + return (deps.applyRemoteDesktopStoreImpl ?? applyRemoteDesktopStore)(held, { + owner: { serverUrl: connection.value.serverUrl, apiKeyId: connection.value.apiKeyId, connectedAt: connection.value.connectedAt }, + expectedTokenFingerprint: currentToken.fingerprint, + baseUrl, apiKey: currentToken.token, mode, models: snapshot.models, + }); + }), deps.lifecycleLockDeps); + if (!result.ok) return { ok: false, path: "", reason: `desktop_lifecycle_${result.reason}` }; + // Policy probes may spawn a process; perform them only after L/C have been released. + const policyWarning = claudeDesktopPolicyWarning((deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)()); + const fallbackWarning = result.baselineKind === "standard_fallback" + ? "Previous Desktop settings were not recorded. Disconnect will switch this managed profile to standard mode." + : undefined; + const warning = [fallbackWarning, policyWarning].filter(Boolean).join(" "); + return { ok: true, path: result.path ?? "", ...(warning ? { warning } : {}) }; + } catch (error) { + const message = error instanceof Error ? error.message : ""; + return { ok: false, path: "", reason: /^[a-z][a-z0-9_]{1,100}$/.test(message) ? message : "desktop_apply_failed" }; + } +} + +export async function applyProfile( + profile: DesktopProfile | undefined, mode: Desktop3pConfigMode, deps: ApplyProfileDeps = {}, ): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; } + const connection = readClientConnectionState(); + if (connection.kind === "connected") return applyConnectedDesktopProfile(mode, connection, deps); + if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; // Explicit apply is an enable action. Persist intent before any Desktop write // so a process crash cannot leave a gateway profile that startup immediately removes. const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return { ok: false, path: "", reason: desired.message }; const config = loadConfig(); const state = await buildClaudeDesktopState(config, profile); - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; - saveConfigPreservingClaudeCode(config); + saveLocalDesktopProfile(state.profile, config.claudeCode?.desktopProfile, connection, deps); const live = await (deps.findLiveProxyImpl ?? findLiveProxy)(); + assertNoClientDisconnectPending(); + if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed"); if (live) { // #859: the Desktop alias reverse-map is process-local. Applying through the // serving process installs the map there; a local-only write leaves the @@ -86,7 +185,8 @@ export async function applyProfile( // The toggle can persist OFF while fetchAllModels was awaiting (same race the // management writers fence). Re-read persisted intent immediately before the // writer; a lost race is a discriminated skip, not a write. - const { claudeDesktopIntegrationEnabledNow } = await import("../codex/desired-state"); + assertNoClientDisconnectPending(); + if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed"); if (!claudeDesktopIntegrationEnabledNow()) { return { ok: false, path: "", reason: "desired_state_changed" }; } @@ -103,8 +203,8 @@ export async function applyProfile( mode, state.profile, nativeContextLimits(config), + deps.lifecycleLockDeps, ); - const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)(); const warning = result.written ? claudeDesktopPolicyWarning(policyState) : undefined; return { @@ -134,12 +234,9 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const parsedMode = parseDesktop3pModeArgs(legacyFlags); if ("error" in parsedMode) { console.error(parsedMode.error); return 2; } try { - const config = loadConfig(); - const state = await buildClaudeDesktopState(config); - const result = await applyProfile(state.profile, parsedMode.mode, deps); + const result = await applyProfile(undefined, parsedMode.mode, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); - console.error("프로필은 저장되었지만 Claude Desktop 설정 파일에는 반영되지 않았습니다. 프록시 상태를 확인한 뒤 다시 적용해 주세요."); return 1; } console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); @@ -155,6 +252,17 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf } try { + const connection = readClientConnectionState(); + if (command === "import" && argv.includes("--apply")) assertNoClientDisconnectPending(); + if (command === "import" && argv.includes("--apply") && connection.kind !== "disconnected") { + throw new CliUsageError(connection.kind === "connected" + ? "Connected Desktop apply uses the hub profile. Import on the hub, then run ocx claude desktop apply here." + : "Client connection state is invalid; refusing import --apply."); + } + const localView = connection.kind === "connected"; + if (localView && ["show", "export", "move", "default", "import"].includes(command ?? "")) { + console.warn("Local client profile only; connected Desktop apply uses the hub profile."); + } const config = loadConfig(); // `status` is API-backed and must NOT build local state first: the whole point of the // route the GUI polls (/api/claude-desktop/status) is the applied-vs-desired comparison, @@ -178,7 +286,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const rest = argv.slice(1); const wantsJson = takeJsonFlag(rest); if (rest.length > 0) throw new CliUsageError("Usage: ocx claude desktop show [--json]"); - if (wantsJson) console.log(JSON.stringify(state)); + if (wantsJson) console.log(JSON.stringify(localView ? { ...state, scope: "local" } : state)); else { for (const family of DESKTOP_FAMILIES) { console.log(`${family.toUpperCase()}${state.profile.defaults[family] ? ` (default: ${state.profile.defaults[family]})` : ""}`); @@ -194,8 +302,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf if (!route || !isFamily(familyRaw) || flags.some(flag => flag !== "--default")) throw new CliUsageError("Usage: ocx claude desktop move [--default]"); if (!state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`); const profile = moveDesktopRoute(state.profile, route, familyRaw, flags.includes("--default")); - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile }; - saveConfigPreservingClaudeCode(config); + saveLocalDesktopProfile(profile, config.claudeCode?.desktopProfile, connection, deps); console.log(`${route} 모델을 ${familyRaw} 그룹으로 옮겼습니다.`); return 0; } @@ -205,8 +312,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const route = routeRaw === "none" ? null : routeRaw; if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`); const profile = setDesktopFamilyDefault(state.profile, familyRaw, route); - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile }; - saveConfigPreservingClaudeCode(config); + saveLocalDesktopProfile(profile, config.claudeCode?.desktopProfile, connection, deps); console.log(`${familyRaw} 기본 모델을 ${route ?? "없음"}으로 지정했습니다.`); return 0; } @@ -224,8 +330,11 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf if (!source || flags.some(flag => flag !== "--apply")) throw new CliUsageError("Usage: ocx claude desktop import [--apply]"); const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8"))); const reconciled = (await buildClaudeDesktopState(config, profile)).profile; - config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconciled }; - saveConfigPreservingClaudeCode(config); + if (flags.includes("--apply")) assertNoClientDisconnectPending(); + if (flags.includes("--apply") && readClientConnectionState().kind !== "disconnected") { + throw new CliUsageError("Client connection changed; refusing import --apply. Connected Desktop apply uses the hub profile."); + } + saveLocalDesktopProfile(reconciled, config.claudeCode?.desktopProfile, connection, deps); if (flags.includes("--apply")) { const result = await applyProfile(reconciled, "static", deps); if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 752d43a7e1..3506a2fa95 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -8,6 +8,8 @@ import { } from "../client/connect"; import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state"; import { readServiceApiTokenState } from "../lib/service-secrets"; +import type { ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { inspectRemoteDesktopStore } from "../claude/desktop-remote-store"; import type { OcxConnectedClientId } from "../types"; import { CliUsageError, @@ -22,6 +24,10 @@ import { type RuntimeApiDeps, } from "./runtime-api"; +export interface ClientCommandDeps extends RuntimeApiDeps { + lifecycleLockDeps?: ClientLifecycleLockDeps; +} + export const CONNECT_USAGE = `Usage: ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) @@ -52,10 +58,10 @@ export type ClientConnectionStatus = { rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe"; }; -export function collectClientConnectionStatus(now = Date.now()): ClientConnectionStatus { +export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDeps?: ClientLifecycleLockDeps): ClientConnectionStatus { const state = readClientConnectionState(); const tokenState = readServiceApiTokenState(); - const rotation = inspectClientRotationRecoveryGate(state).kind; + const rotation = inspectClientRotationRecoveryGate(state, lifecycleLockDeps).kind; let catalog: ClientConnectionStatus["catalog"] = "missing"; if (existsSync(DEFAULT_CATALOG_PATH)) { try { @@ -124,7 +130,7 @@ function statusLines(status: ClientConnectionStatus): string[] { ]; } -async function runRotate(argv: string[], deps: RuntimeApiDeps): Promise { +async function runRotate(argv: string[], deps: ClientCommandDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); const pairing = takeFlag(args, "--pairing-code-stdin"); @@ -136,13 +142,17 @@ async function runRotate(argv: string[], deps: RuntimeApiDeps): Promise { const value = new TextEncoder().encode(await readSecretLine(deps, pairing ? "pairing code" : "admin token")); const connection = await rotateConnectedClientKey({ credential: { kind: pairing ? "pairing-grant" : "admin", value }, - }, { fetchImpl: deps.fetchImpl }); - printData({ apiKeyId: connection.apiKeyId, rotation: "committed" }, wantsJson, [ - `Rotated connected API key ${connection.apiKeyId}; the previous key is no longer admitted.`, + }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps }); + const restartRequired = inspectRemoteDesktopStore({ serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt }).kind !== "absent"; + printData({ apiKeyId: connection.apiKeyId, rotation: connection.rotationOutcome, restartRequired }, wantsJson, [ + connection.rotationOutcome === "committed" + ? `Rotated connected API key ${connection.apiKeyId}; the previous key is no longer admitted.` + : `Rotation rolled back for API key ${connection.apiKeyId}; the previous key was retained or restored.`, + ...(restartRequired ? ["Fully quit and reopen Claude Desktop; a running app may still hold the previous credential."] : []), ]); } -async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise { +async function runConnect(argv: string[], deps: ClientCommandDeps): Promise { const args = [...argv]; const serverUrl = args.shift(); if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE); @@ -173,28 +183,28 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise { managementTransport, noSync, ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }), - }, { fetchImpl: deps.fetchImpl }); + }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps }); console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`); } -async function runRevoke(argv: string[], deps: RuntimeApiDeps): Promise { +async function runRevoke(argv: string[], deps: ClientCommandDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); const admin = takeFlag(args, "--admin-token-stdin"); if (!admin) throw new CliUsageError("revoke requires --admin-token-stdin", CONNECT_USAGE); rejectArgs(args, CONNECT_USAGE, { redactValues: true }); const value = new TextEncoder().encode(await readSecretLine(deps, "admin token")); - const result = await revokeConnectedClientKey({ kind: "admin", value }, { fetchImpl: deps.fetchImpl }); + const result = await revokeConnectedClientKey({ kind: "admin", value }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps }); printData(result, wantsJson, [`Revoked connected API key ${result.apiKeyId}. Disconnect this client next.`]); } -export async function handleConnectCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { +export async function handleConnectCommand(argv: string[], deps: ClientCommandDeps = {}): Promise { return runCliAction(async () => { if (argv[0] === "status") { const args = argv.slice(1); const wantsJson = takeFlag(args, "--json"); rejectArgs(args, CONNECT_USAGE, { redactValues: true }); - const status = collectClientConnectionStatus(); + const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps); printData(status, wantsJson, statusLines(status)); return; } @@ -210,13 +220,13 @@ export async function handleConnectCommand(argv: string[], deps: RuntimeApiDeps }); } -export async function handleDisconnectCommand(argv: string[]): Promise { +export async function handleDisconnectCommand(argv: string[], deps: Pick = {}): Promise { return runCliAction(async () => { const args = [...argv]; const keepCatalog = takeFlag(args, "--keep-catalog"); const wantsJson = takeFlag(args, "--json"); rejectArgs(args, DISCONNECT_USAGE, { redactValues: true }); - const result = await disconnectClient({ keepCatalog }); + const result = await disconnectClient({ keepCatalog }, deps); const payload = { ...result, revoke: { @@ -226,6 +236,9 @@ export async function handleDisconnectCommand(argv: string[]): Promise { }; printData(payload, wantsJson, [ "Disconnected locally; native Codex state was restored.", + ...(result.desktopRestoration === "standard_fallback" + ? ["Previous Desktop settings were not recorded; the managed profile was switched to standard mode."] : []), + ...(result.restartRequired ? ["Fully quit and reopen Claude Desktop; local cleanup cannot revoke an in-memory credential."] : []), `The hub key ${result.apiKeyId} is still valid. Revoke it from Integrations → API Keys.`, ]); }); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index a5a1c9c399..6d018536c7 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -14,6 +14,7 @@ import type { CliHead } from "./root"; import type { ReadyArgs } from "./ready"; import type { LivenessIo, LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; +import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh"; import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help"; import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state"; import { syncModelsToCodex } from "../codex/sync"; @@ -53,9 +54,9 @@ export interface CliDispatchDeps { type CommandRunner = (deps: CliDispatchDeps) => Promise; const commandRunners: Record = { - init: async deps => { + init: async () => { const { runInit } = await import("./init"); - await runInit(deps.args.slice(1)); + await runInit(); // runInit sets process.exitCode = 1 on stdin EOF/closed; preserve it. return Number(process.exitCode ?? 0); }, @@ -387,25 +388,38 @@ const commandRunners: Record = { if (restartDesktopApp) await handleDesktopAppRestart(console); } // `ocx sync` is a direct CLI path; it does not call the management - // `/api/sync` route. Refresh the already-connected MCode block here too, + // `/api/sync` route. Refresh already-connected file integrations here too, // after Codex has published the catalog that supplies its capabilities. - if (synced.status !== "refused" && live) { + if (synced.status !== "refused") { + const results: OwnedIntegrationRefreshOutcome[] = []; + if (live) { + try { + const config = deps.loadConfig(); + const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); + results.push(...await refreshOwnedCatalogIntegrations({ + models: async () => { + const { loadExportModels } = await import("../server/management/model-rows"); + return loadExportModels(config); + }, + config, + port: live.port, + }, ["mcode", "pi"])); + } catch (error) { + console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } + } + // Even without a live proxy, report why Aside could not sync. Its server + // owner is never bypassed, and another client's failure cannot hide it. try { - const config = deps.loadConfig(); - const { refreshOwnedIntegration } = await import("../integrations/owned-refresh"); - const result = await refreshOwnedIntegration({ - clientId: "mcode", - models: async () => { - const { loadExportModels } = await import("../server/management/model-rows"); - return loadExportModels(config); - }, - config, - port: live.port, - }); - if (result?.changed) console.log("MCode integration refreshed from the current catalog."); - else if (result?.reason) console.warn(`MCode integration was not refreshed: ${result.reason}`); + const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); + results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); } catch (error) { - console.warn(`MCode integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } + for (const result of results) { + const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; + if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`); } } return code; diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 73e47552d4..c576435432 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -171,12 +171,14 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep rejectArgs(args, USAGE); const spec = EXPORT_CLIENTS[client]; - const config = (deps.configImpl ?? loadConfig)(); const root = await runtimeBaseUrl(deps); const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); if (!Array.isArray(rows)) { throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); } + // Discovery can persist pending -> ready selection. Read from the caller's + // config source after the response, rather than filtering with a stale snapshot. + const config = (deps.configImpl ?? loadConfig)(); const models = exportModelsFromProxyRows(rows, config); // The text is the client's OWN format — YAML, TOML and JSON5 clients would // otherwise receive a JSON rendering their parser reads differently. diff --git a/src/cli/index.ts b/src/cli/index.ts index 3e54cb639b..384429d0f6 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -108,7 +108,7 @@ function reportShellHookFailure(result: { state: "installed" | "absent" | "faile } -import { removeOwnedConfigState } from "../lib/config-ownership"; +import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { initializeNodeLauncherContext } from "./launcher-context"; @@ -473,8 +473,8 @@ async function handleStart(options: { block?: boolean } = {}) { reportShellHookFailure(reconcileShellHook(systemEnv.injected)); await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start // Codex sync owns the ready/failed verdict, but its successful transition is - // deferred until the best-effort Claude roster reconciliation settles. This - // keeps /readyz closed across both startup writes without making an optional + // deferred until the best-effort Claude roster and Desktop registry settle. This + // keeps /readyz closed across startup initialization without making an optional // Claude integration failure prevent the proxy from starting. const startupSync = await reconcileClientStartupBeforeReady( readinessGate, @@ -482,6 +482,29 @@ async function handleStart(options: { block?: boolean } = {}) { () => systemEnv.injected ? Promise.resolve(null) : syncClaudeAgentDefsAtProxyStartup(config, port), + async () => { + try { + const { fetchAllModels } = await import("../server/management-api"); + const { desktopVisibleNativeSlugs } = await import("../codex/catalog"); + const { resolveCodexModelEntitlements } = await import("../codex/model-entitlements"); + const { buildDesktopDiscoveryInputs } = await import("../claude/desktop-discovery-inputs"); + const [models, modelEntitlements] = await Promise.all([ + fetchAllModels(config), + resolveCodexModelEntitlements(config, { clientVersion: null }), + ]); + const inputs = buildDesktopDiscoveryInputs({ + config, models, modelEntitlements, + desktopNativeCandidates: desktopVisibleNativeSlugs(config), + }); + buildDesktop3pRegistry( + inputs.nativeSlugs, inputs.routedModels, + config.claudeCode?.desktopProfile, inputs.nativeContextCap, + ); + } catch { + // Best-effort; model discovery can rebuild it. Never reflect credential or provider errors. + console.warn("[opencodex] Claude Desktop model registry could not be initialized at startup."); + } + }, ); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); // #1046: one warning per startup, after BOTH writes. The server's cache @@ -496,17 +519,6 @@ async function handleStart(options: { block?: boolean } = {}) { if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) { historyGuardian = startHistoryMigrationGuardian(); } - // Build Desktop 3P alias registry so inbound claude-opus-4-8-{code} aliases (and legacy claude-opus-4-{code}) decode correctly. - try { - const { fetchAllModels } = await import("../server/management-api"); - const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog"); - const models = filterCatalogVisibleModels(await fetchAllModels(config), config); - buildDesktop3pRegistry( - [...visibleNativeSlugs(config)], - models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), - config.claudeCode?.desktopProfile, - ); - } catch { /* best-effort — registry rebuilds on first /v1/models call */ } // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed // grok CLI can pick opencodex-routed models without manual config. No-op when ~/.grok is // absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown. @@ -1284,8 +1296,8 @@ async function handleUninstall() { } if (failures.length === 0) { - await runStep("opencodex config removed", () => { - const result = removeOwnedConfigState(getConfigDir()); + await runStep("opencodex config removed", async () => { + const result = await removeOwnedConfigAfterDesktopCleanup(observed); if (result.status === "absent") return false; if (result.status === "removed") return true; const residual = result.residualPaths.length > 0 diff --git a/src/cli/init.ts b/src/cli/init.ts index 7429248907..f310b7f796 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -3,24 +3,44 @@ import { modelSelectionGuidance } from "./model-selection-guidance"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { injectCodexConfig } from "../codex/inject"; -import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, initializePersistedConfigIfMissing, isValidProviderName, preserveOpenAiTierRollbackSnapshot, replacePersistedConfig } from "../config"; +import { classifyOpenAiTierBackup, ConfigMutationLockError, getConfigPath, getDefaultConfig, initializePersistedConfigIfMissing, isValidProviderName, observeInitialConfigState, preserveOpenAiTierRollbackSnapshot } from "../config"; +import { InitialConfigPublicationError } from "../config/initialize"; +import { redactUserPath } from "../lib/redact"; import { enrichProviderFromCatalog } from "../oauth/key-providers"; import { deriveInitProviders } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; -function createPrompt(): { ask(question: string): Promise; close(): void } { +class InitCancelledError extends Error { + constructor(readonly exitCode: 1 | 130) { + super(exitCode === 130 ? "Setup cancelled." : "stdin reached EOF while waiting for input. Re-run `ocx init` in an interactive terminal."); + } +} + +function createPrompt(): { ask(question: string): Promise; throwIfCancelled(): void; close(): void } { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); let closed = false; - rl.on("close", () => { closed = true; }); + let cancellation: InitCancelledError | undefined; + const onInterrupt = () => { + cancellation = new InitCancelledError(130); + rl.close(); + }; + rl.on("SIGINT", onInterrupt); + process.on("SIGINT", onInterrupt); + rl.on("close", () => { + closed = true; + cancellation ??= new InitCancelledError(1); + process.off("SIGINT", onInterrupt); + rl.off("SIGINT", onInterrupt); + }); return { ask(question: string): Promise { return new Promise((resolve, reject) => { if (closed) { - reject(new Error("stdin closed before the prompt could be answered")); + reject(cancellation ?? new InitCancelledError(1)); return; } const onClose = () => { - reject(new Error("stdin reached EOF while waiting for input")); + reject(cancellation ?? new InitCancelledError(1)); }; rl.once("close", onClose); rl.question(question, answer => { @@ -29,6 +49,9 @@ function createPrompt(): { ask(question: string): Promise; close(): void }); }); }, + throwIfCancelled() { + if (cancellation) throw cancellation; + }, close() { if (!closed) rl.close(); }, @@ -87,49 +110,22 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()): } catch { /* cleanup is best-effort; never block init on backup housekeeping */ } } -export function parseInitArgs(args: string[]): { yes: boolean; error?: string } { - const unknown = args.find(arg => arg !== "--yes"); - return unknown === undefined - ? { yes: args.includes("--yes") } - : { yes: false, error: `Unknown option: ${unknown}. Usage: ocx init [--yes]` }; -} - -export type InitOverwriteDecision = "create" | "replace" | "refuse" | "cancel"; - -export function decideInitOverwrite(existing: boolean, yes: boolean, isTTY: boolean, answer?: string): InitOverwriteDecision { - if (!existing) return "create"; - if (yes) return "replace"; - if (!isTTY) return "refuse"; - return /^(y|yes)$/i.test(answer?.trim() ?? "") ? "replace" : "cancel"; -} - -export async function runInit(args: string[] = []): Promise { - const parsedArgs = parseInitArgs(args); - if (parsedArgs.error) { - console.error(parsedArgs.error); - process.exitCode = 2; +export async function runInit(): Promise { + const initial = observeInitialConfigState(); + if (initial === "exists") { + console.log(`Keeping existing config at ${redactUserPath(getConfigPath())}. Use \`ocx config\` or the dashboard to update it.`); + return; + } + if (initial === "invalid") { + console.error(`Cannot initialize ${redactUserPath(getConfigPath())}: existing config is invalid, unreadable, or not a regular file. It has been preserved.`); + process.exitCode = 1; return; } const prompt = createPrompt(); + let configCreated = false; try { console.log("\n🔧 opencodex (ocx) setup\n"); - const existingConfig = existsSync(getConfigPath()); - let overwriteDecision = decideInitOverwrite(existingConfig, parsedArgs.yes, Boolean(process.stdin.isTTY)); - if (overwriteDecision === "refuse") { - console.error("❌ An opencodex config already exists. Re-run `ocx init --yes` to replace it."); - process.exitCode = 2; - return; - } - if (overwriteDecision === "cancel") { - const answer = await prompt.ask("Overwrite existing config? [y/N]: "); - overwriteDecision = decideInitOverwrite(true, false, true, answer); - if (overwriteDecision === "cancel") { - console.log("Keeping existing config."); - return; - } - } - const providers = buildInitProviders(); printMenu(providers); @@ -209,18 +205,14 @@ export async function runInit(args: string[] = []): Promise { modelDiscovery: { newModelPolicy: "off" }, }; - if (overwriteDecision === "replace") { - replacePersistedConfig(config); - } else { - const outcome = initializePersistedConfigIfMissing(config); - if (outcome !== "created") { - console.error(outcome === "exists" - ? "❌ Config was created by another process while setup was running; keeping that config." - : "❌ Config became invalid while setup was running; no changes were made."); - process.exitCode = 1; - return; - } + prompt.throwIfCancelled(); + const outcome = initializePersistedConfigIfMissing(config); + if (outcome !== "created") { + console.error("Config appeared or changed while setup was running; keeping it and stopping setup."); + process.exitCode = 1; + return; } + configCreated = true; // Init writes a fresh config, so a stale pre-migration backup from a previous // installation would make the next `ocx start` crash on a stale-backup // collision (issue #257). But only a STALE backup (unparseable, or already a @@ -228,23 +220,34 @@ export async function runInit(args: string[] = []): Promise { // valid pre-migration (v1) config is a user-intentional rollback point and is // preserved by renaming it out of the collision path (sol review 260722). cleanupOpenAiTierBackupAfterInit(); - console.log(`\n✅ Config saved to ~/.opencodex/config.json`); + console.log(`\n✅ Config saved to ${redactUserPath(getConfigPath())}`); if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`); const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: "); + prompt.throwIfCancelled(); if (injectAnswer.trim().toLowerCase() !== "n") { console.log("Fetching available models from provider..."); - const result = await injectCodexConfig(port, config); + const result = await injectCodexConfig(port, config, { + beforeClientWrite: () => prompt.throwIfCancelled(), + }).catch(error => { + // The injection/lock boundary may wrap the guard's cancellation error. + prompt.throwIfCancelled(); + throw error; + }); + prompt.throwIfCancelled(); console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`); } const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: "); + prompt.throwIfCancelled(); if (shimAnswer.trim().toLowerCase() !== "n") { try { const { installCodexShim } = await import("../codex/shim"); + prompt.throwIfCancelled(); const result = installCodexShim(); console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`); } catch (err) { + if (err instanceof InitCancelledError) throw err; console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`); } } @@ -252,13 +255,18 @@ export async function runInit(args: string[] = []): Promise { console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`); for (const line of modelSelectionGuidance(providerName)) console.log(line); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (/stdin (closed|reached EOF)/i.test(message)) { - console.error(`\n❌ ${message}. Re-run \`ocx init\` in an interactive terminal.`); + if (error instanceof InitCancelledError) { + console.error(`\n❌ ${error.message}${configCreated ? " The created config has been kept." : ""}`); + process.exitCode = error.exitCode; + } else { + const message = error instanceof InitialConfigPublicationError + ? `${error.message}${error.publication !== "not-published" ? " Config may already exist; inspect it before retrying." : ""}${error.residualTemp ? " A temporary file could not be removed; inspect the config directory." : ""}` + : error instanceof ConfigMutationLockError + ? "Config initialization could not acquire its write lock. Retry when the other config operation finishes." + : `Setup did not finish.${configCreated ? " The created config has been kept." : " Check the config directory and setup inputs before retrying."}`; + console.error(`\n❌ ${message}`); process.exitCode = 1; - return; } - throw error; } finally { prompt.close(); } diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 3b417632ff..bcb87d5d18 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -1,5 +1,6 @@ import { CliUsageError, + RuntimeApiError, csv, printData, rejectArgs, @@ -28,10 +29,25 @@ const GROK_USAGE = `Usage: ocx grok apply [--json]`; const CLIENT_USAGE = `Usage: - ocx integration client [status] [--client ] [--json] - ocx integration client --client [--overwrite-conflict] [--json] - ocx integration client history [--client ] [--json] - ocx integration client restore --op [--confirm-drift] [--json]`; + ocx integration client [status] [--client ] [--profile ] [--json] + ocx integration client --client [--profile ] [--overwrite-conflict] [--json] + ocx integration client history [--client ] [--profile ] [--json] + ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json] + --profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`; + +function validateAsideProfile(profile: string | undefined, client: string | undefined): void { + if (profile === undefined) return; + if (client !== "aside") throw new CliUsageError("--profile requires --client aside", CLIENT_USAGE); + if (!/^(0|[1-9][0-9]*)$/.test(profile) || !Number.isSafeInteger(Number(profile))) { + throw new CliUsageError("--profile must be a nonnegative integer account ID", CLIENT_USAGE); + } + +} + +function clientIntegrationPath(client: string, profile?: string): string { + const base = `/api/client-integrations/${encodeURIComponent(client)}`; + return client === "aside" ? `${base}/profiles${profile === undefined ? "" : `/${encodeURIComponent(profile)}`}` : base; +} function parseMap(raw: string): Record { if (raw === "-") return {}; @@ -163,16 +179,23 @@ export async function handleClientIntegrationCommand( const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); const wantsJson = takeFlag(args, "--json"); + const profile = takeOption(args, "--profile"); if (action === "status" || action === "show" || action === "list") { const client = takeOption(args, "--client"); + validateAsideProfile(profile, client); rejectArgs(args, CLIENT_USAGE); const path = client - ? `/api/client-integrations/${encodeURIComponent(client)}` + ? clientIntegrationPath(client, profile) : "/api/client-integrations"; const result = await runtimeRequest(path, {}, deps); const rows = (result as { clients?: Array> }).clients; - printData(result, wantsJson, rows + const profiles = (result as { profiles?: Array> }).profiles; + printData(result, wantsJson, profiles + ? profiles.length > 0 + ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) + : [String((result as { error?: string }).error ?? "No Aside profiles found.")] + : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) : summaryLines(result)); return; @@ -180,9 +203,11 @@ export async function handleClientIntegrationCommand( if (action === "history" || action === "journal") { const client = takeOption(args, "--client"); + validateAsideProfile(profile, client); rejectArgs(args, CLIENT_USAGE); - const query = client ? `?client=${encodeURIComponent(client)}` : ""; - const result = await runtimeRequest(`/api/client-integrations/journal${query}`, {}, deps); + const path = client === "aside" ? `${clientIntegrationPath(client, profile)}/journal` + : `/api/client-integrations/journal${client ? `?client=${encodeURIComponent(client)}` : ""}`; + const result = await runtimeRequest(path, {}, deps); const operations = (result as { operations?: Array> }).operations ?? []; printData(result, wantsJson, operations.length === 0 ? ["No integration operations recorded yet."] @@ -190,7 +215,8 @@ export async function handleClientIntegrationCommand( // `snapshot` is resolved against the disk by the route, so "expired" // here means the bytes are genuinely gone, not merely old. const backup = row.snapshot === "expired" ? "backup expired" : `op ${String(row.opId)}`; - return `${String(row.at)} ${String(row.clientId)} ${String(row.kind)} (${backup})`; + const owner = row.profileId === undefined ? String(row.clientId) : `${String(row.clientId)}:${String(row.profileId)}`; + return `${String(row.at)} ${owner} ${String(row.kind)} (${backup})`; })); return; } @@ -198,9 +224,12 @@ export async function handleClientIntegrationCommand( if (action === "restore") { const opId = takeOption(args, "--op") ?? takeOption(args, "--op-id"); const confirmDrift = takeFlag(args, "--confirm-drift"); + const client = takeOption(args, "--client"); + validateAsideProfile(profile, client); + if (client !== undefined && profile === undefined) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); - const result = await runtimeRequest("/api/client-integrations/restore", { + const result = await runtimeRequest(profile === undefined ? "/api/client-integrations/restore" : `${clientIntegrationPath("aside", profile)}/restore`, { method: "POST", body: JSON.stringify({ opId, confirmDrift }), }, deps); @@ -212,6 +241,7 @@ export async function handleClientIntegrationCommand( throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); + validateAsideProfile(profile, client); /* * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the * refusal is the default and the waiver has to be typed. @@ -232,7 +262,7 @@ export async function handleClientIntegrationCommand( if (overwriteConflict && action === "disable") { throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); } - const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}`, { + const result = await runtimeRequest(clientIntegrationPath(client, profile), { method: "PUT", // Sent only when asked for, so a proxy on an older build sees the request it // has always seen rather than an unknown field. @@ -240,7 +270,11 @@ export async function handleClientIntegrationCommand( ? { enabled: true, overwriteConflict: true } : { enabled: action === "enable" }), }, deps); - printData(result, wantsJson, [String((result as Record).message ?? `${client} ${action}d.`)]); + const batch = result as { ok?: boolean; message?: string; results?: Array> }; + printData(result, wantsJson, batch.results + ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}${row.residual === true ? " Recovery did not finish." : ""}${typeof row.snapshotPath === "string" ? ` Backup: ${row.snapshotPath}` : ""}`) + : [String(batch.message ?? `${client} ${action}d.`)]); + if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result); }); } diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index d56f5745e5..adcdea1095 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -39,7 +39,7 @@ import type { OpencodeProviderBlocks, OpencodeV2ProviderBlock, } from "../clients/config-export"; -import { visibleNativeSlugs } from "../codex/catalog"; +import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { providerCodexAccountMode } from "../providers/registry"; @@ -376,12 +376,17 @@ export function opencodeCatalogFromProxyRows( config: OcxConfig, ): OpencodeCatalogModel[] { const omitNative = providerCodexAccountMode("openai", config.providers?.openai) === "direct"; + const routedRows = rows.filter((row): row is OpencodeProxyModelRow & { provider: string; id: string } => + row.native !== true && typeof row.provider === "string" && typeof row.id === "string"); + const visibleRouted = new Set(filterCatalogVisibleModels(routedRows, config)); const seen = new Set(); const catalog: OpencodeCatalogModel[] = []; for (const row of rows) { const namespaced = row.namespaced?.trim(); if (!namespaced || row.disabled === true) continue; if (omitNative && row.native === true) continue; + if (row.native !== true && typeof row.provider === "string" && typeof row.id === "string" + && !visibleRouted.has(row)) continue; if (seen.has(namespaced)) continue; seen.add(namespaced); catalog.push({ @@ -632,14 +637,14 @@ export function opencodeNotFoundHint( } export async function cmdOpencode(args: string[]): Promise { - const config = loadConfig(); - const live = await ensureProxyForOpencode(config); + const startupConfig = loadConfig(); + const live = await ensureProxyForOpencode(startupConfig); if (!live) { console.error("❌ Proxy did not become healthy after starting."); return 1; } - const apiKey = opencodeApiKey(config); + const apiKey = opencodeApiKey(startupConfig); let proxyModels: OpencodeProxyModelRow[]; try { proxyModels = await fetchOpencodeProxyModels(live, apiKey); @@ -648,6 +653,8 @@ export async function cmdOpencode(args: string[]): Promise { console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); return 1; } + // /api/models may have completed and persisted initial provider selection. + const config = loadConfig(); const catalog = opencodeCatalogFromProxyRows(proxyModels, config); const blocks = buildOpencodeProviderBlocksFromCatalog(live.port, catalog, live.hostname, config); const baseUrl = blocks.v1.options.baseURL; diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index f6d7353280..7b05d56b9f 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -44,7 +44,7 @@ export class RuntimeApiError extends Error { export async function runtimeBaseUrl(deps: RuntimeApiDeps = {}): Promise { if (deps.baseUrl) return deps.baseUrl.replace(/\/$/, ""); - const live = await findLiveProxy(); + const live = await (deps.findLiveProxy ?? findLiveProxy)(); if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null); return `http://${probeHostname(live.hostname)}:${live.port}`; } @@ -79,7 +79,12 @@ function responseMessage(body: unknown, status: number): string { if (reason && reason !== primary) parts.push(`reason: ${reason}`); const hint = stringField(record, "hint"); if (hint && hint !== primary) parts.push(`hint: ${hint}`); - return parts.join("\n").slice(0, 1200); + const snapshotPath = stringField(record, "snapshotPath"); + const recovery = [ + ...(snapshotPath ? [`Backup: ${snapshotPath.slice(0, 32768)}`] : []), + ...(record.residual === true ? ["Automatic recovery did not finish; check the client configuration before retrying."] : []), + ]; + return [parts.join("\n").slice(0, 1200), ...recovery].join("\n"); } export async function runtimeRequest( diff --git a/src/cli/uninstall-client-state.ts b/src/cli/uninstall-client-state.ts new file mode 100644 index 0000000000..07afc01175 --- /dev/null +++ b/src/cli/uninstall-client-state.ts @@ -0,0 +1,78 @@ +import { getConfigDir } from "../config"; +import { disconnectClient } from "../client/connect"; +import { readClientConnectionState, sameClientConnectionOwner } from "../client/state"; +import { assertClientLifecycleHeld, withClientLifecycle } from "../client/lifecycle-lock"; +import { inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store"; +import { removeOwnedConfigState, type ConfigRemovalResult } from "../lib/config-ownership"; +import { sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; + +export interface UninstallClientStateDeps { + readConnection: typeof readClientConnectionState; + inspectDesktop: typeof inspectRemoteDesktopCleanup; + readReceipt: typeof readDesktopDisconnectReceipt; + disconnect: (options?: Parameters[0]) => Promise; + withLifecycle: typeof withClientLifecycle; + remove: () => ConfigRemovalResult; +} + +const defaults: UninstallClientStateDeps = { + readConnection: readClientConnectionState, + inspectDesktop: inspectRemoteDesktopCleanup, + readReceipt: readDesktopDisconnectReceipt, + disconnect: options => disconnectClient(options), + withLifecycle: withClientLifecycle, + remove: () => removeOwnedConfigState(getConfigDir()), +}; + +/** Restore connection-owned client artifacts before removing their ownership/recovery records. */ +export async function removeOwnedConfigAfterDesktopCleanup( + observed: UninstallObservation, + deps: UninstallClientStateDeps = defaults, +): Promise { + if (!sharedTeardownAuthorized(observed)) { + throw new Error("Client cleanup refused: service or proxy teardown is not proven."); + } + const state = deps.readConnection(); + if (state.kind !== "connected" && state.kind !== "disconnected") { + throw new Error("Client cleanup refused: connection state is invalid or mismatched."); + } + const desktop = deps.inspectDesktop(); + const receipt = deps.readReceipt(); + if (desktop.kind === "unsafe" || receipt.kind === "unsafe") { + throw new Error("Client cleanup refused: Desktop recovery state is unsafe."); + } + const interrupted = receipt.kind === "valid" && receipt.value.phase !== "complete" ? receipt.value : undefined; + if ((state.kind === "connected" && desktop.kind !== "absent" && !sameClientConnectionOwner(state.value, desktop.owner)) + || (interrupted && state.kind === "connected" && !sameClientConnectionOwner(state.value, interrupted.owner)) + || (interrupted && desktop.kind !== "absent" && !sameClientConnectionOwner(desktop.owner, interrupted.owner))) { + throw new Error("Client cleanup refused: Desktop recovery ownership is mismatched."); + } + if (state.kind === "connected" || interrupted) { + // Disconnect owns its N/L/C ordering. Do not hold L across this call, and + // preserve the frozen catalog choice when resuming an interrupted operation. + await deps.disconnect({ + expectedOwner: state.kind === "connected" ? { + serverUrl: state.value.serverUrl, apiKeyId: state.value.apiKeyId, connectedAt: state.value.connectedAt, + } : interrupted!.owner, + ...(interrupted ? { keepCatalog: interrupted.keepCatalog } : {}), + }); + } else if (desktop.kind !== "absent") { + throw new Error("Client cleanup refused: finish Desktop recovery before uninstalling."); + } + + // A connection can appear while asynchronous cleanup is finishing. The final + // inspection and actual remover share L, but never hold C while deleting its DB. + return deps.withLifecycle(async held => { + assertClientLifecycleHeld(held); + const latest = deps.readConnection(); + const latestDesktop = deps.inspectDesktop(); + const latestReceipt = deps.readReceipt(); + if (latest.kind !== "disconnected" + || latestDesktop.kind !== "absent" + || latestReceipt.kind === "unsafe" + || (latestReceipt.kind === "valid" && latestReceipt.value.phase !== "complete")) { + throw new Error("Client cleanup refused: connection or Desktop state changed before removal."); + } + return deps.remove(); + }); +} diff --git a/src/client/connect.ts b/src/client/connect.ts index a9f0d9881a..2b0e8efe08 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -6,7 +6,17 @@ import { unlinkSync, } from "node:fs"; import { hostname } from "node:os"; -import { atomicWriteFile, loadConfig } from "../config"; +import { atomicWriteFile, loadConfig, withConfigMutationLockSync } from "../config"; +import { claudeDesktopIntegrationEnabledNow } from "../codex/desired-state"; +import { + inspectRemoteDesktopStore, readDesktopDisconnectReceipt, writeDesktopDisconnectReceipt, + replaceRemoteDesktopCredential, restoreRemoteDesktopStore, finishRemoteDesktopCleanup, + type DesktopDisconnectReceipt, type DesktopRemoteOwner, type DesktopStoreResult, +} from "../claude/desktop-remote-store"; +import { + withClientLifecycle, withClientLifecycleSync, + type ClientLifecycleHeld, type ClientLifecycleLockDeps, +} from "./lifecycle-lock"; import { invalidateCodexModelsCache } from "../codex/catalog/sync"; import { injectCodexConfig, @@ -55,6 +65,7 @@ import { clearClientConnection, commitClientConnection, readClientConnectionState, + assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner, } from "./state"; class RotationRecoveryRequiredError extends Error { @@ -77,6 +88,7 @@ export interface ConnectOptions { export interface ClientConnectDeps { fetchImpl?: typeof fetch; now?: () => Date; + lifecycleLockDeps?: ClientLifecycleLockDeps; } export interface RotateClientOptions { @@ -178,168 +190,278 @@ async function rotationAuthority( return { kind: "gui-session", value: session }; } -function clearRotationState( +export type ClientRotationResult = OcxClientConnectionConfig & { + rotationOutcome: "committed" | "rolled_back"; +}; + +function desktopOwner(connection: OcxClientConnectionConfig): DesktopRemoteOwner { + return { serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt }; +} + +function requireDesktopResult(result: DesktopStoreResult): Extract { + if (!result.ok) throw new Error(`desktop_lifecycle_${result.reason}`); + return result; +} + +function assertRotationCandidates( connection: OcxClientConnectionConfig, - tokenFingerprint: string, -): OcxClientConnectionConfig { - const next = { ...connection, tokenFingerprint }; - delete next.pendingOperation; - commitClientConnection(next); - return next; + currentFingerprint: string, + backupFingerprint: string, +): void { + assertClientConnectionUnchanged(connection); + const current = readServiceApiTokenState(); + const backup = readTokenBackupState(); + if (current.kind !== "present" || backup.kind !== "present" + || current.fingerprint !== currentFingerprint || backup.fingerprint !== backupFingerprint) { + throw new RotationRecoveryRequiredError("rotation token generations changed; preserve recovery files"); + } +} + +function alignDesktopCredential( + held: ClientLifecycleHeld, + connection: OcxClientConnectionConfig, + previousFingerprint: string, + token: { token: string; fingerprint: string }, +): void { + withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection); + const current = readServiceApiTokenState(); + if (current.kind !== "present" || current.fingerprint !== token.fingerprint) { + throw new Error("client_token_changed"); + } + if (inspectRemoteDesktopStore(desktopOwner(connection)).kind === "absent") return; + let result: DesktopStoreResult; + if (!claudeDesktopIntegrationEnabledNow()) { + result = restoreRemoteDesktopStore(held, { + owner: desktopOwner(connection), knownTokenFingerprints: [connection.tokenFingerprint, current.fingerprint], + }); + } else { + result = replaceRemoteDesktopCredential(held, { + owner: desktopOwner(connection), expectedTokenFingerprint: previousFingerprint, replacementKey: current.token, + }); + if (!result.ok && !result.changed && result.reason === "conflict" && previousFingerprint !== current.fingerprint) { + // Recovery may find Desktop already on the chosen generation while only + // service-api-token needed rollback. Retry that exact, freshly proven + // current generation; never retry a partial write or an unsafe artifact. + result = replaceRemoteDesktopCredential(held, { + owner: desktopOwner(connection), expectedTokenFingerprint: current.fingerprint, replacementKey: current.token, + }); + } + } + requireDesktopResult(result); + }); +} + +function finalizeRotation( + held: ClientLifecycleHeld, + connection: OcxClientConnectionConfig, + previousFingerprint: string, + token: { token: string; fingerprint: string }, + rotationOutcome: ClientRotationResult["rotationOutcome"], +): ClientRotationResult { + try { + alignDesktopCredential(held, connection, previousFingerprint, token); + return withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection); + const current = readServiceApiTokenState(); + if (current.kind !== "present" || current.fingerprint !== token.fingerprint) throw new Error("client_token_changed"); + const next = { ...connection, tokenFingerprint: token.fingerprint }; + delete next.pendingOperation; + commitClientConnection(next); + removeOrphanTokenBackup(); + // Outcome is an API result only, never a persisted client configuration field. + return { ...next, rotationOutcome }; + }); + } catch { + throw new RotationRecoveryRequiredError("rotation local finalization is incomplete; preserve recovery files"); + } } async function recoverRotationWithAuthority( + held: ClientLifecycleHeld, connection: OcxClientConnectionConfig, authority: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession }, deps: ClientConnectDeps, -): Promise { - const pending = connection.pendingOperation; - if (!pending || pending.oldKeyBackupPath !== serviceApiTokenBackupPath()) { - throw new RotationRecoveryRequiredError("rotation recovery state is missing or invalid"); - } - const current = readServiceApiTokenState(); - const backup = readTokenBackupState(); - if (current.kind !== "present" || backup.kind !== "present") { - throw new RotationRecoveryRequiredError( - "rotation recovery requires owner-only current and .prev token files; preserve both and rerun ocx connect rotate with transient authority", - ); - } - let currentAccepted: boolean; - let backupAccepted: boolean; +): Promise { try { - [currentAccepted, backupAccepted] = await Promise.all([ + const pending = connection.pendingOperation; + if (!pending || pending.oldKeyBackupPath !== serviceApiTokenBackupPath()) throw new Error("invalid rotation marker"); + const current = readServiceApiTokenState(); + const backup = readTokenBackupState(); + if (current.kind !== "present" || backup.kind !== "present" || backup.fingerprint !== connection.tokenFingerprint) { + throw new Error("rotation recovery generations are unavailable"); + } + const assertFresh = () => withConfigMutationLockSync(() => assertRotationCandidates(connection, current.fingerprint, backup.fingerprint)); + assertFresh(); + if (current.fingerprint === backup.fingerprint) { + // A crash after the marker but before replacement leaves two copies of OLD. + // Their equal successful probes must never commit an uninstalled new hub key. + if (!await probeClientKeyId(connection.serverUrl, backup.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) { + throw new Error("old generation not admitted"); + } + assertFresh(); + await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl }); + if (!await probeClientKeyId(connection.serverUrl, backup.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) { + throw new Error("old generation not admitted after abort"); + } + assertFresh(); + return finalizeRotation(held, connection, backup.fingerprint, backup, "rolled_back"); + } + const [currentAccepted, backupAccepted] = await Promise.all([ probeClientKeyId(connection.serverUrl, current.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl }), probeClientKeyId(connection.serverUrl, backup.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl }), ]); + assertFresh(); + if (currentAccepted && backupAccepted) { + alignDesktopCredential(held, connection, backup.fingerprint, current); + await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl }); + assertFresh(); + return finalizeRotation(held, connection, backup.fingerprint, current, "committed"); + } + if (currentAccepted && !backupAccepted) { + return finalizeRotation(held, connection, backup.fingerprint, current, "committed"); + } + if (!currentAccepted && backupAccepted) { + // Remote abort is confirmed before either local credential is rolled back. + await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl }); + assertFresh(); + const restored = withConfigMutationLockSync(() => restoreTokenBackup(pending.oldKeyBackupPath)); + return finalizeRotation(held, connection, current.fingerprint, { token: backup.token, fingerprint: restored.fingerprint }, "rolled_back"); + } + throw new Error("both generations rejected"); } catch (error) { - throw new RotationRecoveryRequiredError( - "rotation recovery could not establish both key admissions; preserve service-api-token and .prev", - { cause: error }, - ); - } - if (currentAccepted && backupAccepted) { - await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl }); - const next = clearRotationState(connection, current.fingerprint); - removeOrphanTokenBackup(); - return next; - } - if (currentAccepted && !backupAccepted) { - const next = clearRotationState(connection, current.fingerprint); - removeOrphanTokenBackup(); - return next; - } - if (!currentAccepted && backupAccepted) { - const restored = restoreTokenBackup(pending.oldKeyBackupPath); - await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl }); - const next = clearRotationState(connection, restored.fingerprint); - removeOrphanTokenBackup(); - return next; + if (error instanceof RotationRecoveryRequiredError) throw error; + throw new RotationRecoveryRequiredError("rotation recovery could not settle admission and Desktop state; preserve current and backup tokens"); } - throw new RotationRecoveryRequiredError( - "both rotation candidates were rejected; preserve service-api-token and .prev and repair admission from the hub", - ); } export async function recoverPendingClientRotation( options: RotateClientOptions, deps: ClientConnectDeps = {}, -): Promise { +): Promise { try { - const state = readClientConnectionState(); - if (state.kind !== "connected" || !state.value.pendingOperation) { - throw new Error("no pending client key rotation to recover"); - } - const authority = await rotationAuthority(state.value, options, deps); - return await recoverRotationWithAuthority(state.value, authority, deps); - } finally { - releaseCredential(options.credential); - } + return await withClientLifecycle(async held => { + assertNoClientDisconnectPending(); + const state = readClientConnectionState(); + if (state.kind !== "connected" || !state.value.pendingOperation) throw new Error("no pending client key rotation to recover"); + const authority = await rotationAuthority(state.value, options, deps); + assertClientConnectionUnchanged(state.value); + return recoverRotationWithAuthority(held, state.value, authority, deps); + }, deps.lifecycleLockDeps); + } finally { releaseCredential(options.credential); } } export async function rotateConnectedClientKey( options: RotateClientOptions, deps: ClientConnectDeps = {}, -): Promise { +): Promise { + try { + return await withClientLifecycle(held => rotateConnectedClientKeyHeld(held, options, deps), deps.lifecycleLockDeps); + } finally { releaseCredential(options.credential); } +} + +async function rotateConnectedClientKeyHeld( + held: ClientLifecycleHeld, + options: RotateClientOptions, + deps: ClientConnectDeps, +): Promise { let connection: OcxClientConnectionConfig | null = null; let authority: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null; let started: { rotationId: string; key: string; createdAt: string } | null = null; let markerPersisted = false; + let backupCreated = false; + let hubCommitted = false; try { + assertNoClientDisconnectPending(); const state = readClientConnectionState(); - if (state.kind !== "connected") throw new Error(`connect rotate is available only while connected (${state.kind})`); + if (state.kind !== "connected") throw new Error("connect rotate is available only while connected"); connection = state.value; - authority = await rotationAuthority(connection, options, deps); - if (connection.pendingOperation) return await recoverRotationWithAuthority(connection, authority, deps); const current = readServiceApiTokenState(); - if (current.kind !== "present" || current.fingerprint !== connection.tokenFingerprint) { - throw new Error(current.kind === "unsafe" ? current.reason : "connected service token ownership changed"); + if (current.kind !== "present") throw new Error("connected service token unavailable"); + if (!connection.pendingOperation) { + if (current.fingerprint !== connection.tokenFingerprint) throw new Error("connected service token ownership changed"); + const desktop = inspectRemoteDesktopStore(desktopOwner(connection)); + if (["conflict", "unsafe", "pending"].includes(desktop.kind)) throw new Error("desktop_lifecycle_recovery_required"); + // Establish legacy fallback ownership and reject edited projections BEFORE hub issuance. + alignDesktopCredential(held, connection, current.fingerprint, current); + withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection!); + const orphan = readTokenBackupState(); + if (orphan.kind === "unsafe") throw new Error("service token backup is unsafe"); + if (orphan.kind === "present") removeOrphanTokenBackup(); + }); } - writeTokenBackup(current.fingerprint); + authority = await rotationAuthority(connection, options, deps); + assertClientConnectionUnchanged(connection); + if (connection.pendingOperation) return recoverRotationWithAuthority(held, connection, authority, deps); + withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection!); + writeTokenBackup(current.fingerprint); + }); + backupCreated = true; const rotation = await startClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, { fetchImpl: deps.fetchImpl }); started = { rotationId: rotation.rotationId, key: rotation.key, createdAt: rotation.createdAt }; const marked: OcxClientConnectionConfig = { ...connection, - pendingOperation: { - kind: "rotate", - rotationId: rotation.rotationId, - newKeyIssuedAt: rotation.createdAt, - oldKeyBackupPath: serviceApiTokenBackupPath(), - }, + pendingOperation: { kind: "rotate", rotationId: rotation.rotationId, newKeyIssuedAt: rotation.createdAt, oldKeyBackupPath: serviceApiTokenBackupPath() }, }; - commitClientConnection(marked); + withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection!); + commitClientConnection(marked); + }); connection = marked; markerPersisted = true; - const replacement = replaceServiceApiTokenFile(rotation.key); + const replacement = withConfigMutationLockSync(() => { + assertRotationCandidates(connection!, current.fingerprint, current.fingerprint); + return replaceServiceApiTokenFile(rotation.key); + }); + alignDesktopCredential(held, connection, current.fingerprint, { token: rotation.key, fingerprint: replacement.fingerprint }); if (!await probeClientKeyId(connection.serverUrl, rotation.key, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) { throw new Error("new client key admission probe was refused"); } + withConfigMutationLockSync(() => assertRotationCandidates(connection!, replacement.fingerprint, current.fingerprint)); try { await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, rotation.rotationId, { fetchImpl: deps.fetchImpl }); } catch { - return await recoverRotationWithAuthority(connection, authority, deps); + return await recoverRotationWithAuthority(held, connection, authority, deps); } - const next = clearRotationState(connection, replacement.fingerprint); - removeOrphanTokenBackup(); - return next; + hubCommitted = true; + return finalizeRotation(held, connection, current.fingerprint, { token: rotation.key, fingerprint: replacement.fingerprint }, "committed"); } catch (error) { - if (error instanceof RotationRecoveryRequiredError) throw error; + if (error instanceof RotationRecoveryRequiredError || hubCommitted) { + throw error instanceof RotationRecoveryRequiredError ? error : new RotationRecoveryRequiredError("committed rotation requires local recovery"); + } if (connection && authority && started) { - if (markerPersisted && connection.pendingOperation) { - try { - // Abort FIRST, restore second. - // - // The old order restored the local token and then asked the hub to abort. If that - // abort failed transiently the process was left holding the old key locally while - // the hub still had a pending rotation for the new one — two sides disagreeing - // about which generation is current, with the failure surfaced only as "rollback - // was incomplete". Confirming the hub's state first means the local file is only - // rewound once the authority that decides it has agreed. - await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl }); - const restored = restoreTokenBackup(connection.pendingOperation.oldKeyBackupPath); - clearRotationState(connection, restored.fingerprint); - removeOrphanTokenBackup(); - } catch (recoveryError) { - // Both candidates and the pending marker stay on disk. Recovery cannot tell which - // generation is authoritative without the hub, so it preserves the evidence and - // names the command that carries the authority to ask. - throw new RotationRecoveryRequiredError( - "rotation rollback was incomplete; preserve service-api-token and .prev and rerun ocx connect rotate with transient authority", - { cause: recoveryError }, - ); + try { + await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl }); + if (markerPersisted && connection.pendingOperation) { + const beforeRestore = readServiceApiTokenState(); + const backup = readTokenBackupState(); + if (beforeRestore.kind !== "present" || backup.kind !== "present") throw new Error("rotation rollback token unavailable"); + withConfigMutationLockSync(() => { + assertRotationCandidates(connection!, beforeRestore.fingerprint, backup.fingerprint); + restoreTokenBackup(connection!.pendingOperation!.oldKeyBackupPath); + }); + finalizeRotation(held, connection, beforeRestore.fingerprint, backup, "rolled_back"); + } else { + withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection!); + if (backupCreated) removeOrphanTokenBackup(); + }); } - } else { - try { await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl }); } - finally { removeOrphanTokenBackup(); } + } catch { + throw new RotationRecoveryRequiredError("rotation rollback was incomplete; preserve current and backup tokens"); } - } else { - const backup = readTokenBackupState(); - if (backup.kind === "present") removeOrphanTokenBackup(); + } else if (backupCreated && connection) { + withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(connection!); + removeOrphanTokenBackup(); + }); } throw error; } finally { if (started) started.key = ""; authority = null; - releaseCredential(options.credential); } } @@ -357,6 +479,16 @@ async function cleanupIssuedKey( } } +function assertConnectingState(expectedTokenFingerprint?: string): void { + assertNoClientDisconnectPending(); + if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed"); + const token = readServiceApiTokenState(); + if (expectedTokenFingerprint === undefined ? token.kind !== "absent" + : token.kind !== "present" || token.fingerprint !== expectedTokenFingerprint) { + throw new Error("client_token_changed"); + } +} + export async function connectClient( options: ConnectOptions, deps: ClientConnectDeps = {}, @@ -376,17 +508,11 @@ export async function connectClient( if (options.selectedClients.length < 1 || new Set(options.selectedClients).size !== options.selectedClients.length) { throw new Error("at least one unique connected client is required"); } - const state = readClientConnectionState(); - if (state.kind !== "disconnected") { - const detail = state.kind === "connected" ? "already connected" : state.reason; - throw new Error(`connect refused: client state is ${state.kind} (${detail})`); - } - const externalProvider = currentExternalCodexModelProvider(); - if (externalProvider) throw new Error(`connect refused: external Codex provider ${externalProvider} owns config.toml`); - const tokenState = readServiceApiTokenState(); - if (tokenState.kind !== "absent") { - throw new Error(tokenState.kind === "unsafe" ? tokenState.reason : "connect refused: service token file already exists"); - } + withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertConnectingState(); + const externalProvider = currentExternalCodexModelProvider(); + if (externalProvider) throw new Error("connect refused: an external Codex provider owns config.toml"); + }), deps.lifecycleLockDeps); const ready = await fetchHubReady(serverUrl, { fetchImpl: deps.fetchImpl }); if (ready.status !== "ready") throw new Error(`hub is not ready (${ready.status})`); @@ -405,16 +531,23 @@ export async function connectClient( } issued = await issueClientKey(managementUrl, cleanupCredential, clientKeyName(), { fetchImpl: deps.fetchImpl }); - priorCatalog = catalogSnapshot(); - const persisted = writeServiceApiTokenFile(issued.key); + const initialFiles = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertConnectingState(); + return { prior: catalogSnapshot(), persisted: writeServiceApiTokenFile(issued!.key) }; + }), deps.lifecycleLockDeps); + priorCatalog = initialFiles.prior; + const persisted = initialFiles.persisted; tokenFingerprint = persisted.fingerprint; const catalog = await downloadClientCatalog(serverUrl, issued.key, { fetchImpl: deps.fetchImpl, timeoutMs: options.catalogTimeoutMs, }); - atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body); - writtenCatalogFingerprint = sha256(catalog.body); + writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertConnectingState(persisted.fingerprint); + atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body); + return sha256(catalog.body); + }), deps.lifecycleLockDeps); const config = loadConfig(); const target = routingTarget(serverUrl); @@ -424,6 +557,7 @@ export async function connectClient( routingTarget: target, catalogPath: DEFAULT_CATALOG_PATH, journalOwner: { kind: "client", apiKeyId: issued.id }, + beforeClientWrite: () => assertConnectingState(persisted.fingerprint), }); if (!preflight.success) throw new Error(preflight.message); @@ -432,6 +566,7 @@ export async function connectClient( routingTarget: target, catalogPath: DEFAULT_CATALOG_PATH, journalOwner: { kind: "client", apiKeyId: issued.id }, + beforeClientWrite: () => assertConnectingState(persisted.fingerprint), }); if (!injected.success || injected.status === "skipped") throw new Error(injected.message); injectionCommitted = true; @@ -456,8 +591,11 @@ export async function connectClient( priorCatalog: priorCatalog.kind === "file" ? Buffer.from(priorCatalog.body, "utf8").toString("base64") : "", catalogSyncedAt: now, }; - commitClientConnection(connection); - committed = true; + withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertConnectingState(persisted.fingerprint); + commitClientConnection(connection); + committed = true; + }), deps.lifecycleLockDeps); return connection; } catch (error) { const rollbackFailures: string[] = []; @@ -465,13 +603,18 @@ export async function connectClient( const restored = restoreJournalState(); if (!restored.complete) rollbackFailures.push("Codex journal restore was partial"); } - if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) { - rollbackFailures.push("catalog rollback did not match the written artifact"); - } - if (tokenFingerprint) { - const removed = removeServiceApiTokenFileIfOwned(tokenFingerprint); - if (removed === "changed") rollbackFailures.push("service token changed during rollback"); - } + try { + withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertNoClientDisconnectPending(); + if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) { + rollbackFailures.push("catalog rollback did not match the written artifact"); + } + if (tokenFingerprint) { + const removed = removeServiceApiTokenFileIfOwned(tokenFingerprint); + if (removed === "changed") rollbackFailures.push("service token changed during rollback"); + } + }), deps.lifecycleLockDeps); + } catch { rollbackFailures.push("client cleanup ownership unavailable"); } let remoteCleanup: string | null = null; if (issued && cleanupCredential && managementUrl) { remoteCleanup = await cleanupIssuedKey(managementUrl, cleanupCredential, issued.id, deps); @@ -498,50 +641,60 @@ export async function syncConnectedClient( _options: { restartCodex?: boolean } = {}, deps: ClientConnectDeps = {}, ): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }> { - const state = readClientConnectionState(); - if (state.kind !== "connected") throw new Error(`connected sync refused: client state is ${state.kind}`); - const token = readServiceApiTokenState(); - if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) { - throw new Error(token.kind === "absent" ? "connected service token is missing" : "connected service token ownership changed"); - } - - let catalogWritten = false; + const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertNoClientDisconnectPending(); + const state = readClientConnectionState(); + if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("client_sync_unavailable"); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) throw new Error("client_token_changed"); + return { connection: state.value, token }; + }), deps.lifecycleLockDeps); + let downloaded: Awaited> | undefined; let stale = false; - let next = state.value; try { - const downloaded = await downloadClientCatalog(state.value.serverUrl, token.token, { - fetchImpl: deps.fetchImpl, - }); - atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); - catalogWritten = true; - const now = (deps.now ?? (() => new Date()))().toISOString(); - next = { - ...state.value, - catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"), - catalogSyncedAt: now, - }; - commitClientConnection(next); + downloaded = await downloadClientCatalog(initial.connection.serverUrl, initial.token.token, { fetchImpl: deps.fetchImpl }); } catch (error) { const transient = error instanceof HubClientError && (error.code === "unreachable" || (error.status !== undefined && error.status >= 500)); if (!transient) throw error; - validLocalCatalog(); stale = true; } - + const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + assertClientConnectionUnchanged(initial.connection); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== initial.token.fingerprint) throw new Error("client_token_changed"); + if (!downloaded) { validLocalCatalog(); return initial.connection; } + atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); + const updated = { + ...initial.connection, + catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"), + catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString(), + }; + commitClientConnection(updated); + return updated; + }), deps.lifecycleLockDeps); + // Read-only: injection invokes this while N/C are held. Acquiring L here would invert C→L. + const beforeClientWrite = () => { + assertClientConnectionUnchanged(next); + const token = readServiceApiTokenState(); + if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed"); + }; let injected = false; if (next.selectedClients.includes("codex")) { const config = loadConfig(); const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { - routingTarget: routingTarget(next.serverUrl), - catalogPath: DEFAULT_CATALOG_PATH, - journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, + routingTarget: routingTarget(next.serverUrl), catalogPath: DEFAULT_CATALOG_PATH, + journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite, }); if (!result.success || result.status === "skipped") throw new Error(result.message); injected = true; } - const cacheSynced = invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); - return { catalogWritten, cacheSynced, injected, stale }; + const cacheSynced = withClientLifecycleSync(() => { + beforeClientWrite(); + // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held. + return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); + }, deps.lifecycleLockDeps); + return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale }; } /** @@ -573,70 +726,168 @@ function restorePriorCatalog(connection: OcxClientConnectionConfig): "removed" | } } +const DISCONNECT_PHASES: readonly DesktopDisconnectReceipt["phase"][] = [ + "prepared", "desktop_restored", "catalog_settled", "removing_token", "token_removed", "clearing_connection", "connection_cleared", "complete", +]; + +function disconnectAtLeast(receipt: DesktopDisconnectReceipt, phase: DesktopDisconnectReceipt["phase"]): boolean { + return DISCONNECT_PHASES.indexOf(receipt.phase) >= DISCONNECT_PHASES.indexOf(phase); +} + +function catalogIsRecordedPrior(connection: OcxClientConnectionConfig, snapshot: CatalogSnapshot): boolean { + return snapshot.kind === "file" && !!connection.priorCatalog + && snapshot.fingerprint === sha256(Buffer.from(connection.priorCatalog, "base64").toString("utf8")); +} + +function preflightDisconnectCatalog(connection: OcxClientConnectionConfig, keepCatalog: boolean): void { + const snapshot = catalogSnapshot(); + if (!keepCatalog && snapshot.kind === "file" + && !catalogMatchesFingerprint(snapshot.body, connection.catalogFingerprint) + && !catalogIsRecordedPrior(connection, snapshot)) throw new Error("client_catalog_ownership_changed"); +} + +function catalogAfterState(): NonNullable { + const snapshot = catalogSnapshot(); + return snapshot.kind === "absent" ? { kind: "absent" } : { kind: "file", fingerprint: snapshot.fingerprint }; +} + +function verifyDisconnectCatalog(receipt: DesktopDisconnectReceipt): void { + if (!receipt.catalogAfter || JSON.stringify(catalogAfterState()) !== JSON.stringify(receipt.catalogAfter)) { + throw new Error("client_catalog_changed_during_disconnect"); + } +} + +function restoreConnectedCodex(connection: OcxClientConnectionConfig): void { + if (!connection.selectedClients.includes("codex")) return; + const owner = journalOwner(); + if (owner && owner.kind === "client" && owner.apiKeyId !== connection.apiKeyId) { + throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key"); + } + if (owner !== null) { + if (!restoreJournalState().complete) throw new Error("disconnect refused: Codex journal restore was partial"); + } else if (isCodexRoutingInjected()) { + throw new Error("disconnect refused: Codex routing is injected but no journal records the original state"); + } +} + export async function disconnectClient( - options: { keepCatalog?: boolean } = {}, + options: { keepCatalog?: boolean; expectedOwner?: DesktopRemoteOwner } = {}, + deps: Pick = {}, ): Promise<{ - restored: boolean; - tokenRemoved: boolean; - /** True when the catalog no longer holds remote bytes: removed outright or overwritten. */ - catalogRemoved: boolean; - /** True only when a recorded pre-connect catalog was written back. */ - catalogRestored: boolean; - apiKeyId: string; + restored: boolean; tokenRemoved: boolean; catalogRemoved: boolean; catalogRestored: boolean; apiKeyId: string; + desktopRestoration?: "owned_projection" | "standard_fallback" | "selection_preserved"; + restartRequired: boolean; }> { - const state = readClientConnectionState(); - if (state.kind !== "connected") throw new Error(`disconnect refused: client state is ${state.kind}`); - const token = readServiceApiTokenState(); - if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) { - throw new Error(token.kind === "absent" ? "disconnect refused: service token is missing" : "disconnect refused: service token ownership changed"); - } - - let restored = true; - if (state.value.selectedClients.includes("codex")) { - const owner = journalOwner(); - // A journal owned by this client key is ours, obviously. A journal owned by a PROCESS is - // also ours to unwind: it is what `ocx start` leaves behind, and connecting on top of it - // never transfers ownership — writeJournal() declines to overwrite a journal whose - // config is already injected, so the process owner survives into the connected state. - // - // Treating that as a conflict stranded the normal "start, then connect" path: disconnect - // refused, and nothing the operator could do would satisfy the check. The genuine - // conflict is a journal owned by a DIFFERENT client key, which is the one case where - // restoring would unwind somebody else's routing. - if ( - owner === null - || owner.kind === "process" - || owner.apiKeyId === state.value.apiKeyId - ) { - if (owner !== null) restored = restoreJournalState().complete; - else if (isCodexRoutingInjected()) { - // Injected routing with no journal at all: there is no recorded baseline to restore, - // so unwinding would be a guess about what the config looked like before. - throw new Error("disconnect refused: Codex routing is injected but no journal records the original state"); + const keepCatalog = options.keepCatalog === true; + const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => { + const read = readDesktopDisconnectReceipt(); + if (read.kind === "unsafe") throw new Error("client_disconnect_receipt_unsafe"); + const state = readClientConnectionState(); + const previous = read.kind === "valid" ? read.value : null; + const observedOwner = state.kind === "connected" ? desktopOwner(state.value) : previous?.owner; + if (options.expectedOwner && (!observedOwner || !sameClientConnectionOwner(observedOwner, options.expectedOwner))) { + throw new Error("client_disconnect_expected_owner_changed"); + } + let receipt = previous; + let connection: OcxClientConnectionConfig | null = null; + if (state.kind === "connected") { + connection = state.value; + if (connection.pendingOperation) throw new Error("client_rotation_recovery_required"); + if (receipt?.phase === "complete" && !sameClientConnectionOwner(receipt.owner, connection)) receipt = null; + if (receipt && !sameClientConnectionOwner(receipt.owner, connection)) throw new Error("client_disconnect_owner_changed"); + if (receipt?.phase === "complete") throw new Error("client_disconnect_completed_owner_reappeared"); + const token = readServiceApiTokenState(); + const expectedFingerprint = receipt?.tokenFingerprint ?? connection.tokenFingerprint; + if (connection.tokenFingerprint !== expectedFingerprint + || (token.kind === "present" ? token.fingerprint !== expectedFingerprint + : token.kind !== "absent" || !receipt || !disconnectAtLeast(receipt, "removing_token"))) { + throw new Error("client_token_changed"); } - } else { - throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key"); + if (!receipt) { + const desktop = inspectRemoteDesktopStore(desktopOwner(connection)); + if (desktop.kind === "unsafe" || desktop.kind === "conflict") throw new Error("desktop_lifecycle_unsafe"); + preflightDisconnectCatalog(connection, keepCatalog); + receipt = { version: 1, owner: desktopOwner(connection), tokenFingerprint: connection.tokenFingerprint, keepCatalog, phase: "prepared" }; + writeDesktopDisconnectReceipt(held, previous, receipt); + } + } else if (state.kind !== "disconnected" || !receipt || !disconnectAtLeast(receipt, "clearing_connection")) { + throw new Error("disconnect refused: no recoverable connected state"); } - if (!restored) throw new Error("disconnect refused: Codex journal restore was partial"); + if (!receipt || receipt.keepCatalog !== keepCatalog) throw new Error("client_disconnect_options_changed"); + return { receipt, connection }; + }), deps.lifecycleLockDeps); + + // The prepared receipt blocks even a sync queued for its actual N/C injection commit. + // Codex-only restore MUST stay outside L; it never invokes Desktop cleanup. + if (prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored")) { + restoreConnectedCodex(prepared.connection); } - const tokenRemoval = removeServiceApiTokenFileIfOwned(state.value.tokenFingerprint); - if (tokenRemoval === "changed") throw new Error("disconnect refused: service token changed before removal"); - let catalogRemoval: "removed" | "restored" | "absent" | "changed" = "absent"; - if (!options.keepCatalog) { - catalogRemoval = restorePriorCatalog(state.value); - if (catalogRemoval === "changed") throw new Error("disconnect refused: catalog ownership changed"); - } - if (clearClientConnection(state.value.apiKeyId) !== "committed") { - throw new Error("disconnect refused: client state changed before final commit"); - } - return { - restored, - tokenRemoved: tokenRemoval === "removed", - catalogRemoved: catalogRemoval === "removed" || catalogRemoval === "restored", - catalogRestored: catalogRemoval === "restored", - apiKeyId: state.value.apiKeyId, - }; + return withClientLifecycleSync(held => withConfigMutationLockSync(() => { + const read = readDesktopDisconnectReceipt(); + if (read.kind !== "valid" || JSON.stringify(read.value) !== JSON.stringify(prepared.receipt)) { + throw new Error("client_disconnect_receipt_changed"); + } + let receipt = read.value; + const state = readClientConnectionState(); + let connection: OcxClientConnectionConfig | null = null; + if (state.kind === "connected") { + if (!sameClientConnectionOwner(state.value, receipt.owner) || state.value.pendingOperation + || state.value.tokenFingerprint !== receipt.tokenFingerprint) throw new Error("client_disconnect_owner_changed"); + connection = state.value; + } else if (state.kind !== "disconnected" || !disconnectAtLeast(receipt, "clearing_connection")) { + throw new Error("client_disconnect_owner_changed"); + } + const token = readServiceApiTokenState(); + if (token.kind === "present" ? token.fingerprint !== receipt.tokenFingerprint + : token.kind !== "absent" || !disconnectAtLeast(receipt, "removing_token")) throw new Error("client_token_changed"); + const advance = (phase: DesktopDisconnectReceipt["phase"], fields: Partial = {}) => { + const next = { ...receipt, ...fields, phase }; + writeDesktopDisconnectReceipt(held, receipt, next); + receipt = next; + }; + let desktop: Extract; + if (!disconnectAtLeast(receipt, "desktop_restored")) { + desktop = requireDesktopResult(restoreRemoteDesktopStore(held, { + owner: receipt.owner, knownTokenFingerprints: [receipt.tokenFingerprint], + })); + advance("desktop_restored", desktop.fingerprint ? { desktopAfterFingerprint: desktop.fingerprint } : {}); + } else { + // A retry after token/config removal must verify the completed projection, + // not invoke a mutator that needs the now-removed connection credential. + const inspected = inspectRemoteDesktopStore(receipt.owner); + if (inspected.kind !== "restored" && !(inspected.kind === "absent" + && (!receipt.desktopAfterFingerprint || receipt.phase === "complete"))) { + throw new Error("desktop_disconnect_after_state_changed"); + } + desktop = { ok: true, changed: false, status: inspected.kind === "absent" ? "absent" : "restored", + restartRequired: receipt.desktopAfterFingerprint !== undefined }; + } + if (!disconnectAtLeast(receipt, "catalog_settled")) { + if (!connection) throw new Error("client_disconnect_catalog_context_missing"); + preflightDisconnectCatalog(connection, keepCatalog); + const snapshot = catalogSnapshot(); + if (!keepCatalog && snapshot.kind !== "absent" && !catalogIsRecordedPrior(connection, snapshot)) { + if (restorePriorCatalog(connection) === "changed") throw new Error("client_catalog_ownership_changed"); + } + advance("catalog_settled", { catalogAfter: catalogAfterState() }); + } else verifyDisconnectCatalog(receipt); + if (!disconnectAtLeast(receipt, "removing_token")) advance("removing_token"); + const tokenRemoval = removeServiceApiTokenFileIfOwned(receipt.tokenFingerprint); + if (tokenRemoval === "changed") throw new Error("client_token_changed"); + if (!disconnectAtLeast(receipt, "token_removed")) advance("token_removed"); + if (!disconnectAtLeast(receipt, "clearing_connection")) advance("clearing_connection"); + if (clearClientConnection(receipt.owner) === "conflict") throw new Error("client_disconnect_owner_changed"); + if (!disconnectAtLeast(receipt, "connection_cleared")) advance("connection_cleared"); + requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner)); + if (receipt.phase !== "complete") advance("complete"); + return { + restored: true, tokenRemoved: tokenRemoval === "removed", + catalogRemoved: !keepCatalog, catalogRestored: !keepCatalog && receipt.catalogAfter?.kind === "file", + apiKeyId: receipt.owner.apiKeyId, restartRequired: desktop.restartRequired, + ...(desktop.restoration ? { desktopRestoration: desktop.restoration } : {}), + }; + }), deps.lifecycleLockDeps); } export async function revokeConnectedClientKey( @@ -644,11 +895,13 @@ export async function revokeConnectedClientKey( deps: ClientConnectDeps = {}, ): Promise<{ apiKeyId: string }> { try { - const state = readClientConnectionState(); - if (state.kind !== "connected") throw new Error("connect revoke is available only while connected"); - await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl }); - return { apiKeyId: state.value.apiKeyId }; - } finally { - credential.value.fill(0); - } + return await withClientLifecycle(async () => { + assertNoClientDisconnectPending(); + const state = readClientConnectionState(); + if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("connect revoke requires a settled connection"); + await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl }); + assertClientConnectionUnchanged(state.value); + return { apiKeyId: state.value.apiKeyId }; + }, deps.lifecycleLockDeps); + } finally { credential.value.fill(0); } } diff --git a/src/client/hub-client.ts b/src/client/hub-client.ts index 6f0a9e7c07..43e7105e7e 100644 --- a/src/client/hub-client.ts +++ b/src/client/hub-client.ts @@ -1,6 +1,8 @@ import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download"; import { readBoundedResponseBytes } from "../lib/bounded-body"; import { clearableDeadline } from "../lib/abort"; +import type { Desktop3pModelEntry } from "../claude/desktop-3p"; +import { assertDesktop3pModelsValid } from "../claude/desktop-3p-guard"; /** * A pairing grant may cross loopback or authenticated HTTPS, and nothing else. @@ -30,6 +32,8 @@ import { const READY_BODY_LIMIT = 64 * 1024; const MANAGEMENT_BODY_LIMIT = 128 * 1024; const DEFAULT_TIMEOUT_MS = 5_000; +const DESKTOP_SNAPSHOT_MAX_BYTES = 1024 * 1024; +const DESKTOP_SNAPSHOT_MAX_ENTRIES = 2000; export type OneTimeConnectCredential = | { kind: "admin"; value: Uint8Array } @@ -97,6 +101,7 @@ async function fetchBounded( }); headerDeadline?.clear(); if (response.status >= 300 && response.status < 400 && response.status !== 304) { + try { await response.body?.cancel(); } catch { /* best effort */ } throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status); } return response; @@ -115,6 +120,7 @@ async function boundedText( ): Promise { const declared = Number(response.headers.get("content-length") ?? "0"); if (Number.isFinite(declared) && declared > maxBytes) { + try { await response.body?.cancel(); } catch { /* best effort */ } throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status); } const result = await readBoundedResponseBytes(response, { @@ -465,6 +471,81 @@ export async function downloadClientCatalog( return { kind: "fresh", body, ...(keyId ? { keyId } : {}) }; } +function desktopSnapshotModels(value: unknown): Desktop3pModelEntry[] { + const invalid = () => new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid"); + if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(); + const raw = value as Record; + if (raw.version !== 1) { + throw new HubClientError("desktop_snapshot_unsupported", "Hub Desktop model snapshot format is unsupported"); + } + if (!Array.isArray(raw.models) || raw.models.length > DESKTOP_SNAPSHOT_MAX_ENTRIES) throw invalid(); + const models: Desktop3pModelEntry[] = raw.models.map((row: unknown) => { + if (!row || typeof row !== "object" || Array.isArray(row)) throw invalid(); + const entry = row as Record; + const family = entry.anthropicFamilyTier; + if (typeof entry.name !== "string" || typeof entry.labelOverride !== "string" + || (family !== "opus" && family !== "fable" && family !== "sonnet" && family !== "haiku") + || (Object.hasOwn(entry, "isFamilyDefault") && typeof entry.isFamilyDefault !== "boolean") + || (Object.hasOwn(entry, "supports1m") && entry.supports1m !== true) + || (Object.hasOwn(entry, "prefer1m") && entry.prefer1m !== true)) throw invalid(); + return { + name: entry.name, + labelOverride: entry.labelOverride, + anthropicFamilyTier: family, + ...(typeof entry.isFamilyDefault === "boolean" ? { isFamilyDefault: entry.isFamilyDefault } : {}), + ...(entry.supports1m === true ? { supports1m: true as const } : {}), + ...(entry.prefer1m === true ? { prefer1m: true as const } : {}), + }; + }); + try { assertDesktop3pModelsValid(models); } catch { throw invalid(); } + return models; +} + +export async function downloadDesktop3pModels( + serverUrl: string, + admissionToken: string, + options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}, +): Promise<{ version: 1; models: Desktop3pModelEntry[] }> { + const origin = normalizeHubOrigin(serverUrl); + if (!isPairingTransportPermitted(origin)) { + throw new HubClientError("insecure_http_refused", "Desktop model snapshots require HTTPS or loopback HTTP"); + } + try { + // Keep fetchBounded's total request deadline active through body consumption; + // continuous progress must not extend a small Desktop snapshot download indefinitely. + const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/models?ids=desktop&format=desktop-config`, { + method: "GET", + headers: new Headers({ + Accept: "application/json", + "anthropic-version": "2023-06-01", + "x-opencodex-api-key": admissionToken, + }), + }, options.timeoutMs); + if (!response.ok || response.status === 304) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new HubClientError(`desktop_snapshot_http_${response.status}`, "Hub Desktop model snapshot request failed", response.status); + } + if (!jsonCompatibleContentType(response)) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid"); + } + const body = await boundedText(response, DESKTOP_SNAPSHOT_MAX_BYTES, { + inactivityTimeoutMs: safeTimeout(options.timeoutMs), + }); + return { version: 1, models: desktopSnapshotModels(parseJson(body, "desktop_snapshot_invalid")) }; + } catch (error) { + // Existing low-level errors can carry a cause containing remote JSON or fetch details. + // Expose only the fixed category/message, never that cause or a remote field value. + if (error instanceof HubClientError) { + const message = error.code === "desktop_snapshot_invalid" ? "Hub Desktop model snapshot was invalid" + : error.code === "desktop_snapshot_unsupported" ? "Hub Desktop model snapshot format is unsupported" + : "Hub Desktop model snapshot request failed"; + throw new HubClientError(error.code, message, error.status); + } + throw new HubClientError("unreachable", "Hub Desktop model snapshot request did not complete"); + } +} + export async function probeClientKeyId( serverUrl: string, admissionToken: string, diff --git a/src/client/hub-relay.ts b/src/client/hub-relay.ts index 820ffd3845..95ab7b0c0d 100644 --- a/src/client/hub-relay.ts +++ b/src/client/hub-relay.ts @@ -164,6 +164,7 @@ function boundedRelayResponseStream( body: ReadableStream, limit: number, signal: AbortSignal, + cleanup: () => void, ): ReadableStream { const reader = body.getReader(); let bytes = 0; @@ -172,6 +173,7 @@ function boundedRelayResponseStream( if (finished) return; finished = true; signal.removeEventListener("abort", onAbort); + cleanup(); try { reader.releaseLock(); } catch { /* a pending read may still own it */ } }; const onAbort = () => { @@ -245,9 +247,19 @@ export async function relayHubManagementRequest( ? Math.min(Math.floor(deps.timeoutMs), 120_000) : HUB_RELAY_DEFAULT_TIMEOUT_MS; const timeoutSignal = AbortSignal.timeout(timeoutMs); - const signal = req.signal - ? AbortSignal.any([req.signal, timeoutSignal]) - : timeoutSignal; + const relayAbort = new AbortController(); + const signal = relayAbort.signal; + const stopDeadline = () => timeoutSignal.removeEventListener("abort", onTimeout); + const cleanup = () => { + stopDeadline(); + req.signal.removeEventListener("abort", onClientAbort); + }; + const onTimeout = () => { relayAbort.abort(timeoutSignal.reason); cleanup(); }; + const onClientAbort = () => { relayAbort.abort(req.signal.reason); cleanup(); }; + timeoutSignal.addEventListener("abort", onTimeout, { once: true }); + req.signal.addEventListener("abort", onClientAbort, { once: true }); + if (req.signal.aborted) onClientAbort(); + else if (timeoutSignal.aborted) onTimeout(); let upstream: Response; try { upstream = await (deps.fetchImpl ?? fetch)(destination, { @@ -258,9 +270,16 @@ export async function relayHubManagementRequest( signal, }); } catch { + cleanup(); + return relayError(502, "hub relay unavailable"); + } + if (signal.aborted) { + cleanup(); + try { await upstream.body?.cancel(); } catch { /* best effort */ } return relayError(502, "hub relay unavailable"); } if (upstream.status >= 300 && upstream.status < 400) { + cleanup(); try { await upstream.body?.cancel(); } catch { /* best effort */ } return relayError(502, "hub relay redirect refused"); } @@ -268,18 +287,31 @@ export async function relayHubManagementRequest( const responseConnectionNamed = new Set((upstream.headers.get("connection") ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean)); const responseHeaders = filteredHeaders(upstream.headers, RESPONSE_HEADERS, responseConnectionNamed); if (!headersWithinLimit(responseHeaders)) { + cleanup(); try { await upstream.body?.cancel(); } catch { /* best effort */ } return relayError(502, "hub relay response headers too large"); } const declaredResponseLength = upstream.headers.get("content-length"); if (declaredResponseLength !== null && (!/^\d+$/.test(declaredResponseLength) || Number(declaredResponseLength) > HUB_RELAY_RESPONSE_BODY_MAX_BYTES)) { + cleanup(); try { await upstream.body?.cancel(); } catch { /* best effort */ } return relayError(502, "hub relay response body too large"); } - const responseBody = method === "HEAD" || !upstream.body - ? null - : boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, signal); + // Only this known, successfully established SSE endpoint outlives the handshake. + // Its body remains byte-bounded and connected to the browser's abort signal. + if (method === "GET" && destination.pathname === "/api/accounts/events" && !destination.search + && upstream.status === 200 + && responseHeaders.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream") { + stopDeadline(); + } + let responseBody: ReadableStream | null = null; + if (method === "HEAD" || !upstream.body) { + cleanup(); + try { await upstream.body?.cancel(); } catch { /* best effort */ } + } else { + responseBody = boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, signal, cleanup); + } return new Response(responseBody, { status: upstream.status, statusText: upstream.statusText, diff --git a/src/client/lifecycle-lock.ts b/src/client/lifecycle-lock.ts new file mode 100644 index 0000000000..ead22373cc --- /dev/null +++ b/src/client/lifecycle-lock.ts @@ -0,0 +1,129 @@ +/** Client/Desktop lifecycle exclusion. Lock order: N -> L -> C; never acquire N inside L. */ +import { Database } from "bun:sqlite"; +import { chmodSync, closeSync, lstatSync, mkdirSync, openSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { resolveEffectiveUserIdentity, resolveEffectiveUserRuntimeRoot } from "../codex/user-identity"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; + +declare const lifecycleLease: unique symbol; +export interface ClientLifecycleHeld { readonly [lifecycleLease]: true } +export interface ClientLifecycleLockDeps { lockPath?: string } + +const activeLeases = new WeakSet(); + +class ClientLifecycleError extends Error { + constructor(readonly code: string, options?: ErrorOptions) { + super(code, options); + this.name = "ClientLifecycleError"; + } +} + +/** A type assertion, copied property or expired callback cannot manufacture exclusion. */ +export function assertClientLifecycleHeld(held: ClientLifecycleHeld): void { + if (!activeLeases.has(held)) throw new ClientLifecycleError("client_lifecycle_lease_invalid"); +} + +function errorCode(error: unknown): unknown { + return error !== null && typeof error === "object" && "code" in error ? error.code : undefined; +} + +function assertPath(path: string, directory: boolean): void { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1)) { + throw new ClientLifecycleError("client_lifecycle_path_unsafe"); + } + if (process.platform !== "win32" && stat.uid !== process.getuid!()) { + throw new ClientLifecycleError("client_lifecycle_path_unsafe"); + } +} + +function preparePath(lockPath: string): void { + const directory = dirname(lockPath); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertPath(directory, true); + if (process.platform !== "win32") chmodSync(directory, 0o700); + hardenSecretDir(directory, { required: true }); + // Create privately before SQLite opens it; refuse an existing symlink or hardlink + // before chmod/ACL operations or the database constructor can follow it. + try { closeSync(openSync(lockPath, "wx", 0o600)); } + catch (error) { if (errorCode(error) !== "EEXIST") throw error; } + assertPath(lockPath, false); + if (process.platform !== "win32") chmodSync(lockPath, 0o600); + hardenSecretPath(lockPath, { required: true }); + assertPath(directory, true); + assertPath(lockPath, false); +} + +function acquire(deps: ClientLifecycleLockDeps): Database { + let database: Database | undefined; + try { + // The production namespace belongs to the effective OS user, never HOME, + // OPENCODEX_HOME, Desktop library overrides or an environment test switch. + const path = deps.lockPath === undefined + ? join(resolveEffectiveUserRuntimeRoot(resolveEffectiveUserIdentity()), "client-desktop-lifecycle.sqlite") + : resolve(deps.lockPath); + preparePath(path); + database = new Database(path, { create: true }); + database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + return database; + } catch (error) { + try { database?.close(); } catch { /* preserve acquisition failure */ } + const code = errorCode(error); + if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || (error instanceof Error && /database (?:is|table is) locked/i.test(error.message))) { + throw new ClientLifecycleError("client_lifecycle_busy"); + } + throw new ClientLifecycleError("client_lifecycle_lock_failed", { cause: error }); + } +} + +type Outcome = { ok: true; value: T } | { ok: false; error: unknown }; + +function finish(database: Database, outcome: Outcome): T { + let release: Outcome = { ok: true, value: undefined }; + try { database.exec("ROLLBACK"); } + catch (error) { release = { ok: false, error }; } + // Always close, including when rollback throws. SQLite/OS owns crash release; + // never unlink a lock database or infer lock ownership from a stale PID. + try { database.close(); } + catch (error) { if (release.ok) release = { ok: false, error }; } + if (!outcome.ok) throw outcome.error; // Includes a literal `throw undefined`. + if (!release.ok) throw new ClientLifecycleError("client_lifecycle_lock_failed", { cause: release.error }); + return outcome.value; +} + +export async function withClientLifecycle( + work: (held: ClientLifecycleHeld) => Promise, + deps: ClientLifecycleLockDeps = {}, +): Promise { + const database = acquire(deps); + const held = Object.freeze({}) as ClientLifecycleHeld; + activeLeases.add(held); + let outcome: Outcome; + try { outcome = { ok: true, value: await work(held) }; } + catch (error) { outcome = { ok: false, error }; } + finally { activeLeases.delete(held); } + return finish(database, outcome); +} + +export function withClientLifecycleSync( + work: (held: ClientLifecycleHeld) => T, + deps: ClientLifecycleLockDeps = {}, +): T { + const database = acquire(deps); + const held = Object.freeze({}) as ClientLifecycleHeld; + activeLeases.add(held); + let outcome: Outcome; + try { + const value = work(held); + if (value !== null && (typeof value === "object" || typeof value === "function") + && typeof (value as { then?: unknown }).then === "function") { + // Observe native rejected promises without invoking arbitrary thenables. + if (value instanceof Promise) void Promise.prototype.then.call(value, undefined, () => undefined); + throw new ClientLifecycleError("client_lifecycle_async_callback"); + } + outcome = { ok: true, value }; + } catch (error) { outcome = { ok: false, error }; } + finally { activeLeases.delete(held); } + return finish(database, outcome); +} diff --git a/src/client/state.ts b/src/client/state.ts index 4711586d09..fd4045d482 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -6,8 +6,11 @@ import { mutatePersistedConfig, readConfigDiagnostics, saveConfig, + withConfigMutationLockSync, } from "../config"; import type { OcxClientConnectionConfig } from "../types"; +import { inspectRemoteDesktopStore, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store"; +import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "./lifecycle-lock"; import { readServiceApiTokenState, readTokenBackupState, @@ -71,62 +74,88 @@ export function readClientConnectionState(): ClientConnectionState { return { kind: "connected", value: client }; } -/** - * Does the persisted config record a rotation that has not finished? - * - * Read fresh rather than taken from a caller-supplied snapshot: the whole point is to see a - * `pendingOperation` that landed after that snapshot was taken. - */ -function rotationInFlight(): boolean { +export function sameClientConnectionOwner( + left: Pick, + right: Pick, +): boolean { + return left.serverUrl === right.serverUrl && left.apiKeyId === right.apiKeyId && left.connectedAt === right.connectedAt; +} + +/** Read-only: safe at a Codex N/C commit boundary; never acquires L or removes recovery state. */ +export function assertNoClientDisconnectPending(): void { + const receipt = readDesktopDisconnectReceipt(); + if (receipt.kind === "unsafe") throw new Error("client_disconnect_receipt_unsafe"); + if (receipt.kind === "valid" && receipt.value.phase !== "complete") { + throw new Error("client_disconnect_pending"); + } +} + +/** Full snapshot CAS for work returning from an await, including selection and rotation state. */ +export function assertClientConnectionUnchanged(expected: OcxClientConnectionConfig): void { + assertNoClientDisconnectPending(); const current = readClientConnectionState(); - return current.kind === "connected" && current.value.pendingOperation !== undefined; + if (current.kind !== "connected" || JSON.stringify(current.value) !== JSON.stringify(expected)) { + throw new Error("client_connection_changed"); + } } -export function inspectClientRotationRecoveryGate( - state: ClientConnectionState = readClientConnectionState(), -): ClientRotationRecoveryGate { +/** Undefined means only "possible orphan": the caller must repeat this read under L/C. */ +function observeClientRotationRecovery(): ClientRotationRecoveryGate | undefined { + const state = readClientConnectionState(); const current = readServiceApiTokenState(); const backup = readTokenBackupState(); + const receipt = readDesktopDisconnectReceipt(); + if (receipt.kind === "unsafe") return { kind: "unsafe", reason: "client_disconnect_receipt_unsafe" }; + if (receipt.kind === "valid" && receipt.value.phase !== "complete") { + return { kind: "recovery-required", reason: "client_disconnect_pending" }; + } if (state.kind === "connected" && state.value.pendingOperation) { if (current.kind !== "present" || backup.kind !== "present") { - return { - kind: "unsafe", - reason: "pending key rotation requires owner-only service-api-token and service-api-token.prev files", - }; + return { kind: "unsafe", reason: "pending rotation requires current and backup token files" }; } - return { - kind: "recovery-required", - reason: "rerun ocx connect rotate with --pairing-code-stdin or --admin-token-stdin", - }; + return { kind: "recovery-required", reason: "rerun ocx connect rotate with transient authority" }; } - if (backup.kind === "unsafe") return { kind: "unsafe", reason: backup.reason }; + if (backup.kind === "unsafe") return { kind: "unsafe", reason: "service token backup is unsafe" }; if (backup.kind === "present" && current.kind === "present") { - // Only an ORPHAN backup is cleanable, and this branch cannot always tell an orphan from - // a backup belonging to a rotation that is mid-flight. - // - // `rotateConnectedClientKey` writes the .prev backup BEFORE it persists - // `pendingOperation`. A concurrent `ocx connect status` landing in that window sees - // "backup present, token present, no pending marker" — indistinguishable from a stale - // leftover — and deleted the live rollback target. If the rotation then failed, its - // restore had nothing to restore from. - // - // Re-reading the persisted state closes most of the window: the caller's `state` may - // have been captured before the marker landed, while a fresh read sees it. The - // remaining window is narrow enough that the rotation's own lock is the right owner, - // and deleting nothing is the safe side of it. - if (rotationInFlight()) { - return { kind: "recovery-required", reason: "a key rotation is in flight; leave service-api-token.prev in place" }; + if (state.kind === "invalid" || state.kind === "mismatched" + || (state.kind === "connected" && current.fingerprint !== state.value.tokenFingerprint)) { + return { kind: "unsafe", reason: "connected token ownership changed" }; } - try { - removeOrphanTokenBackup(); - return { kind: "orphan-cleaned" }; - } catch (error) { - return { kind: "unsafe", reason: error instanceof Error ? error.message : "token backup cleanup failed" }; + if (state.kind === "connected") { + const desktop = inspectRemoteDesktopStore({ serverUrl: state.value.serverUrl, apiKeyId: state.value.apiKeyId, connectedAt: state.value.connectedAt }); + if (desktop.kind !== "absent" && desktop.kind !== "restored") { + // The inspection DTO deliberately exposes no credential generation. Let + // explicit rotation reconcile an active Desktop copy before discarding .prev. + return { kind: "recovery-required", reason: "Desktop credential reconciliation requires ocx connect rotate" }; + } } + return undefined; } return { kind: "clean" }; } +export function inspectClientRotationRecoveryGate( + _state: ClientConnectionState = readClientConnectionState(), + lockDeps?: ClientLifecycleLockDeps, +): ClientRotationRecoveryGate { + try { + // Ordinary status is read-only: even acquiring C creates config-mutation.sqlite. + // Only actual orphan cleanup needs L/C; this first observation authorizes no write. + const observed = observeClientRotationRecovery(); + if (observed) return observed; + return withClientLifecycleSync(() => withConfigMutationLockSync((): ClientRotationRecoveryGate => { + const fresh = observeClientRotationRecovery(); + if (fresh) return fresh; + removeOrphanTokenBackup(); + return { kind: "orphan-cleaned" }; + }), lockDeps); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "client_lifecycle_busy") return { kind: "recovery-required", reason: "client_lifecycle_busy" }; + return { kind: "unsafe", reason: "client rotation state could not be inspected safely" }; + } +} + export function commitClientConnection( state: OcxClientConnectionConfig, @@ -157,13 +186,13 @@ export function commitClientConnection( } export function clearClientConnection( - expectedApiKeyId: string, + expected: string | Pick, ): "committed" | "absent" | "conflict" { const outcome = mutatePersistedConfig(config => { if (!config.client && config.runtimeRole !== "client") { return { changed: false, value: "absent" as const }; } - if (!config.client || config.runtimeRole !== "client" || config.client.apiKeyId !== expectedApiKeyId) { + if (!config.client || config.runtimeRole !== "client" || (typeof expected === "string" ? config.client.apiKeyId !== expected : !sameClientConnectionOwner(config.client, expected))) { return { changed: false, value: "conflict" as const }; } deleteConfigTopLevelKey(config, "client"); diff --git a/src/clients/aside-profiles.ts b/src/clients/aside-profiles.ts new file mode 100644 index 0000000000..31f13d9b76 --- /dev/null +++ b/src/clients/aside-profiles.ts @@ -0,0 +1,224 @@ +import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { IntegrationIO } from "../integrations/config-io"; +import { asideHomeDir, ClientPathError } from "./config-export"; + +export interface AsideProfile { + id: number; + name?: string; + current: boolean; + root: string; + configPath: string; + detectDir: string; +} + +const MAX_PROFILES = 128; +const MAX_MANIFEST_BYTES = 4 * 1024 * 1024; +const MAX_LEAF_LINKS = 40; + +function refuse(message: string): never { + // Never include manifest contents or underlying filesystem error messages. + throw new ClientPathError(`Aside profile: ${message}`); +} + +function isId(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0); +} + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function inspect(path: string, follow = false): Stats | null { + try { + return follow ? statSync(path) : lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + return refuse("a filesystem boundary could not be inspected."); + } +} + +function canonical(path: string): string { + try { return realpathSync.native(path); } catch { + return refuse("a filesystem boundary could not be resolved."); + } +} + +/** Resolve a peer's leaf link even when its final model file does not exist yet. */ +function leafDestination(path: string): string | null { + const visited = new Set(); + while (inspect(path)?.isSymbolicLink()) { + if (visited.has(path) || visited.size >= MAX_LEAF_LINKS) refuse("an account catalog has a cyclic or excessive link chain."); + visited.add(path); + try { path = resolve(dirname(path), readlinkSync(path)); } catch { + return refuse("an account catalog link could not be inspected."); + } + } + if (!inspect(dirname(path), true)?.isDirectory()) return null; + return join(canonical(dirname(path)), basename(path)); +} + +function readProfiles(root: string): AsideProfile[] { + const rootStat = inspect(root); + if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + refuse("the configured root is missing or is not a safe directory."); + } + const manifest = join(root, "accounts.json"); + const manifestStat = inspect(manifest); + if (!manifestStat || manifestStat.isSymbolicLink() || !manifestStat.isFile() + || manifestStat.size > MAX_MANIFEST_BYTES) { + refuse("the account manifest is missing, unreadable or unsafe. Launch Aside to create it."); + } + let parsed: unknown; + try { parsed = JSON.parse(readFileSync(manifest, "utf8")); } catch { + return refuse("the account manifest is not readable JSON."); + } + if (!object(parsed) || !isId(parsed.currentAccountId)) { + refuse("the account manifest has no valid current account ID."); + } + const currentId = parsed.currentAccountId; + const accounts: unknown = Object.hasOwn(parsed, "accounts") ? parsed.accounts : [{ id: currentId }]; + if (!Array.isArray(accounts) || accounts.length === 0 || accounts.length > MAX_PROFILES) { + refuse("the account manifest must contain between 1 and 128 accounts."); + } + const ids = new Set(); + const profiles = accounts.map((account: unknown): AsideProfile => { + if (!object(account) || !isId(account.id) || ids.has(account.id)) { + return refuse("the account manifest contains an invalid or duplicate account ID."); + } + const current = account.id === currentId; + if (Object.hasOwn(account, "current") && account.current !== current) { + refuse("the account manifest has inconsistent current account metadata."); + } + ids.add(account.id); + const detectDir = join(root, "u", String(account.id)); + return { + id: account.id, + ...(typeof account.name === "string" ? { name: account.name } : {}), + current, root, detectDir, configPath: join(detectDir, "models.json"), + }; + }); + if (!ids.has(currentId)) refuse("the current account is not registered in the account manifest."); + return profiles; +} + +/** Enumerate account catalogs; browser bindings and session data are never projected. */ +export function listAsideProfiles(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): AsideProfile[] { + const root = asideHomeDir(env, home); + if (!isAbsolute(root)) refuse("the configured root must be absolute."); + return readProfiles(root); +} + +type DirectoryIdentity = { path: string; dev: number; ino: number }; +type Boundary = Array; + +function sameIdentity(a: Pick, b: Pick): boolean { + return a.dev === b.dev && a.ino === b.ino; +} + +function validatePaths(profile: AsideProfile): void { + if (!isId(profile.id) || !isAbsolute(profile.root) || resolve(profile.root) !== profile.root + || profile.detectDir !== join(profile.root, "u", String(profile.id)) + || profile.configPath !== join(profile.detectDir, "models.json")) { + refuse("the selected account paths are invalid."); + } +} + +function registeredProfiles(profile: AsideProfile, profiles?: AsideProfile[]): AsideProfile[] { + validatePaths(profile); + const registered = profiles ?? readProfiles(profile.root); + if (registered.length === 0 || registered.length > MAX_PROFILES) refuse("the account list is invalid."); + const ids = new Set(); + for (const peer of registered) { + validatePaths(peer); + if (peer.root !== profile.root || ids.has(peer.id)) refuse("the account list has conflicting paths."); + ids.add(peer.id); + } + if (!ids.has(profile.id)) refuse("the selected account is not registered."); + return registered; +} + +function boundary(profile: AsideProfile, profiles: AsideProfile[], mutation: boolean): Boundary { + const directories = [profile.root, join(profile.root, "u"), profile.detectDir]; + const identities: Boundary = []; + let parent: string | undefined; + let absent = false; + for (const [index, directory] of directories.entries()) { + const stats = absent ? null : inspect(directory); + if (!stats) { + if (index === 0 || mutation) refuse("the account directory is not installed; it will not be created."); + absent = true; + identities.push(null); + continue; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) refuse("an account directory is a link or is unsafe."); + const path = canonical(directory); + // Aliases ABOVE the chosen root (notably macOS /var) are valid. + const child = index === 1 ? "u" : String(profile.id); + if (parent && path !== join(parent, child)) refuse("an account directory resolves outside its boundary."); + identities.push({ path, dev: stats.dev, ino: stats.ino }); + parent = path; + } + if (absent) return identities; + const leaf = inspect(profile.configPath); + if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1)) { + refuse("the model catalog is a link, shared file or non-regular file."); + } + if (leaf && canonical(profile.configPath) !== join(parent!, "models.json")) { + refuse("the model catalog resolves outside its account directory."); + } + const account = identities[2]!; + for (const peer of profiles) { + if (peer.id === profile.id) continue; + // Follow peers only for identity comparison, never for content or writes. + // This also detects a sibling symlink pointing BACK at this safe target. + const peerDirectory = inspect(peer.detectDir, true); + if (peerDirectory && sameIdentity(account, peerDirectory)) refuse("account directories share a target."); + if (!peerDirectory?.isDirectory()) continue; + if (inspect(peer.configPath)?.isSymbolicLink() + && leafDestination(peer.configPath) === join(parent!, "models.json")) { + refuse("account catalogs share a target."); + } + const peerLeaf = inspect(peer.configPath, true); + if (leaf && peerLeaf && sameIdentity(leaf, peerLeaf)) refuse("account catalogs share a target."); + } + return identities; +} + +/** Missing account directories are readable as not installed, but never writable. */ +export function assertAsideProfileBoundary(profile: AsideProfile, profiles?: AsideProfile[], mutation = false): void { + boundary(profile, registeredProfiles(profile, profiles), mutation); +} + +/** + * Pin directories for one status/write operation, retaining the caller's IO and store. + * Rechecks complement atomic writes; they do not defeat a hostile same-user process + * racing every filesystem syscall. Leaf inodes may change during our atomic writes. + */ +export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, profiles?: AsideProfile[]): IntegrationIO { + const selected = { ...profile }; + const registered = registeredProfiles(selected, profiles).map(peer => ({ ...peer })); + const captured = boundary(selected, registered, false); + function check(path: string, directory: boolean, mutation: boolean): void { + if (path !== (directory ? selected.detectDir : selected.configPath)) { + refuse("IO attempted to access a different account path."); + } + const current = boundary(selected, registered, mutation); + if (current.some((item, index) => { + const prior = captured[index]; + return item === null || prior == null ? item !== prior : item.path !== prior.path || !sameIdentity(item, prior); + })) refuse("the account directory changed after the operation began."); + } + return { + readText: path => { check(path, false, false); return io.readText(path); }, + statKind: path => { check(path, path === selected.detectDir, false); return io.statKind(path); }, + writeText: (path, text) => { check(path, false, true); io.writeText(path, text); }, + removeFile: path => { check(path, false, true); io.removeFile(path); }, + mkdirp: path => { check(path, true, true); io.mkdirp(path); }, + now: () => io.now(), + appendJournal: entry => io.appendJournal(entry), + putRecord: record => io.putRecord(record), + dropRecord: clientId => io.dropRecord(clientId), + }; +} diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index f9f02bba3e..703e08f247 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; import { - atomicWriteFile, deleteConfigTopLevelKey, getConfigPath, saveConfigPreservingClaudeCode, @@ -107,8 +106,8 @@ function restoreRuntimeConfig(target: OcxConfig, snapshot: OcxConfig): void { Object.assign(target, snapshot); } -function assertPersistedConfigUnchanged(configPath: string, previousBytes: string): void { - if (readFileSync(configPath, "utf8") !== previousBytes) { +function assertPersistedConfigUnchanged(configPath: string, previousBytes: Buffer): void { + if (!readFileSync(configPath).equals(previousBytes)) { throw new CodexAccountDeleteRollbackError(); } } @@ -130,7 +129,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): const previousConfig = structuredClone(runtimeConfig); const configPath = getConfigPath(); const hasPersistedConfig = existsSync(configPath); - const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath, "utf8") : undefined; + const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath) : undefined; const hadStoredAccount = (runtimeConfig.codexAccounts ?? []) .some(account => !account.isMain && account.id === accountId); const hadVisiblePickerBinding = hadStoredAccount diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index dc4149fed5..324822b615 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -105,6 +105,7 @@ import { type MainAccountInfo, } from "./main-account-cache"; export { clearMainAccountInfoCache } from "./main-account-cache"; +import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome"; import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock"; import { observeMainReserveRevocation } from "./reserve-availability"; import { maskEmail } from "../lib/privacy"; @@ -779,6 +780,10 @@ async function readMainAuthErrorCode(resp: Response): Promise { interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ + quotaRefresh?: CodexQuotaRefreshOutcome; + /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */ + quotaRefreshGeneration?: number; /** Whether this attempt safely inspected the physical native-main credential. */ credentialChecked: boolean; /** Meaningful only when credentialChecked is true. */ @@ -794,12 +799,16 @@ interface MainAccountInfoFetchResult { export interface MainAccountInfoSnapshot { info: MainAccountInfo; mainIdentityGeneration: number; + quotaRefresh?: CodexQuotaRefreshOutcome; } export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise { const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); return { info: result.info, + ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration) + ? { quotaRefresh: result.quotaRefresh } : {}), mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), }; } @@ -902,34 +911,55 @@ async function fetchMainAccountInfoWhileOwned( ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); + // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. + const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); + let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; + let quotaRefreshGeneration = captureMainAccountIdentityGeneration(); try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, - signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS), + signal: quotaSignal, }); + quotaPhase = "publish"; if (!resp.ok) { const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; if (terminalAuthFailure) { + // Account for this attempt's own synchronous invalidation, never prior external drift. + const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration); clearMainAccountInfoCache(); + if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); } - return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status: "http_error", httpStatus: resp.status }, + quotaRefreshGeneration, + }; } + quotaPhase = "body"; const data = (await resp.json()) as WhamUsageResponse; + quotaPhase = "publish"; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; + quotaPhase = "decode"; + if (data === null || typeof data !== "object" || Array.isArray(data)) { + throw new Error("Invalid WHAM usage object"); + } + quotaPhase = "publish"; // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, // even in the same workspace or after an A→B→A credential transition. if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) { observeMainReserveRevocation(data, mainQuotaWriter); } + quotaPhase = "decode"; const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); const usage = { ...data, ...(plan ? { plan_type: plan } : {}) }; const quota = parseUsageQuota(usage); const policyQuota = parseMainPolicyUsageQuota(usage); + quotaPhase = "publish"; const freshResetCredits = quota?.resetCredits; // Tag the count with the identity it was read from, so a later response that omits the // summary can restore the badge without ever crossing an account boundary. @@ -959,14 +989,26 @@ async function fetchMainAccountInfoWhileOwned( } return { info: result, + quotaRefresh: { status: quota ? "ok" : "not_reported" }, + quotaRefreshGeneration, credentialChecked: true, hasCredential: true, ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; - } catch { + } catch (error) { const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); - return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + if (retried) return retried; + let status: CodexQuotaRefreshOutcome["status"] = "internal_error"; + if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout"; + else if (quotaPhase === "request") status = "network_error"; + else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error"; + else if (quotaPhase === "decode") status = "invalid_response"; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status }, + quotaRefreshGeneration, + }; } } @@ -1063,6 +1105,7 @@ export interface CodexAuthAccountDto { healthSummary: string; healthAction?: string; quotaProbeSkipped?: true; + quotaRefresh?: CodexQuotaRefreshOutcome; mainAccountHardLock?: MainAccountHardLockStatus; } @@ -1771,6 +1814,9 @@ export async function listCodexAuthAccountsSnapshot( id: MAIN_CODEX_ACCOUNT_ID, email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, + ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration) + ? { quotaRefresh: mainResult.quotaRefresh } : {}), logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 421686a8df..3d59ea3ce3 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; -export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, isEligibleV2SubagentEntry, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync"; +export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, isEligibleV2SubagentEntry, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, orderForModelPicker, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync"; export type { ObservedCatalogMergeInput } from "./catalog/sync"; export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index a50dd9469f..f239ce48b1 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -299,8 +299,6 @@ function narrowToLimits(raw: number | undefined, slug: string, input: NativeCont return overlay !== undefined && cap !== undefined ? Math.min(window, cap) : window; } const narrowed = overlay === undefined ? raw : Math.min(raw, overlay); - // 922k is the GPT-5.6 1M opt-in, not a request to shrink gpt-5.4's 1M window. - if (cap === NATIVE_GPT56_MAX_INPUT_TOKENS) return narrowed; return applyProviderContextCap(narrowed, cap) ?? narrowed; } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 8a333d3e90..39a92f765c 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1520,11 +1520,14 @@ async function fetchProviderModelsWithAuth( && prov.googleMode === "vertex" && (prov.models?.length ?? 0) === 0 && Boolean(prov.defaultModel); - // Ordered dedupe union: Vertex seed, then `models`, then `retainModels`. `configured` is the + const seedStaticDefault = prov.liveModels === false + && (prov.models?.length ?? 0) === 0 + && Boolean(prov.defaultModel); + // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints, // so a retain-only id must enter here or it never exists to be retained (#1690). const configuredIds = [...new Set([ - ...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []), + ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []), ...(prov.models ?? []), ...(prov.retainModels ?? []), ])]; @@ -1581,9 +1584,8 @@ async function fetchProviderModelsWithAuth( : resolveAuth.resolve(name, prov)); const apiKey = auth.apiKey; // A configured default is a real callable selector and must remain discoverable when a - // compatible provider's live /models request fails (issue #308). Keep this separate from the - // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero - // rows, while a failed live discovery may degrade to the default selector. + // compatible provider's live /models request fails (issue #308). Static providers already seed + // their default selector above when no explicit model list exists. const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic" ? configured : [{ @@ -2150,6 +2152,31 @@ async function gatherRoutedModelsWithAuth( return models; } +/** Bound a proven Codex-forward custom row without changing its stored configuration. */ +function boundCustomNativeReasoning( + model: CatalogModel, + allowed: readonly string[], + nativeDefault: string | undefined, +): CatalogModel { + if (allowed.length === 0 || model.reasoningEfforts === undefined) return model; + const bounded = { ...model }; + if (model.reasoningEfforts.length === 0) { + bounded.reasoningEfforts = []; + delete bounded.defaultReasoningEffort; + return bounded; + } + const declared = new Set(model.reasoningEfforts); + const surviving = [...new Set(allowed)].filter(effort => declared.has(effort)); + const fallback = nativeDefault && allowed.includes(nativeDefault) ? nativeDefault : allowed[0]!; + // A nonempty but incompatible declaration is not an explicit no-reasoning setting. + bounded.reasoningEfforts = surviving.length > 0 ? surviving : [fallback]; + bounded.defaultReasoningEffort = model.defaultReasoningEffort + && bounded.reasoningEfforts.includes(model.defaultReasoningEffort) + ? model.defaultReasoningEffort + : bounded.reasoningEfforts.includes(fallback) ? fallback : bounded.reasoningEfforts[0]!; + return bounded; +} + async function gatherRoutedModelsUncached( config: OcxConfig, capture: GatherFlightCapture, @@ -2414,7 +2441,7 @@ async function gatherRoutedModelsUncached( ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), // Native-alias defaults apply only where the custom row declares nothing: the explicit // spreads below must win (later in object order), so a stored `[]` stays empty and a - // declared ladder is never replaced by the alias's native ladder. + // declared ladder is narrowed to proven native capabilities after the merge below. ...(codexForwardNativeCapabilityAlias ? { codexForwardNativeCapabilityAlias: true, @@ -2429,7 +2456,8 @@ async function gatherRoutedModelsUncached( : {}), // Explicit custom-row ladder wins over the inherited provider row below: the merge only // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept - // verbatim instead of being replaced by the replaced row's metadata. + // instead of being replaced by that row's metadata. Only proven native aliases are + // bounded against their own capability source after the merge. ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), @@ -2482,22 +2510,25 @@ async function gatherRoutedModelsUncached( ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}), ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), } : base; + const reasoningBounded = codexForwardNativeCapabilityAlias + ? boundCustomNativeReasoning(merged, nativeReasoningEfforts(cm.modelId), nativeAliasDefaultEffort) + : merged; // Vision-sidecar coverage only: when the enriched provider's shared predicate matches // noVisionModels or text-without-image modelInputModalities, advertise image input so the // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full // applyProviderConfigHints pass — custom rows are a // user override, so their explicit contextWindow / inputModalities / reasoning fields must be // preserved verbatim (the hint pass would cap context and overwrite modalities from registry). - const mergedContext = typeof merged.contextWindow === "number" && merged.contextWindow > 0 - ? merged.contextWindow + const mergedContext = typeof reasoningBounded.contextWindow === "number" && reasoningBounded.contextWindow > 0 + ? reasoningBounded.contextWindow : undefined; - const boundedMergedMaxInput = typeof merged.maxInputTokens === "number" && merged.maxInputTokens > 0 - ? (mergedContext !== undefined ? Math.min(merged.maxInputTokens, mergedContext) : merged.maxInputTokens) + const boundedMergedMaxInput = typeof reasoningBounded.maxInputTokens === "number" && reasoningBounded.maxInputTokens > 0 + ? (mergedContext !== undefined ? Math.min(reasoningBounded.maxInputTokens, mergedContext) : reasoningBounded.maxInputTokens) : undefined; const mergedWithHardBounds = boundedMergedMaxInput !== undefined - && boundedMergedMaxInput !== merged.maxInputTokens - ? { ...merged, maxInputTokens: boundedMergedMaxInput } - : merged; + && boundedMergedMaxInput !== reasoningBounded.maxInputTokens + ? { ...reasoningBounded, maxInputTokens: boundedMergedMaxInput } + : reasoningBounded; const mergedSoftCandidates = [mergedWithHardBounds.autoCompactTokenLimit, configuredAutoCompact] .filter((value): value is number => typeof value === "number" && value > 0); const mergedWithAutoCompact: CatalogModel = mergedContext !== undefined && mergedSoftCandidates.length > 0 diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 3024b3af2f..be0e0d2e9a 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -88,15 +88,15 @@ import { export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; // Base for config.modelPickerOrder display priorities (#1649). modelPickerOrder is a DISPLAY-ONLY -// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but never the -// spawn_agent candidate window. The window is derived from SPAWN_PRIORITY_FIELD (the natural -// priority captured before the override), so display order and spawn candidates are decoupled. +// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but not +// OpenCodex's natural-priority guidance window. Native Codex advertisements still follow the +// visible priority and can differ from that guidance window. export const PICKER_ORDER_PRIORITY_BASE = 1_000; -// OpenCodex-private catalog field: the spawn_agent candidate priority a row would have WITHOUT +// OpenCodex-private catalog field: the guidance candidate priority a row would have WITHOUT // modelPickerOrder. Codex ignores unknown catalog fields (same as opencodex_catalog_kind), so this -// is invisible to Codex; effectiveSubagentRoster reads it so a display reorder cannot change which -// rows are spawn_agent candidates. Absent on rows modelPickerOrder did not move. +// is invisible to Codex; effectiveSubagentRoster reads it to keep OpenCodex guidance candidates +// independent of display order. It does not freeze native advertisements. Absent on unmoved rows. export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority"; export type SpawnAgentSurface = "v1" | "v2"; @@ -154,7 +154,9 @@ export interface SubagentRosterExclusion { } export interface EffectiveSubagentRoster { + /** OpenCodex's natural-priority guidance projection, not captured native tool text. */ candidates: EffectiveSubagentModel[]; + /** Configured models within that projection; exact-name eligibility is a separate check. */ advertised: EffectiveSubagentModel[]; excluded: SubagentRosterExclusion[]; } @@ -191,8 +193,8 @@ export function effectiveSubagentRoster( .filter(({ entry }) => entry.visibility === "list") .filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry)) .sort((left, right) => { - // Spawn candidates rank by the natural priority (SPAWN_PRIORITY_FIELD when present), so a - // modelPickerOrder display reorder (#1649) can never change candidate membership. Rows the + // OpenCodex guidance candidates rank by natural priority (SPAWN_PRIORITY_FIELD when present), + // so modelPickerOrder does not change this projection. Native tool advertisements differ. Rows the // override did not move fall back to their Codex-visible `priority`. const spawnPriorityOf = (entry: RawEntry): number => { const spawn = entry[SPAWN_PRIORITY_FIELD]; @@ -315,6 +317,8 @@ export function deriveEntry( contextCap?: NativeContextLimitsInput, ): RawEntry { const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); + // Go exposes model-specific upstream enums; synthetic tiers mislead subagent overrides. + const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true ? upstreamNativeEntry(model.id) : null; @@ -328,6 +332,8 @@ export function deriveEntry( } if (template || codexForwardNativeCapabilityAlias) { const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry; + // A cached template may carry display-order history; each new row owns its natural rank. + delete e[SPAWN_PRIORITY_FIELD]; e.slug = slug; e.display_name = routedDisplayName(slug, model); e.description = desc; @@ -359,7 +365,7 @@ export function deriveEntry( e, model?.reasoningEfforts, model?.defaultReasoningEffort, - preserveExact || codexForwardNativeCapabilityAlias !== null, + preserveExactReasoning || codexForwardNativeCapabilityAlias !== null, ); // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned // native tool/search/responses-lite contract while preserving the routed slug and wire id. @@ -409,7 +415,7 @@ export function deriveEntry( }; if (isRouted) { applyRoutedCodexToolMode(entry, model?.codexToolMode); - applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); + applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning); } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); @@ -465,12 +471,14 @@ export function buildCatalogEntries( accountNativeSlugs?: readonly string[], accountNativeSlugsBySelector?: ReadonlyMap, keepNativeChatGptOnV1 = false, + modelPickerOrder: readonly string[] = [], ): RawEntry[] { - return buildCatalogEntriesFromObservedState({ + const entries = buildCatalogEntriesFromObservedState({ template, gptSlugs, goModels, featured, + modelPickerOrder, wsEnabled, multiAgentMode, exactComboSlugs, @@ -483,6 +491,8 @@ export function buildCatalogEntries( accountNativeSlugs, accountNativeSlugsBySelector, }); + applyFullModelPickerOrder(entries, modelPickerOrder); + return entries; } /** Build entries solely from caller-observed inputs, with no feature-state filesystem read. */ @@ -516,27 +526,25 @@ export function buildCatalogEntriesFromObservedState({ // catalog stays put across rebuilds. Featured rows keep their existing 0..N-1 band; when // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so - // this display reorder cannot change which rows are spawn candidates. - const pickerOrder = Array.isArray(modelPickerOrder) - ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.length > 0) - : []; + // this display reorder does not change OpenCodex's guidance candidate calculation. + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); const pickerOrderActive = pickerOrder.length > 0; // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the // same 1_000+ neighborhood account rows occupy), keeping listed rows visually after the featured - // band. Candidate membership does not depend on this — see SPAWN_PRIORITY_FIELD. + // band. OpenCodex guidance membership does not depend on this — see SPAWN_PRIORITY_FIELD. /** * Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed * slugs sort in declared order within the high picker-order display tier * (>= PICKER_ORDER_PRIORITY_BASE). This sets the Codex-visible `priority` only; the caller records - * the row's natural priority in SPAWN_PRIORITY_FIELD so the spawn_agent candidate window is - * unchanged. Returns undefined when the feature is off or the row is not listed, so those rows + * the row's natural priority in SPAWN_PRIORITY_FIELD for OpenCodex's unchanged guidance window. + * Returns undefined when the feature is off or the row is not listed, so those rows * keep their original assignment (default 5 / account 1_000+) untouched. * * Scope: only the generic routed `/` rows call this (see the goModels loop * below). Native passthrough rows and account-qualified native rows keep their own priority - * logic and are intentionally not reordered here — this matches the documented contract on - * OcxConfig.modelPickerOrder (route native ordering through subagentModels instead). + * logic and are intentionally not reordered in this legacy builder pass. The final merge can + * apply complete ordering when the configured list includes a bare id. */ const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => { if (!pickerOrderActive) return undefined; @@ -658,9 +666,9 @@ export function buildCatalogEntriesFromObservedState({ // Keep the generated account rows together in Codex's priority-sorted flat picker. e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); } - // #1649: modelPickerOrder is a DISPLAY-ONLY override. Record the natural priority spawn_agent - // must keep using, then let modelPickerOrder move only the Codex-visible `priority`. Featured - // rows are never overridden (their rank is authoritative for both display and spawn). + // The legacy routed-only builder pass keeps featured ranks and records natural priority + // before changing non-featured display priority. The final complete-order pass may move + // featured display rows too; OpenCodex guidance continues to use their natural ranks. if (rankHit === undefined) { const pickerPriority = pickerOrderPriority(slug, `${m.provider}/${m.id}`); if (pickerPriority !== undefined) { @@ -716,6 +724,30 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[]) }); } +/** Routed discovery projection; native groups and alias ownership belong to the caller. */ +export function orderForModelPicker( + models: readonly CatalogModel[], + order: readonly string[] = [], + featured: readonly string[] = [], +): CatalogModel[] { + const pickerOrder = normalizeModelPickerOrder(order); + if (pickerOrder.length === 0) return [...models]; + const pickerRank = modelPickerRank(pickerOrder); + const featuredRank = modelPickerRank(featured); + const complete = pickerOrder.some(slug => !slug.includes("/")); + const rank = (model: CatalogModel): number => { + const slug = catalogModelSlug(model); + const featuredIndex = featuredRank(slug) ?? featuredRank(`${model.provider}/${model.id}`); + const natural = featuredIndex ?? 5; + const index = pickerRank(slug) ?? pickerRank(`${model.provider}/${model.id}`); + if (complete) return index ?? pickerOrder.length + natural; + // Preserve the legacy featured/alias bands, including unlisted rows before listed rows. + if (featuredIndex !== undefined || model.nativeAlias === true) return natural; + return index === undefined ? natural : PICKER_ORDER_PRIORITY_BASE + index; + }; + return [...models].sort((a, b) => rank(a) - rank(b)); +} + /** * True when an existing catalog row was authored by OpenCodex routing (#855). * Every generated routed row — current full-slug form, the June–July 2026 @@ -779,12 +811,39 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< unsupportedNativeEntries: "drop", }); +function normalizeModelPickerOrder(order: unknown): string[] { + return Array.isArray(order) + ? order.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; +} + +/** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ +function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { + const exact = new Map(order.map((slug, index) => [slug, index])); + const equivalent = new Map(order.map((slug, index) => [slugEquivalenceKey(slug), index])); + return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); +} + +/** Complete display ordering retains natural ranks for OpenCodex's separate guidance projection. */ +export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { + const pickerOrder = normalizeModelPickerOrder(order); + if (!pickerOrder.some(slug => !slug.includes("/"))) return; + const rankOf = modelPickerRank(pickerOrder); + for (const entry of entries) { + const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; + entry[SPAWN_PRIORITY_FIELD] = natural; + entry.priority = rankOf(String(entry.slug)) ?? pickerOrder.length + Number(natural); + } +} + export interface ObservedCatalogMergeInput { readonly catalogModels: readonly RawEntry[]; readonly baselineCatalogModels: readonly RawEntry[]; readonly routedEntries: readonly RawEntry[]; readonly baseline: ReadonlyMap; readonly featured: readonly string[]; + readonly modelPickerOrder?: readonly string[]; + readonly accountSelectors?: readonly string[]; readonly wsEnabled: boolean; readonly template: RawEntry | null; readonly disabledModels: ReadonlySet; @@ -817,6 +876,8 @@ export function mergeCatalogEntriesFromObservedState({ routedEntries, baseline, featured, + modelPickerOrder = [], + accountSelectors = [], wsEnabled, template, disabledModels, @@ -842,6 +903,10 @@ export function mergeCatalogEntriesFromObservedState({ const detachedBaselineCatalogModels = baselineCatalogModels .map(entry => structuredClone(entry) as RawEntry); const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry); + // Track this invocation's generated custom rows, not ownership markers read from disk. + // Their builder already finalized exact native ladders and ordinary routed mock tiers. + const freshCustomEntries = new Set(detachedRoutedEntries.filter(entry => + entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND)); const detachedAccountBoundEntries = accountBoundEntries .map(entry => structuredClone(entry) as RawEntry); const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey)); @@ -975,7 +1040,9 @@ export function mergeCatalogEntriesFromObservedState({ finished.priority = nativePriority(slug, upstream.priority); return finished; } - const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); + const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); + // Recompute spawn rank from current featured models, not a prior picker override. + delete preserved[SPAWN_PRIORITY_FIELD]; // Older natives kept from disk still need the mock top tiers (max + ultra always // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); @@ -1060,6 +1127,32 @@ export function mergeCatalogEntriesFromObservedState({ // remain outside provider ownership and survive unless a fresh row replaces their exact slug. return !isOcxAuthoredRoutedEntry(entry); }); + // Retained rows bypass the builder. Recompute managed spawn ranks from current config + // before either display-order mode; a saved display override is not current roster authority. + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); + const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); + const rankOf = modelPickerRank(pickerOrder); + const featuredRankOf = modelPickerRank(featured); + const priorityStride = Math.max(accountSelectors.length, 1); + for (const entry of preservedRoutedEntries) { + const natural = entry[SPAWN_PRIORITY_FIELD]; + if (typeof natural === "number") { + entry.priority = natural; + delete entry[SPAWN_PRIORITY_FIELD]; + } + const slug = String(entry.slug); + if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; + const featuredRank = featuredRankOf(slug); + entry.priority = featuredRank !== undefined + ? featuredRank * priorityStride + : (accountSelectors.length > 0 ? 1_000 : 0) + 5; + if (featuredRank !== undefined || fullPickerOrder) continue; + const pickerIndex = rankOf(slug); + if (pickerIndex !== undefined) { + entry[SPAWN_PRIORITY_FIELD] = entry.priority; + entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; + } + } let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; finalRoutedEntries = finalRoutedEntries.filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -1134,7 +1227,7 @@ export function mergeCatalogEntriesFromObservedState({ // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. - if (!exactCombo && !reserveProjection) { + if (!freshCustomEntries.has(m) && !exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { const levels = Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels as Array<{ effort?: string }> : []; @@ -1161,6 +1254,7 @@ export function mergeCatalogEntriesFromObservedState({ multiAgentV2Enabled, { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, ); + applyFullModelPickerOrder(versionedEntries, modelPickerOrder); for (const entry of versionedEntries) { const kind = entry.opencodex_catalog_kind; if (trustedAccountBoundNativeCatalogSlug(entry) === undefined @@ -1768,6 +1862,8 @@ function writeRetainedCatalogSync({ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, + accountSelectors, catalogModels: catalogModelsForMerge, baselineCatalogModels: baselineCatalog?.models ?? [], routedEntries: goEntries, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 0ab10918e3..08858f121d 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -370,6 +370,8 @@ function prepareCatalog( )), ); const mergedModels = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, + accountSelectors, catalogModels, baselineCatalogModels, routedEntries, diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 54a08a778b..fc38f875e3 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -6,6 +6,7 @@ import { readConfigAdmissionSnapshot, subagentDefaultSyncEffective, websocketsEnabled, + withConfigMutationLockSync, } from "../config"; import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock"; import { shouldSyncCodexOnStart } from "./desired-state"; @@ -150,6 +151,18 @@ export interface InjectCodexOptions { /** Explicit remote routing target. Absence preserves byte-compatible standalone output. */ routingTarget?: CodexRoutingTarget; journalOwner?: { kind: "process" } | { kind: "client"; apiKeyId: string }; + /** Synchronous read-only client ownership guard, evaluated at the artifact commit boundary. */ + beforeClientWrite?: () => void; +} + +function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): void { + const result: unknown = guard?.(); + if (result !== null && (typeof result === "object" || typeof result === "function") + && typeof (result as { then?: unknown }).then === "function") { + // Reject async guards without leaving their eventual rejection unhandled. + void Promise.resolve(result).catch(() => {}); + throw new Error("Connected client write guard must be synchronous"); + } } export interface CodexRoutingTarget { @@ -927,7 +940,14 @@ export async function injectCodexConfig( if (activeProvider) { // A launcher may have journaled before the provider manager took ownership. Never let shutdown // replay that stale snapshot over externally managed config. - if (!options.validateOnly) removeJournal(); + if (!options.validateOnly) { + if (options.beforeClientWrite) { + withConfigMutationLockSync(() => { + runClientWriteGuard(options.beforeClientWrite); + removeJournal(); + }); + } else removeJournal(); + } const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults( config, ) @@ -1000,8 +1020,8 @@ export async function injectCodexConfig( // not-ours (which would make them unrestorable). content = stripJournaledOpenaiBaseUrl( content, - journaledInjectedOpenaiBaseUrl(), - journaledInjectedRealtimeWsBaseUrl(), + journaledInjectedOpenaiBaseUrl({ readOnly: !!options.beforeClientWrite }), + journaledInjectedRealtimeWsBaseUrl({ readOnly: !!options.beforeClientWrite }), ); if (hasOcxProviderTable(content)) { content = removeOcxSection(content); @@ -1206,17 +1226,24 @@ export async function injectCodexConfig( let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; if (eligibility.kind === "legacy-uncoordinated") { - // Unchanged behavior for homes the coordinator cannot yet adopt. Stated - // rather than implied: this is the boundary, and adoption is its own phase. - if (!shouldSyncCodexOnStart(loadConfig())) { - return { - success: true, - status: "skipped", - skippedReason: "desired_disabled", - message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", - }; - } - applyNativeArtifacts(); + const applyLegacy = (): CodexInjectResult | undefined => { + if (!shouldSyncCodexOnStart(loadConfig())) { + return { + success: true, + status: "skipped", + skippedReason: "desired_disabled", + message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + }; + } + runClientWriteGuard(options.beforeClientWrite); + applyNativeArtifacts(); + }; + // Only connected guarded writes add C here. A concurrent disconnect claim + // either follows this commit or is observed by the guard before any write. + const skipped = options.beforeClientWrite + ? withConfigMutationLockSync(applyLegacy) + : applyLegacy(); + if (skipped) return skipped; } else { const coordinated = await withCodexWriteLock( { @@ -1237,6 +1264,10 @@ export async function injectCodexConfig( if (!shouldSyncCodexOnStart(loadConfig())) { throw new CodexWriteLockSkipped("desired_disabled"); } + // N and C are held here. Reject stale client work before publishing a + // transition or capturing preimages; rejection must not compensate over + // a disconnect's restored files. + runClientWriteGuard(options.beforeClientWrite); /* * Publish BEFORE touching the filesystem. `assertPublished` runs after this * callback returns and throws unless a transition was recorded, so writing diff --git a/src/codex/journal.ts b/src/codex/journal.ts index a579c2fcf5..b2ec89eef0 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -161,13 +161,13 @@ export function markJournalInjectedState( * survives such a rewrite, so restore can still prove the URL is ours -- and, just as * importantly, prove that a DIFFERENT URL is not. */ -export function journaledInjectedOpenaiBaseUrl(): string | null { - return readJournal()?.injectedOpenaiBaseUrl ?? null; +export function journaledInjectedOpenaiBaseUrl(options: { readOnly?: boolean } = {}): string | null { + return readJournal(options.readOnly !== true)?.injectedOpenaiBaseUrl ?? null; } /** The root `experimental_realtime_ws_base_url` the last injection wrote, or null. */ -export function journaledInjectedRealtimeWsBaseUrl(): string | null { - return readJournal()?.injectedRealtimeWsBaseUrl ?? null; +export function journaledInjectedRealtimeWsBaseUrl(options: { readOnly?: boolean } = {}): string | null { + return readJournal(options.readOnly !== true)?.injectedRealtimeWsBaseUrl ?? null; } /** The catalog path the last injection wrote to, or null when none was recorded. */ @@ -179,14 +179,14 @@ export function removeJournal(): void { try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ } } -function readJournal(): Journal | null { +function readJournal(cleanInvalid = true): Journal | null { if (!existsSync(JOURNAL_PATH)) return null; try { const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8")) as Journal; if (journal.version !== 1) throw new Error("unknown version"); return journal; } catch { - removeJournal(); + if (cleanInvalid) removeJournal(); return null; } } diff --git a/src/codex/quota-refresh-outcome.ts b/src/codex/quota-refresh-outcome.ts new file mode 100644 index 0000000000..fb059ef69d --- /dev/null +++ b/src/codex/quota-refresh-outcome.ts @@ -0,0 +1,27 @@ +/** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */ +export type CodexQuotaRefreshOutcome = + | { status: "http_error"; httpStatus: number } + | { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" }; + +/** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */ +export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const row = value as Record; + if (row.status === "http_error") { + return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus) + && row.httpStatus >= 100 && row.httpStatus <= 599 + ? { status: "http_error", httpStatus: row.httpStatus } + : undefined; + } + switch (row.status) { + case "ok": + case "not_reported": + case "timeout": + case "network_error": + case "invalid_response": + case "internal_error": + return { status: row.status }; + default: + return undefined; + } +} diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 5e0d5ee0ed..68992dbe2c 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -961,14 +961,20 @@ function unixProcessGroupAlive(groupId: number): boolean { } function terminateUnixProcessGroup(groupId: number): void { + let permissionError: unknown; try { process.kill(-groupId, "SIGKILL"); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code === "EPERM") permissionError = error; + else if (code !== "ESRCH") throw error; } + // A concurrently exiting group can briefly reject a second signal. Only + // observed disappearance clears that uncertainty; never send another signal. const deadline = Date.now() + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS; while (Date.now() < deadline && unixProcessGroupAlive(groupId)) Bun.sleepSync(10); if (unixProcessGroupAlive(groupId)) { + if (permissionError) throw permissionError; throw new Error(`Codex shim install probe process group ${groupId} did not terminate`); } } diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index ae48650b0d..bd88e82244 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -1,6 +1,7 @@ import type { OcxComboTarget, OcxConfig } from "../types"; import { getCachedProviderQuota } from "../providers/quota-routing-cache"; import type { ProviderQuota } from "../providers/quota-types"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { sleepWithAbort } from "../lib/upstream-retry"; import { coolComboTarget, @@ -60,9 +61,13 @@ export class NoAvailableComboTargetsError extends Error { } } -function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget): boolean { - return Object.hasOwn(config.providers, target.provider) - && config.providers[target.provider]?.disabled !== true; +function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now: number): boolean { + if (!Object.hasOwn(config.providers, target.provider)) return false; + const provider = config.providers[target.provider]; + if (!provider || provider.disabled === true) return false; + // Native account selection owns model-scoped quota; a provider summary cannot veto it. + return isCanonicalOpenAiForwardProvider(provider) + || !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now); } function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean { @@ -159,8 +164,7 @@ export function pickComboTarget( const excluded = new Set(options.exclude ?? []); const now = options.now ?? Date.now(); const eligible = (target: Required): boolean => - targetProviderIsUsable(config, target) - && !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now) + targetProviderIsUsable(config, target, now) && !isComboTargetInCooldown(comboId, target, now) && !excluded.has(targetKey(target)) && (options.eligible?.(target) ?? true); @@ -338,8 +342,7 @@ export async function pickComboTargetWithWait( const combo = getCombo(config, comboId); if (!combo) throw new UnknownComboError(comboId); const waitingTargets = combo.targets.filter(target => - targetProviderIsUsable(config, target) - && !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now) + targetProviderIsUsable(config, target, now) && !excluded.has(targetKey(target)) && isComboTargetInCooldown(comboId, target, now) && (customEligible?.(target) ?? true), diff --git a/src/config.ts b/src/config.ts index d8d1cd175b..2dd83b80fa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; @@ -8,7 +8,6 @@ import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./config/subag export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models"; import { apiKeyTransportConfigError, - azureCredentialConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, modelDisplayNamesConfigError, @@ -19,9 +18,7 @@ import { providerBaseUrlConfigError, providerHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, - maxWsFrameBytesConfigError, upstreamHttpVersionConfigError, - wsUpstreamConfigError, } from "./config/provider-validation"; import { bumpConfigGenerationAtPath, @@ -65,7 +62,6 @@ import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; -import { antigravityOAuthDestinationConfigError, providerTlsProfileConfigError } from "./lib/provider-tls-profile"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases"; import { MODEL_DISCOVERY_MAX_MODELS } from "./providers/model-discovery-limits"; @@ -92,16 +88,16 @@ import { providerModelWireDefault, registryModelServiceTierCapabilityApplies, } from "./providers/registry"; -import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { slugEquivalenceKey, slugsEquivalent } from "./providers/slug-codec"; +import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort } from "./reasoning-effort"; -import { parseSubagentRoles, salvageSubagentRoles } from "./codex/agent-roles"; import { COST4_RATE_KEYS, isValidCost4Rate, refreshPreservedProviderOwner, refreshUserCostOverlays, + withPreservedDiskOnlyProviders, } from "./usage/user-cost-overlays"; import { MAX_COST4_RATE } from "./usage/expected-prices"; import { @@ -111,12 +107,9 @@ import { } from "./lib/app-owned-memory"; import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy"; import { - AtomicWriteResidualTempError, - AtomicWriteSecretResidualError, atomicWriteFile, isMissingPathError, nextAtomicTempSequence, - resolveWriteTarget, } from "./config/atomic-write"; export { AtomicWriteResidualTempError, @@ -131,6 +124,7 @@ export { type AtomicWriteIO, } from "./config/atomic-write"; import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; +import { InitialConfigPublicationError, publishInitialConfigNoReplace, type InitialConfigPublicationIO } from "./config/initialize"; import { describeProxyForLog, readWindowsSystemProxy, @@ -466,34 +460,46 @@ const retryOn429PolicySchema = z.object({ }).strict(); /** - * `transientRetryOn5xx` is a request-wide total-send budget. Keep it bounded at - * every config boundary so a malformed or hostile value cannot turn one request - * into an effectively unbounded retry loop. + * `transientRetryOn5xx` accepts only these keys. `attempts` is a TOTAL send budget shared by + * both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends + * against an already-failing provider is already generous. */ const transientRetryOn5xxPolicySchema = z.object({ enabled: z.boolean().optional(), attempts: z.number().int().min(1).max(10).optional(), }).strict(); +export function transientRetryOn5xxPolicyConfigError(policy: unknown): string | null { + if (policy === undefined) return null; + const result = transientRetryOn5xxPolicySchema.safeParse(policy); + if (result.success) return null; + const first = result.error.issues[0]; + if (!first) return "transientRetryOn5xx is invalid"; + if (first.code === "unrecognized_keys") { + const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", "); + return `transientRetryOn5xx has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; + } + if (first.path.length === 0) return `transientRetryOn5xx is invalid (${first.message})`; + const field = String(first.path[first.path.length - 1]); + return `transientRetryOn5xx.${field} is invalid (${first.message})`; +} + const requestPacingRuleSchema = z.object({ // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), - jitterMs: z.number().int().min(0).max(60_000).optional(), -}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined || value.jitterMs !== undefined, { - message: "request pacing rules need requestsPerMinute, minIntervalMs, or jitterMs", +}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, { + message: "request pacing rules need requestsPerMinute or minIntervalMs", }); const requestPacingSchema = z.object({ enabled: z.boolean(), requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), - jitterMs: z.number().int().min(0).max(60_000).optional(), models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(), }).strict().refine(value => value.enabled === false || value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined - || value.jitterMs !== undefined || (value.models !== undefined && Object.keys(value.models).length > 0), { message: "enabled request pacing needs a provider rule or model override", }); @@ -526,16 +532,6 @@ const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => { return labels; }); -const initialModelSelectionSchema = z.object({ - version: z.literal(1), - // Runtime treats the registration id as an incarnation fence and accepts only UUIDv4. - registrationId: z.uuid({ version: "v4" }), - status: z.enum(["pending", "ready", "all-off"]), - modelCount: z.number().int().nonnegative().optional(), -}); - -const blockedModelRedirectsSchema = z.record(z.string(), z.string()); - /** * Zod schema for one provider entry: known fields are validated strictly while unknown * fields pass through (preserved for runtime extensions). @@ -543,17 +539,16 @@ const blockedModelRedirectsSchema = z.record(z.string(), z.string()); const providerConfigSchema = z.object({ adapter: z.string().min(1), baseUrl: z.string().min(1), - azureCredential: z.object({ - type: z.literal("default-azure-credential"), - managedIdentityClientId: z.string().trim().min(1).optional(), - }).strict().transform(value => value.managedIdentityClientId === undefined - ? value - : { ...value, managedIdentityClientId: value.managedIdentityClientId.trim() }).optional(), alias: z.string().optional(), modelAliases: z.record(z.string(), z.string()).optional(), modelDisplayNames: modelDisplayNamesSchema.optional(), defaultAliases: z.boolean().optional(), - initialModelSelection: initialModelSelectionSchema.optional().catch(undefined), + initialModelSelection: z.object({ + version: z.literal(1), + registrationId: z.uuid(), + status: z.enum(["pending", "ready", "all-off"]), + modelCount: z.number().int().nonnegative().optional(), + }).optional().catch(undefined), requestPacing: requestPacingSchema.optional().catch(undefined), mcpMaxTools: z.number().int().positive().optional(), mcpMaxSchemaBytes: z.number().int().positive().optional(), @@ -570,8 +565,6 @@ const providerConfigSchema = z.object({ decodesNativeCompactionBlobs: z.boolean().optional(), allowEncryptedV2AgentTasks: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), - wsUpstream: z.boolean().nullish().transform(value => value ?? undefined), - maxWsFrameBytes: z.number().int().positive().nullish().transform(value => value ?? undefined), // The management API accepts `null` as "clear this", so a config written before the POST // canonicalization below can hold one on disk. Rejecting it here would send the operator // through invalid-config recovery for a value the API told them was fine. @@ -594,15 +587,12 @@ const providerConfigSchema = z.object({ .optional(), retryOn429: retryOn429PolicySchema.optional(), transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), - replayTransientFailures: z.boolean().optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), // Validated rather than passed through: this schema ends in `.passthrough()`, so an // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be // accepted, persisted, and then silently resolved to the `code_mode_only` default — the // operator asked for shell mode, got code mode, and was told nothing (#2106). codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), - projectContext: z.enum(["off", "on"]).optional(), - tlsProfile: z.literal("antigravity-browser").optional(), responsesItemIdRepair: z.object({ message: z.array(z.string().min(1)).optional(), reasoning: z.array(z.string().min(1)).optional(), @@ -617,8 +607,6 @@ const providerConfigSchema = z.object({ export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; export { apiKeyTransportConfigError, - azureCredentialConfigError, - isAzureIdentityProvider, booleanRecordConfigError, modelAdapterRecordConfigError, modelDisplayNamesConfigError, @@ -629,9 +617,7 @@ export { providerBaseUrlConfigError, providerHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, - maxWsFrameBytesConfigError, upstreamHttpVersionConfigError, - wsUpstreamConfigError, } from "./config/provider-validation"; function providerResponsesPathConfigError(responsesPath: string | undefined): string | null { @@ -964,6 +950,15 @@ const clientIntegrationsSchema = z.object({ "claude-desktop": z.boolean().optional().catch(undefined), }).passthrough(); +const asideProfileSyncSchema = z.object({ + allProfiles: z.boolean().optional(), + profiles: z.record( + z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))), + z.boolean(), + ).optional(), + legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(), +}).passthrough(); + const agentTaskRecoverySchema = z.object({ enabled: z.boolean().optional(), model: z.string().trim().min(1).optional(), @@ -971,11 +966,6 @@ const agentTaskRecoverySchema = z.object({ cacheEntries: z.number().int().min(1).max(512).optional(), }).strict(); -const v2NativeParentOverrideSchema = z.object({ - enabled: z.boolean().optional(), - model: z.string().trim().min(1).optional(), -}).strict(); - const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]); function canonicalHttpOrigin(value: string): string | null { @@ -1101,12 +1091,6 @@ const quotaResetNotifySchema = z.object({ const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), - autonomousRemediation: z.object({ - enabled: z.boolean().optional(), - instanceId: z.string().trim().min(1).optional(), - threshold: z.number().int().positive().optional(), - rollingWindowMs: z.number().int().positive().optional(), - }).strict().optional().catch(undefined), // A malformed hand edit must disable only remote-role behavior, not discard // providers or data-plane keys. Live writes are rejected explicitly below. runtimeRole: runtimeRoleSchema.optional().catch(undefined), @@ -1182,13 +1166,12 @@ const configSchema = z.object({ subagentModelsVersion: z.number().int().positive().optional().catch(undefined), subagentModels: z.array(z.string().min(1)).optional().catch(undefined), clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), + // A malformed profile policy must not fall back to legacy all-profile activation. + asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), + providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), - // Invalid hand edits disable only this experimental opt-in. - v2RoutedDelegationBridge: z.boolean().optional().catch(undefined), - // Invalid hand edits disable only this experimental opt-in subtree. - v2NativeParentOverride: v2NativeParentOverrideSchema.optional().catch(undefined), // Invalid optional recovery config must not discard unrelated provider/account state. agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), // Same rationale: a bad notify section must not cost the operator their providers. @@ -1200,18 +1183,12 @@ const configSchema = z.object({ injectionModel: z.string().optional().catch(undefined), injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), - syncCodexAgentRoles: z.boolean().optional().catch(undefined), - subagentRoles: z.unknown().optional(), // Per-primary-model fallback chains. Values must be non-empty string arrays; // malformed entries degrade to undefined rather than rejecting the whole config. subagentModelFallbackByModel: z.record( z.string(), z.array(z.string().trim().min(1)).min(1), ).optional().catch(undefined), - subagentCandidates: z.union([ - z.array(z.string().trim().min(1)).min(1), - z.record(z.string().trim().min(1), z.array(z.string().trim().min(1)).min(1)), - ]).optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), codexDesktopAuthless: z.boolean().optional().catch(undefined), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), @@ -1239,7 +1216,7 @@ const configSchema = z.object({ // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), - blockedModelRedirects: blockedModelRedirectsSchema.optional().catch(undefined), + blockedModelRedirects: z.record(z.string(), z.string()).optional().catch(undefined), // Same degrade-don't-reject rationale as the fields above: a hand-edited // non-string must not trip the backup-and-defaults repair path. Unset then // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot). @@ -1266,10 +1243,7 @@ const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { - desktopProfile?: unknown; - compatibility?: unknown; - }; + const claude = claudeCode as { desktopProfile?: unknown }; if (claude.desktopProfile !== undefined) { try { parseDesktopProfile(claude.desktopProfile); @@ -1281,13 +1255,6 @@ const configSchema = z.object({ }); } } - if (claude.compatibility !== undefined && claude.compatibility !== "shadow" && claude.compatibility !== "enforce") { - ctx.addIssue({ - code: "custom", - path: ["claudeCode", "compatibility"], - message: "compatibility must be \"shadow\" or \"enforce\"", - }); - } } const accountNamespaces = config.codexAccountNamespaces; @@ -1396,30 +1363,6 @@ const configSchema = z.object({ message: responsesPathError, }); } - const wsUpstreamError = wsUpstreamConfigError((provider as { wsUpstream?: unknown }).wsUpstream); - if (wsUpstreamError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "wsUpstream"], - message: wsUpstreamError, - }); - } - const maxWsFrameBytesError = maxWsFrameBytesConfigError((provider as { maxWsFrameBytes?: unknown }).maxWsFrameBytes); - if (maxWsFrameBytesError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "maxWsFrameBytes"], - message: maxWsFrameBytesError, - }); - } - const tlsProfileError = providerTlsProfileConfigError(name, provider); - if (tlsProfileError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "tlsProfile"], - message: tlsProfileError, - }); - } const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers); if (headersError) { ctx.addIssue({ @@ -1456,14 +1399,6 @@ const configSchema = z.object({ message: apiKeyTransportError, }); } - const azureCredentialError = azureCredentialConfigError(provider); - if (azureCredentialError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "azureCredential"], - message: azureCredentialError, - }); - } const modelAdaptersError = modelAdapterRecordConfigError( (provider as { modelAdapters?: unknown }).modelAdapters, "modelAdapters", @@ -1727,11 +1662,6 @@ function sanitizeRetryOn429ForLoad(parsed: unknown): void { const safeProviderName = JSON.stringify(redactSecretString(name)); if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; const p = provider as Record; - if (p.replayTransientFailures !== undefined && typeof p.replayTransientFailures !== "boolean") { - const receivedType = typeof p.replayTransientFailures; - delete p.replayTransientFailures; - console.warn(`⚠️ config.json providers.${safeProviderName}.replayTransientFailures (${receivedType}) is invalid — ignoring the field`); - } const policy = p.retryOn429; if (policy === undefined) continue; if (!policy || typeof policy !== "object" || Array.isArray(policy)) { @@ -1805,22 +1735,6 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null { return `retryOn429.${field} is invalid (${first.message})`; } -/** Strict write-boundary validation for the opt-in transient 5xx retry budget. */ -export function transientRetryOn5xxPolicyConfigError(policy: unknown): string | null { - if (policy === undefined) return null; - const result = transientRetryOn5xxPolicySchema.safeParse(policy); - if (result.success) return null; - const first = result.error.issues[0]; - if (!first) return "transientRetryOn5xx is invalid"; - if (first.code === "unrecognized_keys") { - const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", "); - return `transientRetryOn5xx has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; - } - if (first.path.length === 0) return `transientRetryOn5xx is invalid (${first.message})`; - const field = String(first.path[first.path.length - 1]); - return `transientRetryOn5xx.${field} is invalid (${first.message})`; -} - /** * Load-time degradation for `providers..modelCosts`, mirroring * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row @@ -2031,8 +1945,6 @@ function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCod if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) { delete normalized.subagentEffort; } - const isValidMode = (v: unknown): v is "shadow" | "enforce" => v === "shadow" || v === "enforce"; - if (Object.hasOwn(normalized, "compatibility") && !isValidMode(normalized.compatibility)) delete normalized.compatibility; // A hand-authored config never passes through the management validator, so coerce here too. // A malformed classifierFallbacks (a bare string, or an array with non-string entries) would // otherwise reach the resolver unchecked. @@ -2067,53 +1979,6 @@ function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void { } } -function normalizeSubagentRoles(config: OcxConfig, rawParsed: unknown): OcxConfig { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "subagentRoles")) return config; - const salvaged = salvageSubagentRoles(raw.subagentRoles); - const normalized = { ...config }; - if (salvaged.roles === undefined) delete normalized.subagentRoles; - else normalized.subagentRoles = salvaged.roles; - return normalized; -} - -function subagentRolesLoadWarnings(rawParsed: unknown): string[] { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "subagentRoles")) return []; - return salvageSubagentRoles(raw.subagentRoles).warnings; -} - -function malformedSyncCodexAgentRolesWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "syncCodexAgentRoles")) return null; - if (typeof raw.syncCodexAgentRoles === "boolean") return null; - return "syncCodexAgentRoles ignored: expected a boolean; treating as false"; -} - -function normalizeSyncCodexAgentRoles(config: OcxConfig, rawParsed: unknown): OcxConfig { - return malformedSyncCodexAgentRolesWarning(rawParsed) - ? { ...config, syncCodexAgentRoles: false } - : config; -} - -function warnDegradedSyncCodexAgentRoles(rawParsed: unknown): void { - const warning = malformedSyncCodexAgentRolesWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -function warnDegradedSubagentRoles(rawParsed: unknown): void { - for (const warning of subagentRolesLoadWarnings(rawParsed)) { - console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); - } -} - -function subagentRolesError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "subagentRoles")) return null; - const parsed = parseSubagentRoles(raw.subagentRoles); - return parsed.ok ? null : `schema_invalid: ${parsed.error}`; -} - function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null { const raw = rawConfigRecord(rawParsed); if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null; @@ -2358,8 +2223,6 @@ export function loadConfig(): OcxConfig { warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); warnDegradedClaudeSubagentEffort(parsed); - warnDegradedSubagentRoles(parsed); - warnDegradedSyncCodexAgentRoles(parsed); warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); @@ -2367,7 +2230,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); - return withRefreshedCostOverlays(normalizeSyncCodexAgentRoles(normalizeSubagentRoles(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed), parsed), parsed)); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Schema validation failed — merge defaults into the raw object instead of // discarding it entirely, so pool accounts and providers survive a missing @@ -2388,8 +2251,6 @@ export function loadConfig(): OcxConfig { warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); warnDegradedClaudeSubagentEffort(parsed); - warnDegradedSubagentRoles(parsed); - warnDegradedSyncCodexAgentRoles(parsed); warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); @@ -2397,7 +2258,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); - return withRefreshedCostOverlays(normalizeSyncCodexAgentRoles(normalizeSubagentRoles(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed), parsed), parsed)); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Still failing, but if every complaint is about one or more named entries // in an independent section, drop exactly those and keep the rest. Falling @@ -2414,8 +2275,6 @@ export function loadConfig(): OcxConfig { warnDegradedCodexAccountPriorities(parsed, config); warnDegradedCodexQuotaAutoRefresh(parsed, config); warnDegradedClaudeSubagentEffort(parsed); - warnDegradedSubagentRoles(parsed); - warnDegradedSyncCodexAgentRoles(parsed); warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); @@ -2423,7 +2282,7 @@ export function loadConfig(): OcxConfig { warnDegradedRuntimeRole(parsed); warnDegradedOptionalRemoteBlocks(parsed); warnDegradedQuotaResetNotify(parsed); - return withRefreshedCostOverlays(normalizeSyncCodexAgentRoles(normalizeSubagentRoles(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed), parsed), parsed)); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } } // Merge couldn't fix it — truly broken config @@ -2541,7 +2400,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf // ordinary save persists the normalized absence. const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed); const rawEffort = rawClaudeSubagentEffort(rawParsed); - const normalized = normalizeSyncCodexAgentRoles(normalizeSubagentRoles(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed), rawParsed), rawParsed); + const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed); const warnings = configPlaceholderWarnings(normalized); warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); @@ -2550,9 +2409,6 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`); } - warnings.push(...subagentRolesLoadWarnings(rawParsed)); - const agentRoleSyncWarning = malformedSyncCodexAgentRolesWarning(rawParsed); - if (agentRoleSyncWarning) warnings.push(agentRoleSyncWarning); warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning)); const pickerWarning = malformedCodexAccountPickerWarning(rawParsed); if (pickerWarning) warnings.push(pickerWarning); @@ -2587,52 +2443,6 @@ export function subagentDefaultSyncEffective( return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim()); } -/** - * Resolve and normalize configured candidates for one spawned sub-agent. - * Supports a global ordered list or a record keyed by role/model, with - * `default` and `*` fallbacks for unmatched requests. - */ -export function resolveSubagentCandidates( - config: Pick | OcxConfig, - roleOrModel?: string, -): string[] { - const candidates = config?.subagentCandidates; - if (!candidates) return []; - - const normalizeList = (raw: unknown): string[] => { - if (!Array.isArray(raw)) return []; - const result: string[] = []; - const seen = new Set(); - for (const item of raw) { - if (typeof item !== "string") continue; - const trimmed = item.trim(); - if (!trimmed) continue; - const key = slugEquivalenceKey(trimmed); - if (seen.has(key)) continue; - seen.add(key); - result.push(trimmed); - } - return result; - }; - - if (Array.isArray(candidates)) return normalizeList(candidates); - if (typeof candidates !== "object") return []; - - const record = candidates as Record; - const trimmed = roleOrModel?.trim(); - let selected: unknown; - if (trimmed) { - if (Object.hasOwn(record, trimmed)) { - selected = record[trimmed]; - } else { - const matchingKey = Object.keys(record).find(key => slugsEquivalent(key, trimmed)); - if (matchingKey) selected = record[matchingKey]; - } - } - if (!selected) selected = record.default ?? record["*"]; - return normalizeList(selected); -} - function mergeConfigDefaults(parsed: unknown): unknown { if (!parsed || typeof parsed !== "object") return parsed; const defaults = getDefaultConfig(); @@ -2644,19 +2454,6 @@ function mergeConfigDefaults(parsed: unknown): unknown { return merged; } -function configNeedsProviderRepair(parsed: Record): boolean { - const providers = parsed.providers; - if (parsed.defaultProvider !== undefined - || (providers && typeof providers === "object" && !Array.isArray(providers) - && Object.keys(providers).length > 0)) return false; - const candidate = structuredClone(parsed); - sanitizeAliasesForLoad(candidate); - sanitizeRetryOn429ForLoad(candidate); - sanitizeModelCostsForLoad(candidate); - return !configSchema.safeParse(candidate).success - && configSchema.safeParse(mergeConfigDefaults(candidate)).success; -} - function schemaDiagnosticsError(error: z.ZodError): string { const details = error.issues.map(issue => { const path = issue.path.join(".") || "config"; @@ -2720,25 +2517,6 @@ function agentTaskRecoveryError(value: unknown): string | null { return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; } -function v2RoutedDelegationBridgeError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "v2RoutedDelegationBridge")) return null; - const enabled = raw.v2RoutedDelegationBridge; - return enabled === undefined || typeof enabled === "boolean" - ? null - : "schema_invalid: v2RoutedDelegationBridge: must be a boolean or omitted"; -} - -function v2NativeParentOverrideError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "v2NativeParentOverride") || raw.v2NativeParentOverride === undefined) return null; - const result = v2NativeParentOverrideSchema.safeParse(raw.v2NativeParentOverride); - if (result.success) return null; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return `schema_invalid: v2NativeParentOverride${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; -} - function runtimeRoleError(value: unknown): string | null { const raw = rawConfigRecord(value); if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null; @@ -2963,36 +2741,6 @@ function managementIngressConfigError(value: unknown): string | null { return null; } -/** Live/import writes must not receive the load path's degrade-to-undefined leniency. */ -function initialModelSelectionConfigError(value: unknown): string | null { - const raw = rawConfigRecord(value); - const providers = rawConfigRecord(raw?.providers); - if (!providers) return null; - for (const [name, candidate] of Object.entries(providers)) { - const provider = rawConfigRecord(candidate); - if (!provider || !Object.hasOwn(provider, "initialModelSelection") - || provider.initialModelSelection === undefined) continue; - const parsed = initialModelSelectionSchema.safeParse(provider.initialModelSelection); - if (parsed.success) continue; - const issue = parsed.error.issues[0]; - const field = issue?.path.length ? `.${issue.path.join(".")}` : ""; - return `schema_invalid: providers.${redactSecretString(name)}.initialModelSelection${field}: ${issue?.message ?? "invalid configuration"}`; - } - return null; -} - -/** Reject malformed live/import writes while retaining load-time degradation for hand edits. */ -function blockedModelRedirectsConfigError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "blockedModelRedirects") - || raw.blockedModelRedirects === undefined) return null; - const parsed = blockedModelRedirectsSchema.safeParse(raw.blockedModelRedirects); - if (parsed.success) return null; - const issue = parsed.error.issues[0]; - const field = issue?.path.length ? `.${issue.path.join(".")}` : ""; - return `schema_invalid: blockedModelRedirects${field}: ${issue?.message ?? "invalid configuration"}`; -} - export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const boundaryError = blankHostnameError(value) ?? (() => { @@ -3001,12 +2749,9 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx return error ? `schema_invalid: ${error}` : null; })() ?? claudeSubagentEffortError(value) - ?? subagentRolesError(value) ?? appOwnedMemoryBudgetError(value) ?? upstreamHostCircuitThresholdError(value) ?? agentTaskRecoveryError(value) - ?? v2RoutedDelegationBridgeError(value) - ?? v2NativeParentOverrideError(value) ?? quotaResetNotifyError(value) ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) @@ -3019,22 +2764,11 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? clientConnectionConfigError(value) ?? clientRolePairError(value) ?? loopbackListenerPortError(value) - ?? managementIngressConfigError(value) - ?? initialModelSelectionConfigError(value) - ?? blockedModelRedirectsConfigError(value); + ?? managementIngressConfigError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); - for (const [name, provider] of Object.entries(config.providers)) { - const antigravityError = antigravityOAuthDestinationConfigError(name, provider); - if (antigravityError) return { ok: false, error: `providers.${name}.baseUrl: ${antigravityError}` }; - } - if (value && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, "subagentRoles")) { - const parsedRoles = parseSubagentRoles((value as { subagentRoles?: unknown }).subagentRoles); - if (!parsedRoles.ok) return { ok: false, error: `schema_invalid: ${parsedRoles.error}` }; - config.subagentRoles = parsedRoles.roles; - } return { ok: true, config }; } return { ok: false, error: schemaDiagnosticsError(result.error) }; @@ -3105,6 +2839,16 @@ export function readConfigDiagnostics(): ConfigDiagnostics { return readConfigFileSnapshot().diagnostics; } +/** Read-only init preflight. Occupied unsafe entries are never treated as absence. */ +export function observeInitialConfigState(): "missing" | "exists" | "invalid" { + try { + if (!lstatSync(getConfigPath()).isFile()) return "invalid"; + } catch (error) { + return isMissingPathError(error) ? "missing" : "invalid"; + } + return readConfigFileSnapshot().diagnostics.source === "file" ? "exists" : "invalid"; +} + /** * The persisted config, plus a digest of the EXACT bytes it was parsed from. * @@ -3381,32 +3125,8 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync * cost-overlay registry from the persisted config so runtime estimates follow * every save path. */ -type PersistConfigAuthority = "ordinary" | "mutation" | "replacement"; - -function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthority = "ordinary"): boolean { +function persistConfigUnlocked(config: OcxConfig): boolean { const configPath = getConfigPath(); - // Check the resolved file target before reading it: a symlink can point from an - // isolated test home into the protected real home, where another write guard - // must not mask this refusal based on the target's current contents. - assertNotRealHomeUnderTest(dirname(resolveWriteTarget(configPath))); - const raw = readRawConfigJson(); - if (authority !== "replacement" && raw && configNeedsProviderRepair(raw)) { - throw new Error("refusing to overwrite a config repaired with defaults; fix the persisted config first"); - } - const snapshot = readConfigFileSnapshot(); - if (authority !== "replacement" && snapshot.diagnostics.source === "fallback") { - throw new Error("refusing to overwrite an invalid persisted config; fix the persisted config first"); - } - // Automatic whole-config writes own non-provider settings only. A valid disk - // registry is authoritative; explicit locked mutations pass replacement - // authority for intentional provider/default changes. - const base = snapshot.diagnostics.source === "file" && authority === "ordinary" - ? { - ...config, - providers: snapshot.diagnostics.config.providers, - defaultProvider: snapshot.diagnostics.config.defaultProvider, - } - : config; const rawBeforeWrite = readRawConfigJson(); const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config); if (clientPersistenceError) throw new Error(clientPersistenceError); @@ -3416,7 +3136,7 @@ function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthor // Provider preservation reads symbol-keyed live-owner state, which structuredClone // intentionally drops. Resolve that ownership before projecting JSON provenance. const provenanceProjection = projectConfigRebaseProvenance(config); - const persisted = base; + const persisted = withPreservedDiskOnlyProviders(config); if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance; else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance; const bytes = JSON.stringify(persisted, null, 2) + "\n"; @@ -3441,197 +3161,58 @@ function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthor return true; } -/** Persist `config` to config.json under the config-mutation lock. */ -export function saveConfig(config: OcxConfig): void { - // Keep the real-home assertion ahead of even lock-directory preparation. - assertNotRealHomeUnderTest(getConfigDir()); - withConfigMutationLockSync(() => { - const withProvenance = projectCustomModelCatalogMigration( - readRawConfigJson(), - projectConfigRebaseProvenance(config), - ); - if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite(); - adoptCustomModelCatalogMigration(config, withProvenance); - if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance; - else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance); - clearPendingConfigTopLevelDeletions(config); - }); -} - -/** Replace a validated config under the shared lock for confirmed import/init flows. */ -export function replacePersistedConfig(config: OcxConfig): void { - assertNotRealHomeUnderTest(getConfigDir()); - withConfigMutationLockSync(() => { - const projected = projectCustomModelCatalogMigration( - readRawConfigJson(), - projectConfigRebaseProvenance(config), - ); - if (persistConfigUnlocked(projected, "replacement")) bumpGenerationForCooperatingConfigWrite(); - adoptCustomModelCatalogMigration(config, projected); - if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance; - else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance); - clearPendingConfigTopLevelDeletions(config); - }); -} - export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid"; -export class PersistedConfigInitializationCleanupError extends Error { - constructor(options?: ErrorOptions) { - super("Initial config publication cleanup failed after rollback", options); - this.name = "PersistedConfigInitializationCleanupError"; - } -} - -export class PersistedConfigInitializationRollbackError extends Error { - constructor(options?: ErrorOptions) { - super("Initial config publication rollback failed", options); - this.name = "PersistedConfigInitializationRollbackError"; - } -} - -export interface PersistedConfigInitializationIO { - createExclusive(path: string): void; - write(path: string, bytes: string): void; - harden(path: string): void; - publishNoReplace(temp: string, target: string): void; - truncate(path: string): void; - unlink(path: string): void; -} - -let persistedConfigInitializationBeforePublishForTests: (() => void) | null = null; - -/** Test-only one-shot seam: create a competing config after staging, before no-replace publication. */ -export function setPersistedConfigInitializationBeforePublishForTests(hook: (() => void) | null): void { - persistedConfigInitializationBeforePublishForTests = hook; -} - -function publishInitialConfigNoReplace( +/** Initialize only a missing config; ordinary explicit updates still use saveConfig. */ +export function initializePersistedConfigIfMissing( config: OcxConfig, - io: PersistedConfigInitializationIO, -): boolean { - const configPath = getConfigPath(); - const target = resolveWriteTarget(configPath); - assertNotRealHomeUnderTest(dirname(target)); - recordOwnedConfigPath(getConfigDir(), configPath); - const persisted = projectConfigRebaseProvenance(config); - const bytes = JSON.stringify(persisted, null, 2) + "\n"; - const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; - let staged = false; - let hardened = false; + io?: Partial, +): PersistedConfigInitializationOutcome { + assertNotRealHomeUnderTest(getConfigDir()); + const before = observeInitialConfigState(); + if (before !== "missing") return before; let published = false; - let cleanupAttempted = false; - - const scrubUnpublishedTemp = (cause?: unknown): void => { - cleanupAttempted = true; - let scrubbed = false; - try { - io.truncate(temp); - scrubbed = true; - } catch (error) { - if (isMissingPathError(error)) scrubbed = true; - else { - try { io.write(temp, ""); scrubbed = true; } catch { /* removal may still succeed */ } - } - } - let removed = false; - try { - io.unlink(temp); - removed = true; - } catch (error) { - if (isMissingPathError(error)) removed = true; - else { - try { io.unlink(temp); removed = true; } - catch (retryError) { if (isMissingPathError(retryError)) removed = true; } - } - } - if (removed) forgetEphemeralSecretPath(temp); - if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(temp, { cause }); - if (!removed) throw new AtomicWriteResidualTempError(temp, hardened, { cause }); - }; - try { - io.createExclusive(temp); - staged = true; - io.write(temp, bytes); - io.harden(temp); - hardened = true; - const hook = persistedConfigInitializationBeforePublishForTests; - persistedConfigInitializationBeforePublishForTests = null; - hook?.(); - try { - io.publishNoReplace(temp, target); - } catch (cause) { - if (!isAlreadyExistsError(cause)) throw cause; - scrubUnpublishedTemp(cause); - return false; - } - published = true; - try { - io.unlink(temp); - forgetEphemeralSecretPath(temp); - } catch (firstError) { - if (isMissingPathError(firstError)) { - forgetEphemeralSecretPath(temp); - } else try { - io.unlink(temp); - forgetEphemeralSecretPath(temp); - } catch (secondError) { - if (isMissingPathError(secondError)) { - forgetEphemeralSecretPath(temp); - } else { - // Both names point to one inode. Remove the published name before scrubbing. - try { io.unlink(target); } - catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); } - published = false; - scrubUnpublishedTemp(secondError); - throw new PersistedConfigInitializationCleanupError({ cause: secondError }); - } + const persisted = withConfigMutationLockSync((): OcxConfig | "exists" | "invalid" => { + const current = observeInitialConfigState(); + if (current !== "missing") return current; + const projected = projectCustomModelCatalogMigration(undefined, projectConfigRebaseProvenance(config)); + if (!validateConfigCandidate(projected).ok) throw new Error("Initial configuration is invalid."); + if (!publishInitialConfigNoReplace(getConfigPath(), JSON.stringify(projected, null, 2) + "\n", io)) { + return observeInitialConfigState() === "exists" ? "exists" : "invalid"; } - } + published = true; + recordOwnedConfigPath(getConfigDir(), getConfigPath()); + bumpGenerationForCooperatingConfigWrite(); + return projected; + }); + if (typeof persisted === "string") return persisted; + adoptCustomModelCatalogMigration(config, persisted); + if (persisted.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(persisted.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); refreshUserCostOverlays(persisted); - return true; + return "created"; } catch (cause) { - if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause); + if (published) throw new InitialConfigPublicationError("published", false, false, { cause }); throw cause; } } -function defaultPersistedConfigInitializationIO(configPath: string): PersistedConfigInitializationIO { - return { - createExclusive: target => { writeFileSync(target, "", { flag: "wx", mode: 0o600 }); }, - write: (target, bytes) => writeFileSync(target, bytes), - harden: target => { - try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } - if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: configPath }); - }, - publishNoReplace: (temp, target) => linkSync(temp, target), - truncate: target => truncateSync(target, 0), - unlink: unlinkSync, - }; -} - -/** Create the initial config under the shared lock, but never replace existing bytes. */ -export function initializePersistedConfigIfMissing( - config: OcxConfig, - io = defaultPersistedConfigInitializationIO(getConfigPath()), -): PersistedConfigInitializationOutcome { +/** Persist `config` to config.json under the config-mutation lock. */ +export function saveConfig(config: OcxConfig): void { + // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); - return withConfigMutationLockSync(() => { - const snapshot = readConfigFileSnapshot(); - if (snapshot.diagnostics.source === "file") return "exists"; - if (snapshot.diagnostics.source !== "default") return "invalid"; - const projected = projectCustomModelCatalogMigration( + withConfigMutationLockSync(() => { + const withProvenance = projectCustomModelCatalogMigration( readRawConfigJson(), projectConfigRebaseProvenance(config), ); - if (!publishInitialConfigNoReplace(projected, io)) { - const winner = readConfigFileSnapshot(); - return winner.diagnostics.source === "file" ? "exists" : "invalid"; - } - bumpGenerationForCooperatingConfigWrite(); - adoptCustomModelCatalogMigration(config, projected); - return "created"; + if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite(); + adoptCustomModelCatalogMigration(config, withProvenance); + if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); }); } @@ -3644,13 +3225,6 @@ export type PersistedConfigMutationOutcome = | { status: "committed" | "unchanged"; value: T } | { status: "unavailable"; reason: "missing" | "invalid" | "conflict" }; -export class ConfigMutationValidationError extends Error { - constructor(readonly validationError: string) { - super(`Config mutation rejected: ${validationError}`); - this.name = "ConfigMutationValidationError"; - } -} - const CONFIG_MUTATION_MAX_REBASE_ATTEMPTS = 3; let persistedConfigMutationBeforeCommitForTests: (() => void) | null = null; @@ -3723,9 +3297,7 @@ export function mutatePersistedConfig( commitBase.diagnostics.config, projectConfigRebaseProvenance(confirmedConfig), ); - const validation = validateConfigCandidate(projected); - if (!validation.ok) throw new ConfigMutationValidationError(validation.error); - if (persistConfigUnlocked(projected, "mutation")) bumpGenerationForCooperatingConfigWrite(); + if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; } return { status: "unavailable", reason: "conflict" }; @@ -4080,7 +3652,7 @@ function readPersistedServerBinding( * conflict keeps the live value; * - a provider or custom-model row deleted on disk stays deleted even if stale * live state edited that same row; - * - missing file → save what we have; invalid existing file → fail closed. + * - file missing/unreadable → save what we have, no throw. * * Custom-model rows are merged by their stable `id`, preserving independent * edits and deletions across stale whole-config saves. @@ -4152,10 +3724,10 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port }; if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; else persistedConfig.hostname = persistedBinding.hostname; - if (persistConfigUnlocked(persistedConfig, "mutation")) bumpGenerationForCooperatingConfigWrite(); + if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); persistedLiveServerBinding.set(config, persistedBinding); } else { - if (persistConfigUnlocked(projectedConfig, "mutation")) bumpGenerationForCooperatingConfigWrite(); + if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite(); } adoptCustomModelCatalogMigration(config, projectedConfig); if (claudeCodeBaseline.has(config)) { @@ -4254,11 +3826,12 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements } /** - * Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound - * provider call through the proxy — no per-callsite changes (verified: Bun honors these plus - * NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the - * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry - * that makes outbound provider requests (server start, catalog sync). + * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports + * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY + * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. + * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and + * running-proxy API calls stay direct. Call once per process entry that makes outbound provider + * requests (server start, catalog sync). */ export function applyProxyEnv(config: OcxConfig): void { applyProxyEnvWith(config); @@ -4318,6 +3891,7 @@ export function applyProxyEnvWith( const raw = config.noProxy; let configuredEntries: string[]; if (Array.isArray(raw)) { + // One unusable element must not discard the operator's other entries. if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements"); configuredEntries = raw.filter((entry): entry is string => typeof entry === "string"); } else if (typeof raw === "string") { @@ -4345,7 +3919,7 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void { if (warnedConfigFallbacks.has(configPath)) return; warnedConfigFallbacks.add(configPath); const fields = error.issues.map(i => i.path.join(".") || "config").join(", "); - console.error(`opencodex config at ${configPath}: repaired invalid or missing field(s) [${fields}] in memory. A providerless fallback config will not be written automatically.`); + console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`); } /** @@ -4559,3 +4133,59 @@ export function backupInvalidConfig(configPath: string): string | null { return null; } } + +export function resolveSubagentCandidates( + config: Pick | OcxConfig, + roleOrModel?: string, +): string[] { + const candidates = config?.subagentCandidates; + if (!candidates) return []; + + const normalizeList = (raw: unknown): string[] => { + if (!Array.isArray(raw)) return []; + const result: string[] = []; + const seen = new Set(); + for (const item of raw) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed) continue; + const key = slugEquivalenceKey(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; + }; + + if (Array.isArray(candidates)) return normalizeList(candidates); + if (typeof candidates !== "object") return []; + + const record = candidates as Record; + const trimmed = roleOrModel?.trim(); + let selected: unknown; + if (trimmed) { + if (Object.hasOwn(record, trimmed)) { + selected = record[trimmed]; + } else { + const matchingKey = Object.keys(record).find(key => slugsEquivalent(key, trimmed)); + if (matchingKey) selected = record[matchingKey]; + } + } + if (!selected) selected = record.default ?? record["*"]; + return normalizeList(selected); +} + +export function replacePersistedConfig(config: OcxConfig): void { + assertNotRealHomeUnderTest(getConfigDir()); + withConfigMutationLockSync(() => { + const projected = projectCustomModelCatalogMigration( + readRawConfigJson(), + projectConfigRebaseProvenance(config), + ); + if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); + adoptCustomModelCatalogMigration(config, projected); + if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); + }); +} diff --git a/src/config/initialize.ts b/src/config/initialize.ts new file mode 100644 index 0000000000..864b09b036 --- /dev/null +++ b/src/config/initialize.ts @@ -0,0 +1,132 @@ +import { + closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, + openSync, unlinkSync, writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { forgetEphemeralSecretPath, hardenSecretPath } from "../lib/windows-secret-acl"; +import { isMissingPathError, nextAtomicTempSequence } from "./atomic-write"; + +type PublicationState = "not-published" | "published" | "uncertain"; + +/** Messages contain no candidate bytes or raw filesystem error text. */ +export class InitialConfigPublicationError extends Error { + constructor( + readonly publication: PublicationState, + readonly residualTemp: boolean, + readonly hardLinkUnavailable: boolean, + options?: ErrorOptions, + ) { + super(hardLinkUnavailable + ? "Initial config requires hard-link publication; the filesystem or its permissions denied it." + : "Initial config publication did not finish.", options); + this.name = "InitialConfigPublicationError"; + } +} + +/** Narrow fault boundary; publication must be a single link operation. */ +export interface InitialConfigPublicationIO { + harden(fd: number, temp: string, target: string): void; + write(fd: number, bytes: string): void; + link(temp: string, target: string): void; + unlink(temp: string): void; + close(fd: number): void; +} + +function hardenInitialConfig(fd: number, temp: string, target: string): void { + if (process.platform === "win32") { + hardenSecretPath(temp, { required: true, timeoutMemoKey: target }); + } else { + fchmodSync(fd, 0o600); + } +} + +function identifiesDescriptor(fd: number, path: string): boolean { + const opened = fstatSync(fd); + const entry = lstatSync(path); + return opened.isFile() && entry.isFile() + && opened.dev === entry.dev && opened.ino === entry.ino; +} + +function verifyPrivateTemp(fd: number, temp: string): void { + if (!identifiesDescriptor(fd, temp) + || (process.platform !== "win32" && (fstatSync(fd).mode & 0o777) !== 0o600)) { + throw new Error("Initial config temporary file identity or permissions changed."); + } +} + +function removeOwnedTemp(fd: number, temp: string, unlink: (path: string) => void): boolean { + for (let attempt = 0; attempt < 2; attempt++) { + try { + if (!identifiesDescriptor(fd, temp)) return false; + unlink(temp); + forgetEphemeralSecretPath(temp); + return true; + } catch (error) { + if (isMissingPathError(error)) { + forgetEphemeralSecretPath(temp); + return true; + } + } + } + return false; +} + +/** + * Publish complete bytes without replacing any entry at target. Never truncate: + * even an error from link can mean a remote filesystem already published the inode. + * Cleanup removes only our temporary name, never the target or another inode. + */ +export function publishInitialConfigNoReplace( + target: string, + bytes: string, + io: Partial = {}, +): boolean { + assertNotRealHomeUnderTest(dirname(target)); + const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + let fd: number | undefined; + let publication: PublicationState = "not-published"; + let collided = false; + let failure: unknown; + let failed = false; + let hardLinkUnavailable = false; + let residualTemp = false; + try { + fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + (io.harden ?? hardenInitialConfig)(fd, temp, target); + verifyPrivateTemp(fd, temp); + (io.write ?? ((descriptor: number, value: string) => writeFileSync(descriptor, value, { encoding: "utf8" })))(fd, bytes); + verifyPrivateTemp(fd, temp); + try { + publication = "uncertain"; + (io.link ?? linkSync)(temp, target); + publication = "published"; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + // EEXIST normally means a competitor won. A shared target means our + // publication may nevertheless have happened (e.g. a remote FS retry). + if (code === "EEXIST" && !identifiesDescriptor(fd, target)) collided = true; + else { + hardLinkUnavailable = ["EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV", "EPERM"].includes(code ?? ""); + throw error; + } + } + if (!collided && !identifiesDescriptor(fd, target)) { + throw new Error("Initial config published target identity changed."); + } + } catch (error) { + failed = true; + failure = error; + } finally { + if (fd !== undefined) { + // Unlink-only cleanup preserves all bytes if another name shares this inode. + residualTemp = !removeOwnedTemp(fd, temp, io.unlink ?? unlinkSync); + try { (io.close ?? closeSync)(fd); } + catch (error) { if (!failed) failure = error; failed = true; } + } + } + if (failed || residualTemp) { + throw new InitialConfigPublicationError(publication, residualTemp, hardLinkUnavailable, { cause: failure }); + } + return !collided; +} diff --git a/src/config/rebase-provenance.ts b/src/config/rebase-provenance.ts index 7f6857a94c..a799725d25 100644 --- a/src/config/rebase-provenance.ts +++ b/src/config/rebase-provenance.ts @@ -66,3 +66,29 @@ export function deleteConfigTopLevelKey(config: OcxCo export function clearPendingConfigTopLevelDeletions(config: OcxConfig): void { pendingTopLevelDeletions.delete(config); } + +/** + * Capture field replacements and deletion intent for a synchronous live-config save. + * Restore before yielding on failure: an asynchronous rollback could overwrite a newer + * mutation. Descriptors preserve absent versus explicitly undefined properties; the + * private pending set must also retain its original presence, even when it was empty. + * Unrelated fields and the live object's identity/baselines are left in place. + */ +export function captureConfigTopLevelRollback( + config: OcxConfig, + keys: readonly (keyof OcxConfig)[], +): () => void { + const descriptors = new Map([...new Set([...keys, CONFIG_REBASE_PROVENANCE_KEY])] + .map(key => [key, Object.getOwnPropertyDescriptor(config, key)] as const)); + const pending = pendingTopLevelDeletions.get(config); + const pendingBefore = pending === undefined ? undefined : new Set(pending); + return () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(config, key, descriptor); + else deleteConfigTopLevelKey(config, key); + } + // The absent fields above are restoration, not new user deletion commands. + if (pendingBefore === undefined) pendingTopLevelDeletions.delete(config); + else pendingTopLevelDeletions.set(config, new Set(pendingBefore)); + }; +} diff --git a/src/images/loop.ts b/src/images/loop.ts index e1c922b1eb..e3a7f8252f 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -18,7 +18,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuatio import { namespacedToolName, toolChoiceToolPredicate } from "../types"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; -import { bridgeToResponsesSSE, diagnoseAdapterEvent, type BridgeDiagnosticContext } from "../bridge"; +import { bridgeToResponsesSSE } from "../bridge"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; @@ -35,7 +35,6 @@ import { submitVideoJob } from "./xai-video-client"; import { downloadVideoToArtifact, createImageBudget, pruneArtifacts } from "./artifacts"; import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "./synthetic-tool"; import type { ImageBridgePlan, VideoBridgePlan } from "./types"; -import { OcxRequestValidationError } from "../lib/errors"; const SSE_HEADERS = { "Content-Type": "text/event-stream", @@ -210,14 +209,8 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] return parts; } -function jsonError(status: number, message: string, errorType = "upstream_error", code: string | null = null): Response { - return new Response(JSON.stringify({ - error: { - message, - type: errorType, - code, - }, - }), { +function jsonError(status: number, message: string): Response { + return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { status, headers: { "Content-Type": "application/json" }, }); @@ -226,12 +219,7 @@ function jsonError(status: number, message: string, errorType = "upstream_error" /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a * non-2xx jsonError; later (already-streaming) iterations surface it as an in-stream error event. */ class LoopError extends Error { - constructor( - readonly status: number, - message: string, - readonly errorType?: string, - readonly code?: string, - ) { + constructor(readonly status: number, message: string) { super(message); this.name = "LoopError"; } @@ -249,16 +237,12 @@ export interface ImageBridgeDeps { videoPlan?: VideoBridgePlan; /** Per-video generation timeout (ms) including polling. */ videoTimeoutMs?: number; - /** OAuth account identity forwarded to AdapterFetchContext for provider-local cooldown bookkeeping. */ - accountId?: string; /** Headers forwarded from the original request (e.g. Codex auth). Cloned per iteration. */ forwardHeaders?: Headers; /** Called before each routed-model dispatch in the bridge loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */ onAttemptSend?: (recovery?: AttemptRecoveryKind) => void; /** Called after each upstream request is built (parity with web-search / normal path). */ onRequestBuilt?: (request: AdapterRequest) => void; - /** Validate the final adapter before every cached replay or request build. */ - validateAdapter?: (parsed: OcxParsedRequest, adapter: ProviderAdapter) => void; abortSignal?: AbortSignal; onFirstOutput?: () => void; /** Max image-generation rounds before forcing a final answer. Defaults to 3; clamped to [0, 10]. */ @@ -269,6 +253,8 @@ export interface ImageBridgeDeps { stallTimeoutSec?: number; /** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */ fetchImpl?: typeof globalThis.fetch; + /** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */ + fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest) => typeof globalThis.fetch; /** Reserve the routed provider's next request-start slot before each adapter dispatch. */ waitForRequestSlot?: (signal?: AbortSignal) => Promise; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ @@ -285,8 +271,6 @@ export interface ImageBridgeDeps { onCompletedResponse?: (response: Record, providerState?: OcxProviderContinuationState) => void; /** WebSocket Responses path only — leave response id empty for protocol compatibility. */ forceEmptyResponseId?: boolean; - /** Internal, opt-in structural stream diagnostics shared with the final bridge. */ - diagnostic?: BridgeDiagnosticContext; } /** @@ -504,7 +488,6 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise => { - deps.validateAdapter?.(iterParsed, requestAdapter); let request: AdapterRequest; if (cachedRequest !== undefined && cachedAdapter === requestAdapter) { request = cachedRequest; @@ -518,6 +501,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise deps.onUsage?.(usage), } : {}), ...(deps.onCompletedResponse ? { onCompletedResponse: deps.onCompletedResponse } : {}), - ...(deps.diagnostic ? { diagnostic: deps.diagnostic } : {}), }, ); return new Response(sse, { headers: SSE_HEADERS }); diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts new file mode 100644 index 0000000000..d0bd9536cb --- /dev/null +++ b/src/integrations/aside-profile-context.ts @@ -0,0 +1,270 @@ +import { lstatSync, readdirSync, realpathSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { ClientPathError, type ExportModel } from "../clients/config-export"; +import { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, type AsideProfile } from "../clients/aside-profiles"; +import type { OcxConfig } from "../types"; +import { type IntegrationIO } from "./config-io"; +import type { JournalEntry } from "./journal"; +import { IntegrationMutationBusyError, runIntegrationMutationFlight } from "./mutation-flight"; +import { fingerprint } from "./ownership"; +import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; +import type { IntegrationWriteInput, WriteOutcome } from "./writer"; +import type { IntegrationWriterLockSeams } from "./writer-lock"; + +export interface AsideProfilesInput { + config: OcxConfig; + models: readonly ExportModel[] | (() => Promise); + port: number; + env?: NodeJS.ProcessEnv; + home?: string; + store?: IntegrationStateStore; + io?: IntegrationIO; + persistConfig?: (config: OcxConfig) => void | Promise; + lockSeams?: IntegrationWriterLockSeams; +} + +export class AsideProfileError extends Error { + constructor(readonly code: string, readonly status: number, message: string) { + super(message); + this.name = "AsideProfileError"; + } +} + +export type AsideProfilePolicy = NonNullable; +export type AsideProfileWriteOutcome = WriteOutcome & { profileId: number }; +export interface AsideProfileScope { + profile: AsideProfile; + store: IntegrationStateStore; + io: IntegrationIO; + assertBoundary: () => void; +} +export interface AsideProfileContext { + input: AsideProfilesInput; + profiles: AsideProfile[]; + rootStore: IntegrationStateStore; + legacyProfileId: number | null; + defaultEnabled: boolean; + models: () => Promise; + scopes: Map; +} + +function storeUnsafe(): never { + throw new AsideProfileError("aside_profile_store_unsafe", 409, "Aside profile ownership storage cannot be accessed safely"); +} + +/** Allow aliases above the trusted anchor, never at or below it. */ +function storeGuard(anchor: string, target: string): () => void { + const base = resolve(anchor); + const root = resolve(target); + const rel = relative(base, root); + if (rel.startsWith(`..${sep}`) || rel === ".." || resolve(base, rel) !== root) storeUnsafe(); + const identities = new Map(); + const inspect = (path: string, directory: boolean): boolean => { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe(); + if (directory) { + const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`; + if (identities.has(path) && identities.get(path) !== identity) storeUnsafe(); + identities.set(path, identity); + } + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" && !identities.has(path)) return false; + storeUnsafe(); + } + }; + return () => { + let path = base; + if (!inspect(path, true)) return; + for (const part of rel ? rel.split(sep) : []) { + path = join(path, part); + if (!inspect(path, true)) return; + } + for (const name of ["records.json", "journal.jsonl", "maintenance.json"]) inspect(join(root, name), false); + const snapshots = join(root, "snapshots"); + if (!inspect(snapshots, true)) return; + const aside = join(snapshots, "aside"); + if (inspect(aside, true)) for (const name of readdirSync(aside)) inspect(join(aside, name), false); + }; +} + +/** Store methods close over their original root; IO bookkeeping must bind to the guarded facade. */ +function guardedStore(store: IntegrationStateStore, anchor: string): IntegrationStateStore { + const guard = storeGuard(anchor, store.root); + guard(); + return new Proxy(store, { + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + guard(); + if (property === "readSnapshot") assertAsideSnapshotEntry(args[0] as JournalEntry); + // Maintenance in this service is Aside-scoped, including a legacy shared store. + if (property === "retryPendingPrunes") { + if (store.readMaintenance().pruneFailures.aside) { + if (store.pruneSnapshots("aside").ok) store.clearPruneFailure("aside"); + } + return; + } + return Reflect.apply(value, target, args); + }; + }, + }); +} + +export function asideRootStore(input: AsideProfilesInput): IntegrationStateStore { + const raw = input.store ?? createIntegrationStateStore(); + return guardedStore(raw, raw.root); +} + +export function assertAsideSnapshotEntry(entry: JournalEntry): void { + if (!entry || entry.clientId !== "aside" || typeof entry.opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(entry.opId) || !entry.snapshot + || !["none", "stored", "expired"].includes(entry.snapshot.kind) + || (entry.snapshot.kind === "stored" && entry.snapshot.relPath !== join("snapshots", "aside", entry.opId))) { + throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation snapshot metadata is invalid"); + } +} + +export function createAsideProfileContext(input: AsideProfilesInput): AsideProfileContext { + let profiles: AsideProfile[]; + try { profiles = listAsideProfiles(input.env, input.home); } + catch (error) { + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profiles_unavailable", 409, error.message); + throw new AsideProfileError("aside_profiles_unavailable", 409, "Aside profiles cannot be read"); + } + const rootStore = asideRootStore(input); + const record = rootStore.readRecords().aside; + const matched = record?.clientId === "aside" ? profiles.find(profile => profile.configPath === record.configPath) : undefined; + const pinned = input.config.asideProfileSync?.legacyProfileId; + const newest = !record && pinned === undefined ? rootStore.listOperations("aside", 1)[0] : undefined; + const legacyProfileId = pinned !== undefined ? pinned : record + ? matched?.id ?? null + : profiles.find(profile => profile.configPath === newest?.configPath)?.id ?? null; + let loaded: Promise | undefined; + return { + input: { ...input, env: { ...(input.env ?? process.env) } }, profiles, rootStore, legacyProfileId, scopes: new Map(), + defaultEnabled: input.config.asideProfileSync?.allProfiles ?? Boolean(matched), + models: () => loaded ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models), + }; +} + +export function selectAsideProfiles(ctx: AsideProfileContext, profileId?: number): AsideProfile[] { + if (profileId === undefined) return ctx.profiles; + if (!Number.isSafeInteger(profileId) || profileId < 0 || Object.is(profileId, -0)) { + throw new AsideProfileError("invalid_aside_profile", 400, "Aside profile must be a nonnegative safe integer"); + } + const profile = ctx.profiles.find(candidate => candidate.id === profileId); + if (!profile) throw new AsideProfileError("aside_profile_not_found", 404, "Aside profile is not registered"); + return [profile]; +} + +export function asideProfileEnabled(ctx: AsideProfileContext, id: number): boolean { + return ctx.input.config.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled; +} + +export function asideProfileScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { + try { return resolveScope(ctx, profile); } + catch (error) { + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message); + throw error; + } +} + +function resolveScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { + const cached = ctx.scopes.get(profile.id); + if (cached) { cached.assertBoundary(); return cached; } + assertAsideProfileBoundary(profile, ctx.profiles); + const store = profile.id === ctx.legacyProfileId ? ctx.rootStore + : guardedStore(createIntegrationStateStore(join(ctx.rootStore.root, "aside-profiles", String(profile.id))), ctx.rootStore.root); + const assertBoundary = () => { + assertAsideProfileBoundary(profile, ctx.profiles); + const record = store.readRecords().aside; + if (record && (record.clientId !== "aside" || record.configPath !== profile.configPath)) { + throw new AsideProfileError("aside_profile_owner_mismatch", 409, "Aside ownership belongs to a different profile"); + } + }; + assertBoundary(); + const baseIO = ctx.input.io ?? store.io(); + const guarded = guardAsideProfileIO(profile, { + ...baseIO, + appendJournal: entry => store.appendJournal(entry), + putRecord: record => store.putRecord(record), + dropRecord: clientId => store.dropRecord(clientId), + }, ctx.profiles); + const io: IntegrationIO = { + ...guarded, + writeText: (path, text) => { assertBoundary(); guarded.writeText(path, text); }, + removeFile: path => { assertBoundary(); guarded.removeFile(path); }, + mkdirp: path => { assertBoundary(); guarded.mkdirp(path); }, + }; + const scope = { profile, store, io, assertBoundary }; + ctx.scopes.set(profile.id, scope); + return scope; +} + +export async function asideWriteInput(ctx: AsideProfileContext, scope: AsideProfileScope): Promise { + const models = await ctx.models(); + scope.assertBoundary(); + return { + clientId: "aside", config: ctx.input.config, models, port: ctx.input.port, + env: ctx.input.env, home: ctx.input.home, store: scope.store, io: scope.io, + resolvedPaths: { configPath: scope.profile.configPath, detectDir: scope.profile.detectDir }, + }; +} + +/** Save intent first; an unsuccessful save must not leave even in-memory intent changed. */ +export async function persistAsidePolicy(ctx: AsideProfileContext, change?: { enabled: boolean; profileId?: number }): Promise { + const { config, persistConfig } = ctx.input; + if (!persistConfig) throw new AsideProfileError("aside_profile_persistence_required", 500, "Aside profile changes require configuration persistence"); + const previous = config.asideProfileSync; + const policy: AsideProfilePolicy = { + ...previous, allProfiles: ctx.defaultEnabled, legacyProfileId: ctx.legacyProfileId, + profiles: { ...previous?.profiles }, + }; + if (change && change.profileId === undefined) { + policy.allProfiles = change.enabled; + policy.profiles = {}; + } else if (change) policy.profiles![String(change.profileId)] = change.enabled; + config.asideProfileSync = policy; + try { await persistConfig(config); } + catch { + if (previous === undefined) delete config.asideProfileSync; + else config.asideProfileSync = previous; + throw new AsideProfileError("aside_profile_persist_failed", 500, "Aside profile preferences could not be saved; no profile files were changed"); + } + ctx.defaultEnabled = policy.allProfiles!; +} + +export async function runAsideProfileAction( + input: AsideProfilesInput, + profileId: number | undefined, + semantics: string, + action: (ctx: AsideProfileContext, profiles: AsideProfile[]) => Promise, +): Promise { + const ctx = createAsideProfileContext(input); + const profiles = selectAsideProfiles(ctx, profileId); + const key = `aside:${fingerprint(`${ctx.rootStore.root}:${profiles.map(p => p.root).join(",")}`)}:${profiles.map(p => p.id).sort((a, b) => a - b).join(",")}:${semantics}:${crypto.randomUUID()}`; + try { + return await runIntegrationMutationFlight("aside", key, input.io?.now ?? Date.now, async () => { + // Publish the flight before invoking user-supplied persistence callbacks. + await Promise.resolve(); + return action(ctx, profiles); + }); + } catch (error) { + if (error instanceof IntegrationMutationBusyError) { + throw new AsideProfileError("integration_mutation_busy", 409, "An Aside profile operation is already running"); + } + if (error instanceof AsideProfileError) throw error; + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message); + throw new AsideProfileError("aside_profile_operation_failed", 500, "Aside profile operation could not be completed"); + } +} + +export function asideProfileFailure(profileId: number, error: unknown): AsideProfileWriteOutcome { + return { + clientId: "aside", profileId, ok: false, reason: "unsafe", state: "unsafe", + message: error instanceof AsideProfileError || error instanceof ClientPathError + ? error.message : "Aside profile could not be updated safely", + }; +} diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts new file mode 100644 index 0000000000..0fd7f47183 --- /dev/null +++ b/src/integrations/aside-profile-journal.ts @@ -0,0 +1,229 @@ +import { EXPORT_CLIENTS } from "../clients/config-export"; +import { loadTarget, parseConfig } from "./config-io"; +import { matchesOperationResult, type JournalEntry } from "./journal"; +import { fingerprint, type OwnershipRecord } from "./ownership"; +import { classifyIntegration, exportContextOf } from "./state"; +import type { IntegrationStateStore } from "./store"; +import { restoreIntegrationCoordinated, type IntegrationWriteInput } from "./writer"; +import { + AsideProfileError, asideProfileFailure, asideProfileScope, asideRootStore, asideWriteInput, assertAsideSnapshotEntry, + createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, + type AsideProfileContext, type AsideProfilesInput, type AsideProfileScope, type AsideProfileWriteOutcome, +} from "./aside-profile-context"; + +export interface AsideOperation { + profileId: number; + entry: JournalEntry; + store: IntegrationStateStore; +} + +function operationRows(ctx: AsideProfileContext, profileId?: number): AsideOperation[] { + const rows: AsideOperation[] = []; + const entriesByRoot = new Map(); + for (const profile of selectAsideProfiles(ctx, profileId)) { + const scope = asideProfileScope(ctx, profile); + const stores = scope.store.root === ctx.rootStore.root ? [scope.store] : [scope.store, ctx.rootStore]; + for (const store of stores) { + let entries = entriesByRoot.get(store.root); + if (entries === undefined) { + entries = store.listOperations("aside", Number.MAX_SAFE_INTEGER); + entriesByRoot.set(store.root, entries); + } + for (const entry of entries) { + if (entry.clientId === "aside" && entry.configPath === profile.configPath) { + assertAsideSnapshotEntry(entry); + if (typeof entry.at !== "string") throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation timestamp is invalid"); + rows.push({ profileId: profile.id, entry, store }); + } + } + } + } + // A copied entry retains its original timestamp; import time cannot make it newest. + return rows.sort((a, b) => b.entry.at.localeCompare(a.entry.at)); +} + +function uniqueOperations(rows: AsideOperation[]): AsideOperation[] { + const seen = new Map(); + for (const row of rows) { + const previous = seen.get(row.entry.opId); + if (previous && (previous.profileId !== row.profileId || JSON.stringify(previous.entry) !== JSON.stringify(row.entry))) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation identifies multiple profiles"); + } + if (!previous) { + seen.set(row.entry.opId, row); + continue; + } + // Identical journal rows can outlive different snapshot-retention windows. + const previousSnapshot = previous.store.readSnapshot(previous.entry); + const candidateSnapshot = row.store.readSnapshot(row.entry); + if (previousSnapshot.kind === "stored" && candidateSnapshot.kind === "stored" + && previousSnapshot.text !== candidateSnapshot.text) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation has conflicting snapshot copies"); + } + if (previousSnapshot.kind === "expired" && candidateSnapshot.kind === "stored") seen.set(row.entry.opId, row); + } + return [...seen.values()]; +} + +export function listAsideOperations(input: AsideProfilesInput, profileId?: number): AsideOperation[] { + try { return uniqueOperations(operationRows(createAsideProfileContext(input), profileId)); } + catch (error) { + if (profileId === undefined && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return []; + throw error; + } +} + +function findOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation | null { + if (typeof opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(opId)) { + throw new AsideProfileError("invalid_op_id", 400, "Aside operation ID is invalid"); + } + const rows = uniqueOperations(operationRows(ctx, profileId)); + const found = rows.find(row => row.entry.opId === opId); + if (found) return found; + const legacy = ctx.rootStore.findOperation(opId); + if (legacy?.clientId === "aside") { + if (!ctx.profiles.some(profile => profile.configPath === legacy.configPath)) { + throw new AsideProfileError("aside_profile_not_found", 404, "The operation's Aside profile is no longer registered"); + } + if (profileId !== undefined) throw new AsideProfileError("aside_operation_profile_mismatch", 409, "Aside operation belongs to a different profile"); + } + return null; +} + +export function findAsideOperation(input: AsideProfilesInput, opId: string, profileId?: number): AsideOperation | null { + const legacy = asideRootStore(input).findOperation(opId); + if (profileId === undefined && legacy && legacy.clientId !== "aside") return null; + try { return findOperation(createAsideProfileContext(input), opId, profileId); } + catch (error) { + if (profileId === undefined && !legacy && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return null; + throw error; + } +} + +/** Guarded history projection: never expose profile bytes to API serializers. */ +export function asideOperationMatchesCurrent(input: AsideProfilesInput, row: AsideOperation): boolean { + try { + const ctx = createAsideProfileContext(input); + const verified = findOperation(ctx, row.entry.opId, row.profileId); + if (!verified || verified.entry.configPath !== row.entry.configPath) return false; + const profile = selectAsideProfiles(ctx, row.profileId)[0]!; + const scope = asideProfileScope(ctx, profile); + const target = loadTarget(scope.io, profile.configPath); + return target.ok && scope.io.statKind(profile.detectDir) === "dir" && matchesOperationResult(verified.entry, target.before); + } catch { return false; } +} + +function requiredOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation { + const row = findOperation(ctx, opId, profileId); + if (!row) throw new AsideProfileError("integration_operation_not_found", 404, "Aside operation not found"); + return row; +} + +function validatePriorRecord(record: OwnershipRecord | null, configPath: string): void { + if (record === null) return; + if (!record || record.clientId !== "aside" || record.configPath !== configPath + || typeof record.fileFingerprint !== "string" || typeof record.blockFingerprint !== "string" + || typeof record.opId !== "string" || typeof record.appliedAt !== "string" + || !Array.isArray(record.fragmentPaths) || record.fragmentPaths.length !== 1 + || record.fragmentPaths[0]?.length !== 2 || record.fragmentPaths[0][0] !== "providers" + || record.fragmentPaths[0][1] !== "opencodex" + || (record.createdContainers !== undefined && (!Array.isArray(record.createdContainers) + || !record.createdContainers.every(path => path === "providers")))) { + throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation ownership metadata is invalid"); + } +} + +function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: IntegrationWriteInput): boolean { + const record = entry.priorRecord; + if (!record || text === null || record.fileFingerprint !== fingerprint(text)) return false; + const state = classifyIntegration({ + fileText: text, fileIsRegular: true, parsed: parseConfig(text, EXPORT_CLIENTS.aside.format), record, + contribution: EXPORT_CLIENTS.aside.buildContribution(exportContextOf(bound)), + configPath: entry.configPath, clientId: "aside", + }).state; + return state === "current" || state === "stale"; +} + +/** Import only an immutable historical row and its bytes, never the legacy ownership record. */ +function importOperation(row: AsideOperation, scope: AsideProfileScope): void { + if (row.store.root === scope.store.root) return; + const existing = scope.store.findOperation(row.entry.opId); + if (existing && JSON.stringify(existing) !== JSON.stringify(row.entry)) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history"); + } + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind === "expired") throw new AsideProfileError("integration_snapshot_expired", 410, "That backup has expired"); + scope.io.statKind(scope.profile.detectDir); + scope.assertBoundary(); + if (snapshot.kind === "stored") { + const present = scope.store.readSnapshot(row.entry); + if (present.kind === "stored" && present.text !== snapshot.text) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside snapshot conflicts with existing profile history"); + } + if (present.kind !== "stored") scope.store.captureSnapshot("aside", row.entry.opId, snapshot.text); + } + if (!existing) scope.store.appendJournal(structuredClone(row.entry)); +} + +export function restoreAsideProfile( + input: AsideProfilesInput, + request: { opId: string; profileId?: number; confirmDrift?: boolean }, +): Promise { + return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { + const row = requiredOperation(ctx, request.opId, request.profileId); + const profile = selectAsideProfiles(ctx, row.profileId)[0]!; + const scope = asideProfileScope(ctx, profile); + assertAsideSnapshotEntry(row.entry); + validatePriorRecord(row.entry.priorRecord, profile.configPath); + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind === "expired") { + return { clientId: "aside", profileId: profile.id, ok: false, reason: "snapshot_expired", state: "absent", message: "That backup has expired" }; + } + const bound = await asideWriteInput(ctx, scope); + const target = loadTarget(scope.io, profile.configPath); + if (!target.ok || scope.io.statKind(profile.detectDir) !== "dir") { + return asideProfileFailure(profile.id, new AsideProfileError("aside_profile_unsafe", 409, "Aside profile cannot be restored safely")); + } + if (!request.confirmDrift && !matchesOperationResult(row.entry, target.before)) { + return { clientId: "aside", profileId: profile.id, ok: false, reason: "drift_requires_confirm", state: "conflict", message: "This profile changed after that operation; confirm to replace it" }; + } + const restoredText = snapshot.kind === "stored" ? snapshot.text : null; + await persistAsidePolicy(ctx, { profileId: profile.id, enabled: snapshotWasOwned(row.entry, restoredText, bound) }); + try { + scope.assertBoundary(); + const currentSnapshot = row.store.readSnapshot(row.entry); + if (JSON.stringify(row.store.findOperation(row.entry.opId)) !== JSON.stringify(row.entry) + || currentSnapshot.kind !== snapshot.kind + || (currentSnapshot.kind === "stored" && currentSnapshot.text !== restoredText)) { + throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences"); + } + importOperation(row, scope); + return { ...await restoreIntegrationCoordinated({ ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, { lockSeams: input.lockSeams }), profileId: profile.id }; + } catch (error) { return asideProfileFailure(profile.id, error); } + }); +} + +export function deleteAsideOperation( + input: AsideProfilesInput, + request: { opId: string; profileId?: number; principal?: string }, +): Promise<{ ok: true; clientId: "aside"; profileId: number; opId: string; snapshotRemoved: boolean }> { + return runAsideProfileAction(input, request.profileId, `delete:${request.opId}`, async ctx => { + const row = requiredOperation(ctx, request.opId, request.profileId); + const rows = operationRows(ctx, row.profileId); + if (rows[0]?.entry.opId === request.opId) { + throw new AsideProfileError("integration_journal_newest_protected", 409, "The newest operation for an Aside profile cannot be deleted"); + } + await persistAsidePolicy(ctx); + const stores = new Map(rows.filter(candidate => candidate.entry.opId === request.opId).map(candidate => [candidate.store.root, candidate.store])); + const tombstone = { tombstone: request.opId, at: new Date(input.io?.now() ?? Date.now()).toISOString(), by: request.principal ?? "management" }; + // Retire every copy before pruning any snapshot; deduped history must not resurrect a source row. + for (const store of stores.values()) store.retireOperation(tombstone); + let snapshotRemoved = true; + for (const store of stores.values()) { + const pruned = store.pruneSnapshots("aside"); + if (pruned.ok) store.clearPruneFailure("aside"); + else { snapshotRemoved = false; store.markPruneFailure("aside", pruned.error); } + } + return { ok: true, clientId: "aside", profileId: row.profileId, opId: request.opId, snapshotRemoved }; + }); +} diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts new file mode 100644 index 0000000000..94514f253a --- /dev/null +++ b/src/integrations/aside-profiles.ts @@ -0,0 +1,176 @@ +import type { AsideProfile } from "../clients/aside-profiles"; +import { asideHomeDir } from "../clients/config-export"; +import { join } from "node:path"; +import type { OwnedIntegrationRefreshOutcome } from "./owned-refresh"; +import { readIntegrationState, type IntegrationState, type IntegrationStatus } from "./state"; +import { + applyIntegrationCoordinated, disableIntegrationCoordinated, + overwriteIntegrationCoordinated, refreshIntegrationCoordinated, +} from "./writer"; +import { + asideProfileEnabled, asideProfileFailure, asideProfileScope, asideWriteInput, + asideRootStore, createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, + type AsideProfileContext, type AsideProfilesInput, type AsideProfileWriteOutcome, +} from "./aside-profile-context"; + +export { AsideProfileError } from "./aside-profile-context"; +export type { AsideProfilesInput, AsideProfileWriteOutcome } from "./aside-profile-context"; + +export interface AsideProfileState extends IntegrationStatus { + profileId: number; + name?: string; + current: boolean; + enabled: boolean; + error?: string; +} + +export interface AsideProfileList extends IntegrationStatus { + profiles: AsideProfileState[]; + allEnabled: boolean; + enabledCount: number; + appliedCount: number; + total: number; + error?: string; +} + +export interface AsideProfileMutationResult { + ok: boolean; + clientId: "aside"; + changed: boolean; + state: IntegrationState; + message: string; + results: AsideProfileWriteOutcome[]; + /** Preserve the ordinary refusal serializer for a single selected profile. */ + result?: AsideProfileWriteOutcome; +} + +function aggregateState(states: readonly IntegrationState[]): IntegrationState { + if (states.includes("unsafe")) return "unsafe"; + if (states.includes("conflict")) return "conflict"; + if (states.every(state => state === "absent")) return "absent"; + return states.every(state => state === "current") ? "current" : "stale"; +} + +async function profileState(ctx: AsideProfileContext, profile: AsideProfile): Promise { + const metadata = { + profileId: profile.id, ...(profile.name !== undefined ? { name: profile.name } : {}), + current: profile.current, enabled: asideProfileEnabled(ctx, profile.id), + }; + try { + const scope = asideProfileScope(ctx, profile); + const input = await asideWriteInput(ctx, scope); + return { ...readIntegrationState(input), ...metadata }; + } catch (error) { + return { + clientId: "aside", ...metadata, state: "unsafe", installed: false, + configPath: profile.configPath, reason: "unresolvable-path", snapshotCount: -1, + retentionDegraded: true, error: asideProfileFailure(profile.id, error).message, + }; + } +} + +export async function listAsideProfileStates(input: AsideProfilesInput): Promise { + let ctx: AsideProfileContext; + try { ctx = createAsideProfileContext(input); } + catch (error) { + return { + clientId: "aside", profiles: [], total: 0, enabledCount: 0, appliedCount: 0, allEnabled: false, + state: "unsafe", installed: false, configPath: join(asideHomeDir(input.env, input.home), "u"), + snapshotCount: -1, retentionDegraded: true, reason: "unresolvable-path", + error: asideProfileFailure(0, error).message, + }; + } + const profiles: AsideProfileState[] = []; + for (const profile of ctx.profiles) profiles.push(await profileState(ctx, profile)); + const enabledCount = profiles.filter(profile => profile.enabled).length; + const snapshotCount = profiles.some(profile => profile.snapshotCount < 0) ? -1 + : profiles.reduce((sum, profile) => sum + profile.snapshotCount, 0); + return { + clientId: "aside", profiles, total: profiles.length, enabledCount, + allEnabled: profiles.length > 0 && enabledCount === profiles.length, + appliedCount: profiles.filter(profile => profile.state === "current" || profile.state === "stale").length, + state: aggregateState(profiles.map(profile => profile.state)), + installed: profiles.some(profile => profile.installed), + configPath: profiles.find(profile => profile.current)?.configPath ?? profiles[0]?.configPath ?? "", + snapshotCount, retentionDegraded: profiles.some(profile => profile.retentionDegraded), + }; +} + +export async function getAsideProfileState(input: AsideProfilesInput, id: number): Promise { + const ctx = createAsideProfileContext(input); + return profileState(ctx, selectAsideProfiles(ctx, id)[0]!); +} + +export function mutateAsideProfiles( + input: AsideProfilesInput, + change: { enabled: boolean; profileId?: number; overwriteConflict?: boolean }, +): Promise { + return runAsideProfileAction(input, change.profileId, `${change.enabled ? "enable" : "disable"}:${Boolean(change.overwriteConflict)}`, async (ctx, profiles) => { + const refused = new Map(); + for (const profile of profiles) { + try { asideProfileScope(ctx, profile); } + catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } + } + // This await precedes model loading, writer preflight, snapshots and all client writes. + await persistAsidePolicy(ctx, change); + const results: AsideProfileWriteOutcome[] = []; + for (const profile of profiles) { + const refusal = refused.get(profile.id); + if (refusal) { results.push(refusal); continue; } + try { + const scope = asideProfileScope(ctx, profile); + const bound = await asideWriteInput(ctx, scope); + const operation = !change.enabled ? disableIntegrationCoordinated + : change.overwriteConflict ? overwriteIntegrationCoordinated : applyIntegrationCoordinated; + results.push({ ...await operation(bound, { lockSeams: input.lockSeams }), profileId: profile.id }); + } catch (error) { results.push(asideProfileFailure(profile.id, error)); } + } + const ok = results.every(result => result.ok); + return { + ok, clientId: "aside", changed: results.some(result => result.ok && result.changed), + state: aggregateState(results.map(result => result.state)), + message: ok ? "Aside profile preferences applied" : "Aside preferences saved; some profiles could not be updated", + results, ...(results.length === 1 ? { result: results[0] } : {}), + }; + }); +} + +export function refreshAsideProfiles(input: AsideProfilesInput): Promise> { + const policy = input.config.asideProfileSync; + const selected = Object.values(policy?.profiles ?? {}).some(enabled => enabled === true); + if (!selected && (policy?.allProfiles === false + || (policy?.allProfiles !== true && !asideRootStore(input).readRecords().aside))) return Promise.resolve([]); + return runAsideProfileAction(input, undefined, "refresh", async (ctx, profiles) => { + const outcomes: Array = []; + for (const profile of profiles) { + if (!asideProfileEnabled(ctx, profile.id)) continue; + try { + const scope = asideProfileScope(ctx, profile); + const owned = scope.store.readRecords().aside !== undefined; + const bound = await asideWriteInput(ctx, scope); + // A surviving ownership record means a removed block stays removed. + // A newly discovered, enabled profile may receive its first safe apply. + const operation = owned ? refreshIntegrationCoordinated : applyIntegrationCoordinated; + const result = await operation(bound, { lockSeams: input.lockSeams }); + outcomes.push({ + client: "aside", profileId: profile.id, ok: result.ok, + ...(result.ok ? { changed: result.changed } : {}), + ...(!result.ok || result.state === "absent" ? { reason: result.message } : {}), + ...(!result.ok ? { + refusalReason: result.reason, state: result.state, + ...(result.snapshotPath ? { snapshotPath: result.snapshotPath } : {}), + ...(result.residual ? { residual: true } : {}), + } : {}), + }); + } catch (error) { + const failure = asideProfileFailure(profile.id, error); + outcomes.push({ client: "aside", profileId: profile.id, ok: false, reason: failure.message, + ...(!failure.ok ? { refusalReason: failure.reason, state: failure.state, + ...(failure.snapshotPath ? { snapshotPath: failure.snapshotPath } : {}), + ...(failure.residual ? { residual: true } : {}) } : {}), + }); + } + } + return outcomes; + }); +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts new file mode 100644 index 0000000000..8efb990002 --- /dev/null +++ b/src/integrations/catalog-refresh.ts @@ -0,0 +1,40 @@ +import { redactSecretString } from "../lib/redact"; +import type { ExportModel } from "../clients/config-export"; +import type { IntegrationClientId } from "./registry"; +import { + refreshOwnedIntegration, + type OwnedIntegrationRefreshInput, + type OwnedIntegrationRefreshOutcome, +} from "./owned-refresh"; + +/** Refresh only previously connected clients; a refused file never blocks its peers. */ +export async function refreshOwnedCatalogIntegrations( + input: Omit, + clientIds: readonly IntegrationClientId[] = ["pi", "aside"], +): Promise { + let models: Promise | undefined; + const loadModels = () => models ??= Promise.resolve().then(() => + typeof input.models === "function" ? input.models() : input.models); + const outcomes: OwnedIntegrationRefreshOutcome[] = []; + for (const clientId of clientIds) { + try { + if (clientId === "aside") { + const { refreshAsideProfiles } = await import("./aside-profiles"); + outcomes.push(...await refreshAsideProfiles({ ...input, models: loadModels })); + continue; + } + const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); + if (result) outcomes.push(result); + } catch (error) { + const busy = error !== null && typeof error === "object" + && "code" in error && error.code === "integration_mutation_busy"; + outcomes.push({ + client: clientId, + ok: false, + reason: busy ? "integration_mutation_busy" + : redactSecretString(error instanceof Error ? error.message : String(error)), + }); + } + } + return outcomes; +} diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts index 4f2a834824..9cb5f97baa 100644 --- a/src/integrations/config-io.ts +++ b/src/integrations/config-io.ts @@ -162,7 +162,22 @@ export function parseConfig(text: string | null, format: ConfigFormat): unknown * evidence is gone. */ if (/(^|[\s,[=])[-+]?(?:inf|nan)(?=[\s,\]]|$)/mi.test(text)) return PARSE_FAILED; - return Bun.TOML.parse(text); + const document = Bun.TOML.parse(text); + // TOML date/time scalars are Temporal objects with toJSON methods. + // The merge layer JSON-clones documents, which silently turns these + // into strings. Refuse before either status or a writer can admit a + // lossy rewrite, including dates nested in arrays and inline tables. + const pending: unknown[] = [document]; + while (pending.length > 0) { + const value = pending.pop(); + if (value === null || typeof value !== "object") continue; + if (!Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return PARSE_FAILED; + } + for (const child of Object.values(value)) pending.push(child); + } + return document; } } } catch { diff --git a/src/integrations/owned-refresh.ts b/src/integrations/owned-refresh.ts index 9af6ad5e3c..37621412e6 100644 --- a/src/integrations/owned-refresh.ts +++ b/src/integrations/owned-refresh.ts @@ -27,6 +27,8 @@ export interface OwnedIntegrationRefreshInput { home?: string; store?: IntegrationStateStore; io?: IntegrationIO; + /** Internal profile target selected before entering the coordinated writer. */ + resolvedPaths?: { configPath: string; detectDir: string }; } export interface OwnedIntegrationRefreshOutcome { @@ -34,6 +36,11 @@ export interface OwnedIntegrationRefreshOutcome { readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; + readonly profileId?: number; + readonly refusalReason?: string; + readonly state?: string; + readonly snapshotPath?: string; + readonly residual?: boolean; } /** @@ -59,7 +66,9 @@ export async function refreshOwnedIntegration( const bound = { ...rest, models, store }; const result = await runIntegrationMutationFlight( input.clientId, - "refresh", + // Separate catalog snapshots must not inherit another refresh's success. + // The shared flight owner returns busy for overlapping operations instead. + `refresh:${crypto.randomUUID()}`, input.io?.now ?? Date.now, () => refreshIntegrationCoordinated(bound, options), ); @@ -70,5 +79,8 @@ export async function refreshOwnedIntegration( changed: result.changed, ...(result.state === "absent" ? { reason: result.message } : {}), } - : { client: input.clientId, ok: false, reason: result.message }; + : { client: input.clientId, ok: false, reason: result.message, refusalReason: result.reason, state: result.state, + ...(result.snapshotPath ? { snapshotPath: result.snapshotPath } : {}), + ...(result.residual ? { residual: true } : {}), + }; } diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 71dd93a71d..008f46fbf1 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -352,6 +352,8 @@ export interface IntegrationStateInput { /** The whole integration state store, bound to one root. */ store?: IntegrationStateStore; io?: IntegrationIO; + /** Internal explicit profile target; never accepted as a caller-provided path. */ + resolvedPaths?: { configPath: string; detectDir: string }; } export function exportContextOf(input: { @@ -431,7 +433,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS try { // One resolution for both, so a client whose paths come from mutable state // cannot report one account's install beside another account's config path. - const paths = resolveIntegrationPaths(input.clientId, input.env, input.home); + const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home); configPath = paths.configPath; installed = io.statKind(paths.detectDir) === "dir"; } catch (error) { diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 4aa0944c80..23b3eaaad4 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -711,7 +711,9 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration * its manifest, so two independent calls could verify one account's install * and then write another account's catalog if a switch landed between them. */ - const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home); + const resolvedPaths = input.resolvedPaths + ? { ...input.resolvedPaths } + : resolveIntegrationPaths(input.clientId, env, home); return { ...input, env, home, store, io, resolvedPaths }; } diff --git a/src/lab/fabric/producer-isolate.ts b/src/lab/fabric/producer-isolate.ts index 3ab672b9bd..70291adffc 100644 --- a/src/lab/fabric/producer-isolate.ts +++ b/src/lab/fabric/producer-isolate.ts @@ -84,6 +84,11 @@ function killChild(child: ChildProcess): void { export async function runIsolatedFabricProducer(request: IsolateRequest): Promise { const now = request.now ?? (() => Date.now()); let lastActivityAt = now(); + // Budget enforcement must not follow wall-clock adjustments; telemetry still does. + const budgetNow = request.now ?? (() => performance.now()); + const startedAt = request.now ? lastActivityAt : budgetNow(); + const totalDeadline = startedAt + request.totalTimeoutMs; + let inactivityDeadline = startedAt + request.inactivityTimeoutMs; return await new Promise((resolve, reject) => { let child: ChildProcess; @@ -104,48 +109,74 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis let stdoutBuffer = ""; let stderrBytes = 0; let settled = false; + let childClosed = false; let receivedResult: SyntheticPatchV1 | undefined; let killReason: FabricTaskError | undefined; const finish = (fn: () => void) => { - if (settled) return; + // A latched failure owns settlement, but scratch cleanup must wait for close. + if (settled || (killReason && !childClosed)) return; settled = true; clearTimeout(totalTimer); clearTimeout(inactivityTimer); - fn(); + if (killReason) reject(killReason); + else fn(); }; const settleTimeout = (error: FabricTaskError) => { - if (settled) return; + if (settled || killReason) return; killReason = error; - killChild(child); + if (childClosed) finish(() => reject(error)); + else killChild(child); + }; + + const expiredDeadline = (at: number): FabricTaskError | undefined => { + // Choose the earliest deadline, regardless of which timer/data callback ran first. + if (at >= inactivityDeadline && inactivityDeadline <= totalDeadline) { + return new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment"); + } + if (at >= totalDeadline) { + return new FabricTaskError("total timeout exceeded", "timeout", "environment"); + } + return undefined; + }; + + const onInactivityTimeout = () => { + settleTimeout(expiredDeadline(budgetNow()) + ?? new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); }; const armInactivity = () => { clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - settleTimeout(new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); - }, request.inactivityTimeoutMs); + inactivityTimer = setTimeout(onInactivityTimeout, request.inactivityTimeoutMs); }; - let inactivityTimer: ReturnType = setTimeout(() => { - settleTimeout(new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); - }, request.inactivityTimeoutMs); + let inactivityTimer: ReturnType = setTimeout(onInactivityTimeout, request.inactivityTimeoutMs); const totalTimer = setTimeout(() => { - settleTimeout(new FabricTaskError("total timeout exceeded", "timeout", "environment")); + settleTimeout(expiredDeadline(budgetNow()) + ?? new FabricTaskError("total timeout exceeded", "timeout", "environment")); }, request.totalTimeoutMs); const handleProtocolLine = (line: string) => { + if (settled || killReason) return; try { const message = parseProducerProtocolLine(line); - if (message.type === "activity") { - lastActivityAt = now(); - armInactivity(); - return; + if (message.type === "activity" || message.type === "result") { + const at = budgetNow(); + const expired = expiredDeadline(at); + if (expired) { + settleTimeout(expired); + return; + } + if (message.type === "activity") { + lastActivityAt = request.now ? at : now(); + inactivityDeadline = at + request.inactivityTimeoutMs; + armInactivity(); + return; + } } if (message.type === "result") { - if (settled) return; receivedResult = message.patch; finish(() => resolve({ patch: message.patch, lastActivityAt })); return; @@ -176,6 +207,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis }; const consumeStdout = (chunk: string) => { + if (settled || killReason) return; stdoutBuffer += chunk; if (Buffer.byteLength(stdoutBuffer, "utf8") > FABRIC_PRODUCER_PROTOCOL_MAX_BYTES) { settleTimeout(new FabricTaskError("producer protocol output exceeded limit", "budget_exhausted", "environment")); @@ -207,16 +239,50 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis } }); + child.stderr?.on("error", (error) => { + settleTimeout(new FabricTaskError(error.message, "harness_failure", "harness")); + }); + child.on("error", (error) => { finish(() => reject(new FabricTaskError(error.message, "harness_failure", "harness"))); }); child.stdin?.on("error", (error: NodeJS.ErrnoException) => { - if (settled || error.code === "EPIPE") return; + if (settled || killReason || error.code === "EPIPE") return; killChild(child); finish(() => reject(new FabricTaskError(error.message, "harness_failure", "harness"))); }); + child.on("close", (code, signal) => { + childClosed = true; + if (settled) return; + if (killReason) { + finish(() => reject(killReason!)); + return; + } + if (receivedResult) { + finish(() => resolve({ patch: receivedResult!, lastActivityAt })); + return; + } + if (stdoutBuffer.trim()) { + try { + handleProtocolLine(stdoutBuffer.trim()); + if (settled) return; + } catch { + /* fall through */ + } + } + if (signal === "SIGKILL") { + finish(() => reject(new FabricTaskError("total timeout exceeded", "timeout", "environment"))); + return; + } + finish(() => reject(new FabricTaskError( + code === 0 ? "isolated producer returned no result" : `isolated producer exited (${code ?? signal ?? "unknown"})`, + "harness_failure", + "harness", + ))); + }); + const payload = JSON.stringify({ harnessKind: request.harnessKind, executorModulePath: request.executorModulePath, @@ -242,6 +308,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis child.stdin?.write(payload); child.stdin?.end(); } catch (error) { + if (killReason) return; killChild(child); finish(() => reject(new FabricTaskError( error instanceof Error ? error.message : String(error), @@ -250,35 +317,6 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis ))); return; } - - child.on("close", (code, signal) => { - if (settled) return; - if (killReason) { - finish(() => reject(killReason!)); - return; - } - if (receivedResult) { - finish(() => resolve({ patch: receivedResult!, lastActivityAt })); - return; - } - if (stdoutBuffer.trim()) { - try { - handleProtocolLine(stdoutBuffer.trim()); - if (receivedResult) return; - } catch { - /* fall through */ - } - } - if (signal === "SIGKILL") { - finish(() => reject(new FabricTaskError("total timeout exceeded", "timeout", "environment"))); - return; - } - finish(() => reject(new FabricTaskError( - code === 0 ? "isolated producer returned no result" : `isolated producer exited (${code ?? signal ?? "unknown"})`, - "harness_failure", - "harness", - ))); - }); }); } diff --git a/src/lib/account-selection-events.ts b/src/lib/account-selection-events.ts new file mode 100644 index 0000000000..655dd9aa99 --- /dev/null +++ b/src/lib/account-selection-events.ts @@ -0,0 +1,32 @@ +/** Process-local invalidations. Connection limits, buffering, and timers belong to consumers. */ +export type AccountSelectionEvent = { + provider: string; + kind: "oauth" | "api-key"; + revision: number; +}; + +const listeners = new Set<(event: AccountSelectionEvent) => void>(); +let revision = 0; + +/** Call only after the authoritative selection has been persisted. */ +export function publishAccountSelection(provider: string, kind: AccountSelectionEvent["kind"]): void { + const event: AccountSelectionEvent = Object.freeze({ provider, kind, revision: ++revision }); + for (const listener of [...listeners]) { + try { + listener(event); + } catch { + // A disconnected consumer must not turn a committed write into a reported failure. + } + } +} + +export function subscribeAccountSelections(listener: (event: AccountSelectionEvent) => void): () => void { + // Give each subscription its own lifetime, even when a callback is reused. + const subscription = (event: AccountSelectionEvent) => listener(event); + listeners.add(subscription); + return () => { listeners.delete(subscription); }; +} + +export function currentAccountSelectionRevision(): number { + return revision; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 03d7a561c6..b006b8ed8c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -14,10 +14,15 @@ export class OcxRequestValidationError extends Error { } } +export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION = + "Encrypted function output content could not be decrypted or decoded."; + /** Canonical human-readable message paths used by Responses upstream failures. */ export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; const json = payload as { + type?: unknown; + message?: unknown; error?: { message?: unknown }; last_error?: { message?: unknown }; response?: { @@ -28,7 +33,10 @@ export function upstreamErrorMessageFromPayload(payload: unknown): string | unde const message = json.error?.message ?? json.last_error?.message ?? json.response?.error?.message - ?? json.response?.incomplete_details?.message; + ?? json.response?.incomplete_details?.message + // The Responses stream error event carries a flat message (type/code/message), + // unlike the response.failed envelope the branches above already cover. + ?? (json.type === "error" ? json.message : undefined); return typeof message === "string" ? message : undefined; } diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 286a574f25..14113c7efd 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { effectiveProxyFor, noProxyMatches, proxyForUrl } from "./proxy-env"; +import { effectiveProxyFor, noProxyMatches, outboundProxyConfigured, proxyForUrl } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; import { antigravityOAuthDestinationConfigError, isCanonicalAntigravityUrl, providerTlsFetch } from "./provider-tls-profile"; import { waitForProviderRequestSlot } from "../providers/request-pacing"; @@ -231,6 +231,7 @@ async function providerOutboundRequest( url, ); } + const proxyConfigured = outboundProxyConfigured(); // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport // below reason about the same value. `null` here means "no proxy fetch would actually use", // even if some other proxy variable is set. diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index d1fb263bab..b4cb76c11f 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -3,18 +3,16 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const; export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number]; export type ProxyEnvMap = Record; +export type ProxyRoute = + | { kind: "direct" } + | { kind: "proxy"; proxy: string } + | { kind: "fallback" }; -export function proxyEnvPresent( - key: ProxyEnvKey, - env: ProxyEnvMap = process.env, -): boolean { - return Boolean(env[key]?.trim() || env[key.toLowerCase()]?.trim()); -} - -export function outboundProxyConfigured( - env: ProxyEnvMap = process.env, -): boolean { - return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); +export function normalizeProxyHostname(hostname: string): string { + const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); + return normalized.startsWith("[") && normalized.endsWith("]") + ? normalized.slice(1, -1) + : normalized; } function proxyValue(key: ProxyEnvKey, env: ProxyEnvMap): string | undefined { @@ -22,15 +20,19 @@ function proxyValue(key: ProxyEnvKey, env: ProxyEnvMap): string | undefined { return value || undefined; } -export function noProxyMatches(url: URL, env: ProxyEnvMap = process.env): boolean { - const raw = (env.NO_PROXY ?? env.no_proxy ?? "").trim(); - const hostname = url.hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, ""); - const port = url.port || (url.protocol === "https:" ? "443" : "80"); - for (const value of raw.split(",")) { - let entry = value.trim().toLowerCase(); +export function noProxyMatches( + url: URL, + env: ProxyEnvMap = process.env, +): boolean { + const raw = env.NO_PROXY ?? env.no_proxy ?? ""; + const hostname = normalizeProxyHostname(url.hostname); + const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80"); + for (const rawEntry of raw.split(",")) { + let entry = rawEntry.trim().toLowerCase(); if (!entry) continue; if (entry === "*") return true; - entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!; + entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!; + let entryHost = entry; let entryPort = ""; const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); @@ -46,7 +48,7 @@ export function noProxyMatches(url: URL, env: ProxyEnvMap = process.env): boolea } } if (entryPort && entryPort !== port) continue; - entryHost = entryHost.replace(/^\*?\./, "").replace(/^\[|\]$/g, "").replace(/\.+$/, ""); + entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true; } return false; @@ -66,6 +68,41 @@ export function proxyForUrl(url: string | URL, env: ProxyEnvMap = process.env): return undefined; } +export function resolveProxyRoute( + url: URL, + env: ProxyEnvMap = process.env, +): ProxyRoute { + if (noProxyMatches(url, env)) return { kind: "direct" }; + const key = url.protocol === "https:" || url.protocol === "wss:" + ? "HTTPS_PROXY" + : "HTTP_PROXY"; + const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"] + .map(candidate => env[candidate]?.trim()) + .find(Boolean); + if (!proxy) return { kind: "direct" }; + try { + const protocol = new URL(proxy).protocol; + return protocol === "http:" || protocol === "https:" + ? { kind: "proxy", proxy } + : { kind: "fallback" }; + } catch { + return { kind: "fallback" }; + } +} + +export function proxyEnvPresent( + key: ProxyEnvKey, + env: ProxyEnvMap = process.env, +): boolean { + return Boolean(env[key]?.trim() || env[key.toLowerCase()]?.trim()); +} + +export function outboundProxyConfigured( + env: ProxyEnvMap = process.env, +): boolean { + return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); +} + /** * The proxy URL that Bun's fetch will actually use for `url`, or null when none applies. * diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index dbf1f06b79..dc8bb5749f 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -715,18 +715,22 @@ function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoExc return error; } -function previousTimeoutError(retryConsumed: boolean): NodeJS.ErrnoException { +type TimeoutMemoRefusalError = NodeJS.ErrnoException & { + aclFailureOrigin: "timeout_memo_refusal"; +}; + +function previousTimeoutError(retryConsumed: boolean): TimeoutMemoRefusalError { if (retryConsumed) { const error = new Error( "ACL hardening skipped — the previous timeout recovery was already consumed", ) as NodeJS.ErrnoException; error.code = "EACLRETRYEXHAUSTED"; - return error; + return Object.assign(error, { aclFailureOrigin: "timeout_memo_refusal" as const }); } - return sanitizedAclError( + return Object.assign(sanitizedAclError( "ACL hardening skipped — previous attempt timed out", Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), - ); + ), { aclFailureOrigin: "timeout_memo_refusal" as const }); } /** Consume, but never reset, the single explicit recovery attempt for this key. */ diff --git a/src/oauth/account-quota-rank.ts b/src/oauth/account-quota-rank.ts index 36360c655b..978f73de9d 100644 --- a/src/oauth/account-quota-rank.ts +++ b/src/oauth/account-quota-rank.ts @@ -67,6 +67,14 @@ function headroomOf(provider: string, accountId: string): number | null { return 100 - Math.max(...percents); } +/** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */ +export function isAccountQuotaExhausted(provider: string, accountId: string): boolean { + const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null; + if (exhaustion !== null) return exhaustion.exhausted; + const headroom = headroomOf(provider, accountId); + return headroom !== null && headroom <= 0; +} + /** * Order candidates best-first. * @@ -90,7 +98,7 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[] const headroom = headroomOf(provider, id); if (exhaustion !== null || headroom !== null) sawEvidence = true; - if (exhaustion?.exhausted === true) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index }; + if (isAccountQuotaExhausted(provider, id)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index }; if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index }; return { id, bucket: RANK_HEALTHY, headroom, index }; }); diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 298c602c2a..a029207be5 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -10,49 +10,41 @@ * Intentionally narrower than the Codex pool: no mid-session quota rotation, * soft-avoid ladders, or probe leases. Anthropic OAuth is ToS-sensitive. * - * Affinity and cooldown delegate to `src/routing/account-pool/` (process-local). - * 401/403 credential failures should set needsReauth on the store (existing OAuth path) - * so the account is excluded from eligibility. + * Affinity is process-local (lost on restart). Cooldown uses Retry-After when present, + * otherwise a default backoff. 401/403 credential failures should set needsReauth on the + * store (existing OAuth path) so the account is excluded from eligibility. */ -import { setActiveAccount, getAccountSet, getAccountCredential } from "./store"; +import { createHash } from "node:crypto"; +import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store"; +import type { OAuthAccessSnapshot } from "./index"; import { getCachedProviderAccountQuota } from "../providers/quota"; import { fallbackCodexAccountLogLabel } from "../codex/account-label"; import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, notePoolRotationFailure, + notePoolRotationSuccess, pickRoundRobinAccount, + peekRoundRobinAccount, POOL_KEY_ANTHROPIC, seedPoolRotationAccount, } from "../codex/pool-rotation"; import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types"; -import { - ACCOUNT_POOL_MAX_FAILOVERS, - affinitySizeForTests, - bindSessionAffinity, - buildSessionKeyFromParts, - clearAccountPoolState, - clearAffinityState, - clearResolveState, - clearSessionAffinityForAccount, - getPoolCooldownRegistry, - getSessionAffinity, - isAccountPoolEligible, - isRateLimitStickWait, - normalizeAffinityComponent, - recordPoolAccountCooldown, - resolvePoolAccount, - touchSessionAffinity, - type AccountPoolPlugin, -} from "../routing/account-pool"; +import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +import { retainedUtf8Bytes } from "../lib/admission"; const PROVIDER = "anthropic"; +const DEFAULT_COOLDOWN_MS = 60_000; +const MAX_COOLDOWN_MS = 15 * 60_000; +const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; +const MAX_AFFINITY_ENTRIES = 2_000; +const MAX_AFFINITY_COMPONENT_BYTES = 512; const UNKNOWN_USAGE_SCORE = 100; const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; const DEFAULT_QUOTA_WINDOW: OcxAccountPoolQuotaWindow = "five-hour"; const VALID_QUOTA_WINDOWS = new Set(["five-hour", "weekly", "max-utilization"]); /** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */ -export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = ACCOUNT_POOL_MAX_FAILOVERS; +export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3; export interface AnthropicAccountPoolConfig { enabled?: boolean; @@ -62,28 +54,31 @@ export interface AnthropicAccountPoolConfig { strategy?: OcxAccountPoolRotationStrategy; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ stickyLimit?: number; - /** Usage window for quota-based scoring. Default "five-hour". */ + /** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */ quotaWindow?: OcxAccountPoolQuotaWindow; } -const anthropicPoolPlugin: AccountPoolPlugin = { - poolKey: POOL_KEY_ANTHROPIC, - sessionKeyFromRequest: buildSessionKeyFromParts, - listEligibleAccountIds(now) { - const set = getAccountSet(PROVIDER); - if (!set) return []; - return set.accounts - .filter(account => - account.needsReauth !== true - && isPoolCredentialUsable(account.id, now)) - .map(account => account.id); - }, - usageScore(accountId) { - return fiveHourScore(accountId); - }, -}; +interface AccountHealth { + cooldownUntil: number; + cooldownSource: "retry-after" | "default"; +} -const TOKEN_SKEW_MS = 60_000; +interface AffinityEntry { + accountId: string; + lastUsedAt: number; +} + +const upstreamHealth = new Map(); +const sessionAffinity = new Map(); +type OAuthAccountSelection = NonNullable>; +// Undefined means this runtime has not admitted a selection yet; null means consumed. +// The startup baseline comes from the authoritative store, never a second persisted pin. +let manualPreference: OAuthAccountSelection | null | undefined; + +function normalizeAffinityComponent(value: string | null | undefined): string { + const normalized = value?.trim() ?? ""; + return normalized && retainedUtf8Bytes(normalized) <= MAX_AFFINITY_COMPONENT_BYTES ? normalized : ""; +} export function anthropicAccountPoolConfig(config: OcxConfig): AnthropicAccountPoolConfig { const raw = config.anthropicAccountPool; @@ -117,35 +112,62 @@ export function anthropicQuotaWindow(config: AnthropicAccountPoolConfig): OcxAcc return normalizeAccountPoolQuotaWindow(config.quotaWindow); } +function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + if (Number.isFinite(seconds) && seconds > 0) { + return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS); + } + } + const timestamp = Date.parse(text); + if (!Number.isFinite(timestamp)) return undefined; + const delay = timestamp - now; + return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined; +} + export function getAnthropicAccountHealthSnapshot( accountId: string, now = Date.now(), -): { cooldownUntil?: number; cooldownSource?: "retry-after" | "default" } | null { - const entry = getPoolCooldownRegistry(POOL_KEY_ANTHROPIC).get(accountId, now); +): { cooldownUntil?: number; cooldownSource?: AccountHealth["cooldownSource"] } | null { + const entry = upstreamHealth.get(accountId); if (!entry) return null; - const source = entry.source === "retry-after" ? "retry-after" : "default"; - return { cooldownUntil: entry.until, cooldownSource: source }; + if (entry.cooldownUntil <= now) { + upstreamHealth.delete(accountId); + return null; + } + return { cooldownUntil: entry.cooldownUntil, cooldownSource: entry.cooldownSource }; } export function clearAnthropicAccountCooldown(accountId: string): boolean { - const registry = getPoolCooldownRegistry(POOL_KEY_ANTHROPIC); - const had = registry.get(accountId) !== null; - registry.clear(accountId); - return had; + return upstreamHealth.delete(accountId); } export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number { - return getPoolCooldownRegistry(POOL_KEY_ANTHROPIC).sweep(now); + let removed = 0; + for (const [accountId, health] of upstreamHealth) { + if (health.cooldownUntil > now) continue; + upstreamHealth.delete(accountId); + removed += 1; + } + return removed; } /** Test / logout helper. */ export function clearAnthropicAccountPoolState(): void { - clearAccountPoolState(POOL_KEY_ANTHROPIC); + upstreamHealth.clear(); + sessionAffinity.clear(); + manualPreference = undefined; quorumCache = null; } export function anthropicSessionAffinitySizeForTests(): number { - return affinitySizeForTests(POOL_KEY_ANTHROPIC); + return sessionAffinity.size; +} + +function isCooled(accountId: string, now: number): boolean { + return getAnthropicAccountHealthSnapshot(accountId, now) !== null; } function fiveHourKnown(accountId: string): boolean { @@ -177,15 +199,17 @@ function exhausted5h(accountId: string): boolean { } function hasKnownUsage(config: OcxConfig, accountId: string): boolean { - switch (anthropicQuotaWindow(anthropicAccountPoolConfig(config))) { + const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); + switch (window) { case "five-hour": return fiveHourKnown(accountId); case "weekly": return weeklyKnown(accountId); case "max-utilization": return fiveHourKnown(accountId) || weeklyKnown(accountId); } } -function anthropicUsageScore(config: OcxConfig, accountId: string): number { - switch (anthropicQuotaWindow(anthropicAccountPoolConfig(config))) { +function usageScore(config: OcxConfig, accountId: string): number { + const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); + switch (window) { case "five-hour": return fiveHourScore(accountId); case "weekly": return weeklyScore(accountId); case "max-utilization": { @@ -198,6 +222,8 @@ function anthropicUsageScore(config: OcxConfig, accountId: string): number { } } +const TOKEN_SKEW_MS = 60_000; + /** Background `local-cli` slots with expired access are not pool-eligible (identity adoption risk). */ function isPoolCredentialUsable(accountId: string, now: number): boolean { const cred = getAccountCredential(PROVIDER, accountId); @@ -207,19 +233,13 @@ function isPoolCredentialUsable(accountId: string, now: number): boolean { return cred.expires > now + TOKEN_SKEW_MS; } -function isAnthropicAccountEligible(accountId: string, now: number): boolean { - return isAccountPoolEligible(POOL_KEY_ANTHROPIC, accountId, now, { - allowStickWait: isRateLimitStickWait(POOL_KEY_ANTHROPIC, accountId, now), - }); -} - export function getEligibleAnthropicAccounts(now = Date.now()): string[] { const set = getAccountSet(PROVIDER); if (!set) return []; return set.accounts .filter(account => account.needsReauth !== true - && isAnthropicAccountEligible(account.id, now) + && !isCooled(account.id, now) && isPoolCredentialUsable(account.id, now)) .map(account => account.id); } @@ -316,10 +336,15 @@ interface ScoredAccount { hasKnownUsage: boolean; score: number; fiveHourTieBreak: number; + /** True only under the opt-in weekly window; see compareScoredAccounts. */ knownFirst: boolean; } function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number { + // known-before-unknown belongs to the OPT-IN windows (weekly, max-utilization), not the + // legacy five-hour default. Applying it unconditionally changed ordering for operators who + // never opted in: an account measured at 100% would sort ahead of an unmeasured one purely + // because it had a reading. The accepted scope preserves the five-hour default exactly. if (a.knownFirst && b.knownFirst && a.hasKnownUsage !== b.hasKnownUsage) { return a.hasKnownUsage ? -1 : 1; } @@ -335,13 +360,16 @@ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: const scored: ScoredAccount[] = eligible.map(accountId => ({ accountId, hasKnownUsage: hasKnownUsage(config, accountId), - score: anthropicUsageScore(config, accountId), + score: usageScore(config, accountId), fiveHourTieBreak: window === "five-hour" ? 0 : fiveHourScore(accountId), + // Every window EXCEPT the legacy five-hour default is an explicit opt-in, so + // known-before-unknown applies to all of them and to none of the default path. knownFirst: window !== "five-hour", })); let best = scored[0]!; for (let i = 1; i < scored.length; i++) { const candidate = scored[i]!; + // Strict `< 0` keeps the earliest eligible account on an exact tie. if (compareScoredAccounts(candidate, best) < 0) best = candidate; } return best.accountId; @@ -369,6 +397,7 @@ function pickNextFillFirstAnthropicAccount( } return ordered[0] ?? null; } + // Skip successors that are also at/above threshold (known drained usage). let fallback: string | null = null; for (let step = 1; step <= stableAll.length; step++) { const candidate = stableAll[(startIdx + step) % stableAll.length]!; @@ -387,7 +416,7 @@ function pickAlternateAnthropicAccount( const strategy = anthropicPoolStrategy(config); const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId); if (strategy === "round-robin") { - return pickRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config)); + return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config)); } if (strategy === "fill-first") { return pickNextFillFirstAnthropicAccount(config, excludeId, eligible); @@ -395,6 +424,16 @@ function pickAlternateAnthropicAccount( return pickLowestUsage(config, excludeId, now); } +function pruneExpiredAffinity(now: number): void { + for (const [key, entry] of sessionAffinity) { + if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) sessionAffinity.delete(key); + } + if (sessionAffinity.size <= MAX_AFFINITY_ENTRIES) return; + const sorted = [...sessionAffinity.entries()].sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt); + const drop = sessionAffinity.size - MAX_AFFINITY_ENTRIES; + for (let i = 0; i < drop; i++) sessionAffinity.delete(sorted[i]![0]); +} + export type AnthropicAccountSelectionReason = | "pool-disabled" | "affinity" @@ -402,6 +441,7 @@ export type AnthropicAccountSelectionReason = | "lowest-usage" | "only-eligible" | "round-robin" + | "manual" | "fill-first" | "none" | "all-cooled"; @@ -424,10 +464,15 @@ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): if (threshold <= 0) return true; const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); if (window === "weekly" && exhausted5h(accountId)) return false; + // Unknown usage must not force fill-first to abandon the active account. if (!hasKnownUsage(config, accountId)) return true; - return anthropicUsageScore(config, accountId) < threshold; + return usageScore(config, accountId) < threshold; } +/** + * Fill-first: keep eligible active under threshold; otherwise advance to the next + * eligible id in stable sorted order after the current active (wrapping). + */ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string | null { const eligible = getEligibleAnthropicAccounts(now); if (eligible.length === 0) return null; @@ -449,86 +494,118 @@ function pickFillFirstAnthropicAccount(config: OcxConfig, now: number): string | return pickNextFillFirstAnthropicAccount(config, active, eligible); } -function resolveAnthropicFillFirst( - sessionKey: string | null | undefined, +/** + * Unbound new-session pick for round-robin / fill-first. Returns null to fall through + * to the legacy quota path (or when the strategy is quota). + */ +function pickUnboundStrategyAccount( config: OcxConfig, - set: NonNullable>, now: number, -): AnthropicAccountSelection { - const key = normalizeAffinityComponent(sessionKey); - if (key) { - const affined = getSessionAffinity(POOL_KEY_ANTHROPIC, key, now); - if (affined) { - const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true); - if ( - stillThere - && isAnthropicAccountEligible(affined.accountId, now) - && isPoolCredentialUsable(affined.accountId, now) - ) { - touchSessionAffinity(POOL_KEY_ANTHROPIC, key, now); - return { accountId: affined.accountId, reason: "affinity" }; - } - clearSessionAffinityForAccount(POOL_KEY_ANTHROPIC, affined.accountId); - } - } +): { accountId: string; reason: "round-robin" | "fill-first" } | null { + const strategy = anthropicPoolStrategy(config); + if (strategy === "quota") return null; - if (!key) { - const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true) - && isAnthropicAccountEligible(set.activeAccountId, now) - && isPoolCredentialUsable(set.activeAccountId, now); - if (activeOk) { - return { accountId: set.activeAccountId, reason: "active" }; - } + if (strategy === "round-robin") { + const eligible = getEligibleAnthropicAccounts(now); + const limit = stickyLimitForPool(config); + const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit); + if (!picked) return null; + return { accountId: picked, reason: "round-robin" }; } - const picked = pickFillFirstAnthropicAccount(config, now); - if (!picked) { - const anyCooled = set.accounts.some(a => !isAnthropicAccountEligible(a.id, now)); - return { accountId: null, reason: anyCooled ? "all-cooled" : "none" }; + if (strategy === "fill-first") { + const picked = pickFillFirstAnthropicAccount(config, now); + if (!picked) return null; + return { accountId: picked, reason: "fill-first" }; } - if (key && normalizeAffinityComponent(picked)) { - bindSessionAffinity(POOL_KEY_ANTHROPIC, key, picked, now); - } - return { accountId: picked, reason: "fill-first" }; + return null; } -function resolveAnthropicQuota( +/** + * Resolve which Anthropic OAuth account should serve this session. + * When the pool is disabled, always returns the store's active account. + */ +export function resolveAnthropicAccountForSession( sessionKey: string | null | undefined, config: OcxConfig, - set: NonNullable>, - now: number, + now = Date.now(), ): AnthropicAccountSelection { + pruneExpiredAffinity(now); + const set = getAccountSet(PROVIDER); + if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" }; + + if (manualPreference === undefined) { + manualPreference = set.selectionRevision !== undefined + ? { accountId: set.activeAccountId, revision: set.selectionRevision } + : null; + } + + if (!isAnthropicAccountPoolEnabled(config)) { + return { accountId: set.activeAccountId, reason: "pool-disabled" }; + } + + // A manual choice is a one-dispatch preference, not a lower-priority quota hint. + // Consume it only after admission commits, so a failed token lookup cannot spend it. + if (manualPreference) { + if (manualPreference.accountId !== set.activeAccountId || manualPreference.revision !== set.selectionRevision) { + manualPreference = null; + } else { + const chosen = manualPreference.accountId; + const quota = getCachedProviderAccountQuota(PROVIDER, chosen); + const exhausted = [quota?.fiveHourPercent, quota?.weeklyPercent, quota?.monthlyPercent, + ...(quota?.customWindows ?? []).map(window => window.percent)] + .some(percent => typeof percent === "number" && percent >= 100); + if (!exhausted && getEligibleAnthropicAccounts(now).includes(chosen)) { + return { accountId: chosen, reason: "manual" }; + } + } + } + const key = normalizeAffinityComponent(sessionKey); if (key) { - const affined = getSessionAffinity(POOL_KEY_ANTHROPIC, key, now); - if (affined) { + const affined = sessionAffinity.get(key); + if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) { const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true); - if ( - stillThere - && isAnthropicAccountEligible(affined.accountId, now) - && isPoolCredentialUsable(affined.accountId, now) - ) { - touchSessionAffinity(POOL_KEY_ANTHROPIC, key, now); + if (stillThere && !isCooled(affined.accountId, now) && isPoolCredentialUsable(affined.accountId, now)) { return { accountId: affined.accountId, reason: "affinity" }; } - clearSessionAffinityForAccount(POOL_KEY_ANTHROPIC, affined.accountId); + sessionAffinity.delete(key); } } + const strategy = anthropicPoolStrategy(config); + // No session identity (Desktop turns without a sticky key): hold the current + // active under RR/fill-first instead of treating every turn as a new session. + // Round-robin only when there is a real new-session key (or active is unusable). + if (!key && (strategy === "round-robin" || strategy === "fill-first")) { + const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true) + && !isCooled(set.activeAccountId, now) + && isPoolCredentialUsable(set.activeAccountId, now); + if (activeOk) { + return { accountId: set.activeAccountId, reason: "active" }; + } + } + + const strategyPick = pickUnboundStrategyAccount(config, now); + if (strategyPick) { + return { accountId: strategyPick.accountId, reason: strategyPick.reason }; + } + const threshold = anthropicAutoSwitchThreshold(config); const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true) - && isAnthropicAccountEligible(set.activeAccountId, now) + && !isCooled(set.activeAccountId, now) && isPoolCredentialUsable(set.activeAccountId, now); + let accountId: string | null = null; let reason: AnthropicAccountSelectionReason = "none"; if (threshold > 0) { const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config)); + // Unknown usage must NOT force a switch away from the healthy active account. if (activeOk && !(window === "weekly" && exhausted5h(set.activeAccountId)) - && (!hasKnownUsage(config, set.activeAccountId) - || anthropicUsageScore(config, set.activeAccountId) < threshold)) { + && (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) { accountId = set.activeAccountId; reason = "active"; } else { @@ -553,56 +630,11 @@ function resolveAnthropicQuota( } if (!accountId) { - const anyCooled = set.accounts.some(a => !isAnthropicAccountEligible(a.id, now)); + const anyCooled = set.accounts.some(a => isCooled(a.id, now)); return { accountId: null, reason: anyCooled ? "all-cooled" : "none" }; } - if (key && normalizeAffinityComponent(accountId)) { - bindSessionAffinity(POOL_KEY_ANTHROPIC, key, accountId, now); - } - return { accountId, reason }; -} - -/** - * Resolve which Anthropic OAuth account should serve this session. - * When the pool is disabled, always returns the store's active account. - */ -export function resolveAnthropicAccountForSession( - sessionKey: string | null | undefined, - config: OcxConfig, - now = Date.now(), -): AnthropicAccountSelection { - const set = getAccountSet(PROVIDER); - if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" }; - - if (!isAnthropicAccountPoolEnabled(config)) { - return { accountId: set.activeAccountId, reason: "pool-disabled" }; - } - const strategy = anthropicPoolStrategy(config); - if (strategy === "fill-first") { - return resolveAnthropicFillFirst(sessionKey, config, set, now); - } - if (strategy === "quota") { - return resolveAnthropicQuota(sessionKey, config, set, now); - } - - const kernelResult = resolvePoolAccount( - anthropicPoolPlugin, - sessionKey ?? null, - { - strategy: "round-robin", - enabled: true, - activeAccountId: set.activeAccountId, - stickyLimit: stickyLimitForPool(config), - autoSwitchThreshold: anthropicAutoSwitchThreshold(config), - }, - now, - ); - - return { - accountId: kernelResult.accountId, - reason: kernelResult.reason as AnthropicAccountSelectionReason, - }; + return { accountId, reason }; } export function bindAnthropicSessionAffinity( @@ -610,11 +642,16 @@ export function bindAnthropicSessionAffinity( accountId: string, now = Date.now(), ): void { - bindSessionAffinity(POOL_KEY_ANTHROPIC, sessionKey, accountId, now); + const key = normalizeAffinityComponent(sessionKey); + if (!key || !normalizeAffinityComponent(accountId)) return; + sessionAffinity.set(key, { accountId, lastUsedAt: now }); + pruneExpiredAffinity(now); } export function clearAnthropicSessionAffinityForAccount(accountId: string): void { - clearSessionAffinityForAccount(POOL_KEY_ANTHROPIC, accountId); + for (const [key, entry] of sessionAffinity) { + if (entry.accountId === accountId) sessionAffinity.delete(key); + } // The roster just lost or changed a member. This is the account-removal path, so the next // activation question must re-read rather than answer from a count taken while the account // was still present -- otherwise a delete leaves a stale quorum for the length of the TTL. @@ -633,30 +670,32 @@ export function rotateAnthropicAccountOn429( sessionKey?: string | null, now = Date.now(), ): string | null { - // Presence enables reactive recovery only when the operator has not made a choice. An explicit - // false is authoritative: the second credential may belong to a different billing, retention, - // or policy domain, so a 429 does not grant permission to replay the request under it. - const configured = config.anthropicAccountPool?.enabled; - if (configured === false) return null; - if (configured !== true && !hasAnthropicFailoverQuorum(now)) return null; - - recordPoolAccountCooldown( - POOL_KEY_ANTHROPIC, - failedAccountId, - "rate_limit", - retryAfterHeader, - now, - ); - clearSessionAffinityForAccount(POOL_KEY_ANTHROPIC, failedAccountId); + // Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing -- + // session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all + // of which move a HEALTHY request and stay opt-in. Rotating away from an account upstream has + // just rate-limited is a different thing: it only ever runs after a refusal, and stranding a + // 429 while a second logged-in account sits idle is a defect, not a configuration choice. + // Presence is the activation rule, the same one an apiKeyPool of two keys already uses. + if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null; + + const parsedRetry = parseRetryAfterMs(retryAfterHeader, now); + const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS; + upstreamHealth.set(failedAccountId, { + cooldownUntil: now + cooldownMs, + cooldownSource: parsedRetry ? "retry-after" : "default", + }); + sweepExpiredOnWrite(now); + clearAnthropicSessionAffinityForAccount(failedAccountId); notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId); // A rotation means the roster in use just changed; do not answer the next activation question // from a count read taken before the failure. quorumCache = null; - // The pool's strategy is a PROACTIVE policy. When enabled is absent, presence-defaulted - // recovery must not silently reactivate round-robin/fill-first merely because dormant values - // remain in config. The quota picker is the neutral recovery policy used by the default. - const next = configured === true + // The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive + // presence-only recovery must not silently reactivate round-robin/fill-first merely + // because those dormant values remain in config. The quota picker is the neutral + // recovery policy already used by the default strategy. + const next = isAnthropicAccountPoolEnabled(config) ? pickAlternateAnthropicAccount(config, failedAccountId, now) : pickLowestUsage(config, failedAccountId, now); if (!next) { @@ -664,19 +703,57 @@ export function rotateAnthropicAccountOn429( return null; } - const affinityKey = normalizeAffinityComponent(sessionKey); - if (affinityKey && normalizeAffinityComponent(next)) { - bindSessionAffinity(POOL_KEY_ANTHROPIC, affinityKey, next, now); - } console.warn( `[anthropic-pool] 429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`, ); return next; } -/** Promote dashboard active account after a validated failover target is usable. */ -export function promoteAnthropicActiveAccount(accountId: string): void { - void setActiveAccount(PROVIDER, accountId).catch(() => { /* best-effort */ }); +export interface AnthropicSelectionRoutingOptions { + config: OcxConfig; + sessionKey?: string | null; + reason?: AnthropicAccountSelectionReason; + expectedCredentialGeneration?: string; +} + +/** Commit the selected account before dispatch; rejected proposals have no routing side effects. */ +export async function promoteAnthropicActiveAccount( + accountId: string, + expectedSelection: OAuthAccountSelection | null, + options: AnthropicSelectionRoutingOptions, +): Promise { + if (!expectedSelection || !isPoolCredentialUsable(accountId, Date.now()) || isCooled(accountId, Date.now())) return null; + const committed = await commitOAuthAccountSelection(PROVIDER, accountId, { + expectedSelection, + expectedCredentialGeneration: options.expectedCredentialGeneration, + requireUsableAccount: true, + }); + if (!committed) return null; + return commitAnthropicSelectionRouting(accountId, expectedSelection, committed, options) ? committed : null; +} + +/** Main's shared selection owner calls this only after its authoritative commit succeeds. */ +export function commitAnthropicSelectionRouting( + accountId: string, + expectedSelection: OAuthAccountSelection, + committed: OAuthAccountSelection, + options: AnthropicSelectionRoutingOptions, +): boolean { + if (committed.accountId !== accountId) return false; + const current = captureOAuthAccountSelection(PROVIDER); + if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false; + if (isAnthropicAccountPoolEnabled(options.config)) { + if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") { + const limit = stickyLimitForPool(options.config); + const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, getEligibleAnthropicAccounts(), limit); + if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId); + notePoolRotationSuccess(POOL_KEY_ANTHROPIC, accountId, limit); + } + bindAnthropicSessionAffinity(options.sessionKey, accountId); + } + if (manualPreference === undefined || (manualPreference?.accountId === expectedSelection.accountId + && manualPreference.revision === expectedSelection.revision)) manualPreference = null; + return true; } /** @@ -684,8 +761,8 @@ export function promoteAnthropicActiveAccount(accountId: string): void { * unbound new session honors the operator-chosen account (Codex parity). */ export function resetAnthropicRoutingForManualSelection(accountId: string): void { - clearAffinityState(POOL_KEY_ANTHROPIC); - clearResolveState(POOL_KEY_ANTHROPIC); + sessionAffinity.clear(); + manualPreference = captureOAuthAccountSelection(PROVIDER); seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId); // A manual account selection is an operator statement about the roster; do not answer the // next activation question from a count read before it. @@ -711,6 +788,17 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise { + const accessToken = await getAnthropicPoolAccessToken(accountId); + const row = getAccountCredentialWithStatus(PROVIDER, accountId); + if (!row || row.needsReauth || row.credential.access !== accessToken + || row.credential.expires <= Date.now()) { + throw new Error("Anthropic pool credential changed during account selection"); + } + return { provider: PROVIDER, accountId, accessToken, generation: credentialGeneration(row.credential) }; +} + /** * Whether the pool may refresh this account's token. Background `local-cli` slots must not * adopt the global Claude CLI credential (same fail-closed rule as quota probes). @@ -749,5 +837,17 @@ export function anthropicSessionKeyFromParts(input: { /** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */ promptCacheKeyIsSharedCohort?: boolean; }): string | null { - return buildSessionKeyFromParts(input); + const preferred = ( + input.clientThreadId + ?? input.sessionIdHeader + ?? input.threadIdHeader + ?? "" + ).trim(); + if (preferred) { + return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex"); + } + if (input.promptCacheKeyIsSharedCohort) return null; + const cacheKey = input.promptCacheKey?.trim() ?? ""; + if (!cacheKey) return null; + return cacheKey.length <= 128 ? cacheKey : createHash("sha256").update(cacheKey).digest("hex"); } diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index 80f39b33e8..b5ab38eb74 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -16,7 +16,7 @@ */ import { getAccountSet } from "./store"; import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index"; -import { exhaustedCooldownMs, hasHeadroomEvidence, rankAccountsByHeadroom } from "./account-quota-rank"; +import { exhaustedCooldownMs, hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom } from "./account-quota-rank"; import { parseRetryAfterMs } from "../combos/failover"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -61,29 +61,12 @@ interface PresenceEntry { readAt: number; } -/** - * Ordered roster plus the active id, for the pre-dispatch preference. - * - * Same reasoning as the presence cache: `getAccountSet` reads through `loadAuthStore`, - * which chmods and re-parses the whole credential file on every call. Selection needs the - * ORDER and the active id, which the presence count cannot supply, so it gets its own - * TTL-bounded row. Ids and an active pointer only — never a credential. - */ -interface RosterEntry { - ids: string[]; - activeId: string | null; - readAt: number; -} - /** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */ const health = new Map(); /** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */ const presence = new Map(); -/** Provider -> recently read roster. TTL-bounded; never holds credential material. */ -const roster = new Map(); - const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`; function isCooled(provider: string, accountId: string, now: number): boolean { @@ -118,24 +101,6 @@ function eligibleAccountCount(providerName: string, now: number): number { return eligible; } -/** - * Roster ids and the active pointer, read at most once per TTL window. - * - * `needsReauth` accounts are excluded for the same reason the presence count excludes - * them: a revoked credential cannot serve the request we are about to send. - */ -function cachedRoster(providerName: string, now: number): { ids: string[]; activeId: string | null } { - const cached = roster.get(providerName); - if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) { - return { ids: cached.ids, activeId: cached.activeId }; - } - const set = getAccountSet(providerName); - const ids = set ? set.accounts.filter(a => a.needsReauth !== true).map(a => a.id) : []; - const activeId = set?.activeAccountId ?? null; - roster.set(providerName, { ids, activeId, readAt: now }); - return { ids, activeId }; -} - /** * Presence IS consent (#2568d). * @@ -190,8 +155,7 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n if (typeof perProvider === "boolean") { return perProvider && hasFailoverAccountQuorum(providerName, now); } - if (config.oauthAccountFailover?.enabled === false) return false; - return hasFailoverAccountQuorum(providerName, now); + return config.oauthAccountFailover?.enabled === true && hasFailoverAccountQuorum(providerName, now); } /** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */ @@ -238,8 +202,6 @@ export function rotateGenericOAuthAccountOn429( // A rotation means the roster in use just changed; do not answer the next activation question // from a count read before the failure. presence.delete(providerName); - // Same for the selection roster: the next request must not pick from a pre-failure read. - roster.delete(providerName); // Deterministic: start after the failed account so repeated 429s walk the roster instead of // hammering whichever id happens to sort first. The ring is built BEFORE ranking — ranking // the store's own order would change which account a quota-less provider rotates to. @@ -287,13 +249,19 @@ export function preferredInitialAccount( // The PROACTIVE predicate, not the reactive one: this steers a request upstream has not // refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it. if (!isProactivePreferenceEnabled(config, providerName, now)) return null; - // This runs on the initial resolution of EVERY request, and `loadAuthStore` has no - // cache: each call chmods the config dir, chmods the secret, reads the whole file and - // normalizes it (store.ts:136-151). So the store is consulted at most ONCE here, behind - // the same TTL the presence check uses, and never at all for a single-account provider. - const { ids: order, activeId: active } = cachedRoster(providerName, now); + // Read the same authoritative selection the management writer commits. Caching the + // active id separately would delay manual selection and account removal. + const selected = getAccountSet(providerName); + if (!selected) return null; + const active = selected.activeAccountId; + const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id); if (order.length < 2) return null; + const activeRow = selected.accounts.find(account => account.id === active); + if (activeRow && activeRow.needsReauth !== true + && !isCooled(providerName, activeRow.id, now) + && !isAccountQuotaExhausted(providerName, activeRow.id)) return null; + // Evidence is required BEFORE eligibility narrows the field. Without this, a provider // with no quota data at all could still be redirected: cool the active account with a // 429 and the eligible list collapses to one candidate, which any ranking returns @@ -316,11 +284,8 @@ export function preferredInitialAccount( const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null; // Nothing to do when the ranking agrees with the account we would have used anyway. // - // The roster may be up to PRESENCE_CACHE_TTL_MS old, so this answer is a PREFERENCE the - // caller must be able to abandon: it resolves the account with `requireUsableAccount`, - // which rejects a removed or reauth-flagged account inside the store read it was already - // performing, and falls back to the active account. Validating here instead would mean a - // second uncached read of the credential file on every redirected request. + // A proposal still needs guarded selection commit after credential resolution: a + // removal, reauth verdict, or manual choice can arrive during that await. return best && best !== active ? best : null; } @@ -339,7 +304,6 @@ export function genericFailoverRetryAfterSeconds(providerName: string, now = Dat /** Test seam and manual-recovery hook. */ export function forgetGenericFailoverRoster(providerName: string): void { - roster.delete(providerName); presence.delete(providerName); } @@ -348,11 +312,9 @@ export function clearGenericFailoverHealth(providerName?: string): void { if (!providerName) { health.clear(); presence.clear(); - roster.clear(); return; } presence.delete(providerName); - roster.delete(providerName); for (const key of [...health.keys()]) { if (key.startsWith(`${providerName}\u0000`)) health.delete(key); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 11b1410da6..4edd6375c8 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -2,13 +2,8 @@ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./typ import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; -import { - ConfigMutationLockError, - initializePersistedConfigIfMissing, - loadConfig, - mutatePersistedConfig, - resolveEnvValue, -} from "../config"; +import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config"; +import { resolveProviderApiKey } from "../providers/key-store"; import { maskEmail } from "../lib/privacy"; import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; import { @@ -41,7 +36,7 @@ import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthr import { loginKimi, refreshKimiToken } from "./kimi"; import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; -import { AntigravityTokenRequestError, loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; +import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; @@ -282,7 +277,6 @@ export const OAUTH_PROVIDERS: Record = { refresh: refreshAntigravityToken, providerConfig: oauthConfig("google-antigravity"), defaultModel: oauthDefaultModel("google-antigravity"), - defaultRefreshPolicy: "lazy-only", }, cursor: { login: (ctrl, opts) => loginCursor(ctrl, undefined, { forceLogin: opts?.forceLogin }), @@ -554,13 +548,7 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise { - return getValidAccessSnapshotForAccount(provider, accountId); -} - /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */ function isTerminalRefreshError(err: unknown): boolean { const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); @@ -619,9 +599,6 @@ function isTerminalRefreshError(err: unknown): boolean { || msg.includes("expired_token"); } function terminal(error:unknown):boolean{ - if (error instanceof AntigravityTokenRequestError) { - return (error.httpStatus === 400 || error.httpStatus === 401) && error.oauthError !== undefined; - } if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""); if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??""); if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined; @@ -1089,7 +1066,7 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf return undefined; } } - return resolveEnvValue(prov.apiKey); + return resolveProviderApiKey(prov.apiKey); } function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConfig): OcxProviderConfig { @@ -1262,6 +1239,37 @@ function isLegacyAntigravityStaticCatalog(provider: OcxProviderConfig): boolean ]); } +/** Refresh registry-owned catalog fields while preserving valid operator selections. */ +function applyOAuthPresetCatalog( + provider: OcxProviderConfig, + preset: OcxProviderConfig, +): void { + for (const field of OAUTH_RECONCILE_FIELDS) { + if (JSON.stringify(provider[field]) === JSON.stringify(preset[field])) continue; + if (preset[field] !== undefined) { + provider[field] = cloneProviderField(preset[field]) as never; + } else { + delete provider[field]; + } + } + if (provider.liveModels === undefined && preset.liveModels !== undefined) { + provider.liveModels = preset.liveModels; + } + // Heal only a selection that the refreshed static catalog no longer contains. Providers + // with live discovery do not expose an enumerable account catalog here, so their saved + // default remains operator-owned. + if ( + provider.liveModels !== true && + provider.defaultModel + && preset.defaultModel + && preset.models + && preset.models.length > 0 + && !(provider.models ?? []).includes(provider.defaultModel) + ) { + provider.defaultModel = preset.defaultModel; + } +} + /** Promote only the versioned canonical static seed; unmarked `liveModels: false` remains user intent. */ function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean { if (config.googleAntigravityStaticCatalogVersion !== 1) return false; @@ -1300,24 +1308,7 @@ function projectOAuthProviderReconciliation(config: OcxConfig): OAuthReconcilePr } if (def && prov.authMode === "oauth") { const preset = def.providerConfig; - for (const field of OAUTH_RECONCILE_FIELDS) { - if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue; - if (preset[field] !== undefined) { - prov[field] = cloneProviderField(preset[field]) as never; - } else { - delete prov[field]; - } - } - if (prov.liveModels === undefined && preset.liveModels !== undefined) { - prov.liveModels = preset.liveModels; - } - // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot). - // Skip providers without a static preset `models` list: for live-discovery providers - // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any - // persisted defaultModel is a user selection and must not be overwritten by the seed. - if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) { - prov.defaultModel = preset.defaultModel; - } + applyOAuthPresetCatalog(prov, preset); } if (JSON.stringify(prov) !== beforeProvider) { changed = true; @@ -1438,7 +1429,6 @@ const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [ "headers", "apiKeyTransport", "responsesPath", - "tlsProfile", "googleMode", "keyOptional", ] as const satisfies readonly (keyof OcxProviderConfig)[]; @@ -1452,17 +1442,31 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; + // Clone operator state, including xAI wire choices and their migration version. const next: OcxProviderConfig = structuredClone(existing ?? def.providerConfig); for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) { const value = def.providerConfig[field]; if (value === undefined) delete next[field]; else next[field] = structuredClone(value) as never; } + // A login may activate a different account. CCA dispatch must take that account's + // project from its credential snapshot, never retain the previous account's project. + if (next.googleMode === "cloud-code-assist") delete next.project; + // Login used to rebuild the whole row from the preset, so catalog data refreshed + // immediately. Keep that timing without overwriting unrelated operator-owned fields. + applyOAuthPresetCatalog(next, def.providerConfig); + // The original Command Code seed was an implementation-owned static catalog, not an + // operator opt-out. Promote that exact legacy shape when OAuth login refreshes the row. + if (provider === "command-code" && existing && isLegacyCommandCodeStaticCatalog(existing)) { + next.liveModels = def.providerConfig.liveModels; + } // OAuth-only providers must never retain credentials for a different auth mechanism. delete next.apiKey; delete next.apiKeyPool; - delete next.azureCredential; + delete (next as unknown as Record).azureCredential; if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { + // Retain stored key billing intent without resolving env references in the login process. + // An explicit OAuth choice stays OAuth even when usable key material is retained. // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes. let storedApiKey = sanitizeApiKeyValue(existing.apiKey); const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool); @@ -1476,9 +1480,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { // Keep routing and listProviderApiKeys in sync: never leave a hidden active key that // is absent from the pool (listing would fall back to pool[0] as "active"). if (!pool.some(entry => entry.key === storedApiKey)) { - const id = apiKeyPoolEntryId(storedApiKey); - if (pool.some(entry => entry.id === id)) throw new Error("API-key pool ID collision"); - pool.push({ id, key: storedApiKey }); + pool.push({ id: apiKeyPoolEntryId(storedApiKey), key: storedApiKey }); } next.apiKey = storedApiKey; next.apiKeyPool = pool; @@ -1494,8 +1496,7 @@ interface RunLoginDeps { saveCredential?: typeof saveCredential; saveAccountCredential?: typeof saveAccountCredential; loadConfig?: typeof loadConfig; - mutatePersistedConfig?: typeof mutatePersistedConfig; - initializePersistedConfigIfMissing?: typeof initializePersistedConfigIfMissing; + saveConfig?: typeof saveConfig; settleKiroLoginTransaction?: typeof settleKiroLoginTransaction; removeAccount?: typeof removeAccount; setActiveAccount?: typeof setActiveAccount; @@ -1530,8 +1531,7 @@ export async function runLogin( const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); const loadLatestConfig = deps.loadConfig ?? loadConfig; - const mutateLatestConfig = deps.mutatePersistedConfig ?? mutatePersistedConfig; - const initializeLatestConfig = deps.initializePersistedConfigIfMissing ?? initializePersistedConfigIfMissing; + const saveLatestConfig = deps.saveConfig ?? saveConfig; if (provider !== "chatgpt") { const preflightConfig = loadLatestConfig(); const namespaceCollision = codexAccountNamespaceProviderCollisionError( @@ -1561,18 +1561,15 @@ export async function runLogin( const existing = getAccountCredential(provider, opts.reauthAccountId); if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); if (!existing.accountId && !existing.email) { - if (provider !== GOOGLE_ANTIGRAVITY_PROVIDER || (!cred.accountId && !cred.email)) { - throw new OAuthReauthIdentityUnverifiedError(); - } - } else { - const identityMatches = existing.accountId && cred.accountId - ? existing.accountId === cred.accountId - : existing.email && cred.email - ? existing.email.toLowerCase() === cred.email.toLowerCase() - : false; - if (!identityMatches) { - throw new OAuthReauthIdentityMismatchError(); - } + throw new OAuthReauthIdentityUnverifiedError(); + } + const identityMatches = existing.accountId && cred.accountId + ? existing.accountId === cred.accountId + : existing.email && cred.email + ? existing.email.toLowerCase() === cred.email.toLowerCase() + : false; + if (!identityMatches) { + throw new OAuthReauthIdentityMismatchError(); } await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, { assertBeforePersist: deps.assertCurrentOwner, @@ -1586,32 +1583,16 @@ export async function runLogin( if (provider !== "chatgpt") { // Re-run against post-credential state so same-provider API-key additions, removals, // and active-key switches survive. A late namespace claim wins over provider creation. - const publish = () => mutateLatestConfig<{ error: string } | { config: OcxConfig }>(fresh => { - const lateCollision = codexAccountNamespaceProviderCollisionError( - fresh.codexAccountNamespaces, - provider, - ); - if (lateCollision) return { changed: false, value: { error: lateCollision } }; - upsertOAuthProvider(fresh, provider); - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - let outcome = publish(); - let published = false; - if (outcome.status === "unavailable" && outcome.reason === "missing") { - const initial = loadLatestConfig(); - const lateCollision = codexAccountNamespaceProviderCollisionError( - initial.codexAccountNamespaces, - provider, - ); - if (lateCollision) throw new OAuthProviderPublicationError(); - upsertOAuthProvider(initial, provider); - const initialized = initializeLatestConfig(initial); - published = initialized === "created"; - if (initialized === "exists") outcome = publish(); - } - if (!published && (outcome.status === "unavailable" || "error" in outcome.value)) { + const latestConfig = loadLatestConfig(); + const lateCollision = codexAccountNamespaceProviderCollisionError( + latestConfig.codexAccountNamespaces, + provider, + ); + if (lateCollision) { throw new OAuthProviderPublicationError(); } + upsertOAuthProvider(latestConfig, provider); + saveLatestConfig(latestConfig); } } catch (error) { const errors: unknown[] = [error]; diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index 8bc7203c7c..210870b718 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -10,9 +10,10 @@ import type { OcxProviderConfig } from "../types"; * * `strategy` and `autoSwitchThreshold` are still a declared contract the selector does not * consume — that is what `inert` reports. `enabled` is NOT inert any more: an explicit - * `false` refuses both the pre-dispatch account preference (`preferredInitialAccount`) and - * reactive 429 rotation. When it is absent, two eligible accounts enable reactive rotation by - * presence. + * `true` enables pre-dispatch exhaustion avoidance (`preferredInitialAccount`); absence is off. + * Healthy manual selections remain authoritative. What the switch can + * no longer do is refuse reactive 429 rotation, which activates on account presence and is not + * disableable. */ export type PoolSettingsKind = "codex" | "anthropic" | "generic"; @@ -46,8 +47,7 @@ export interface GenericPoolSettingsDto { * Slice-1 marker for `strategy` and `autoSwitchThreshold` only: persisted, not yet consumed * by the selector. * - * It deliberately does NOT describe `enabled`, which governs the pre-dispatch preference and - * reactive 429 rotation. + * It deliberately does NOT describe `enabled`, which governs the pre-dispatch preference. * Widening it to the whole DTO would tell a dashboard that `enabled` changes nothing, which * has been false since reactive and proactive activation were split. */ diff --git a/src/oauth/store.ts b/src/oauth/store.ts index cc354b3815..1b63d6e179 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -23,12 +23,13 @@ import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, ha import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget"; +import { publishAccountSelection } from "../lib/account-selection-events"; import { captureConfigGeneration, type GenerationContext, } from "../lib/state-store-sweeper"; import { validateCopilotApiBaseUrl } from "./github-copilot"; -import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types"; +import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types"; export type AuthStore = Record; @@ -536,7 +537,15 @@ function normalizeAccountSet(raw: unknown): { set: ProviderAccountSet | null; wa const active = typeof candidate.activeAccountId === "string" && accounts.some(a => a.id === candidate.activeAccountId) ? candidate.activeAccountId : accounts[0]!.id; - return { set: { activeAccountId: active, accounts }, wasLegacy: false }; + const set: ProviderAccountSet = { activeAccountId: active, accounts }; + // Healing a dangling active id invalidates its old selection generation. Reads stay + // deterministic; only the serialized writer creates new revisions. + if (active === candidate.activeAccountId + && typeof candidate.selectionRevision === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.selectionRevision)) { + set.selectionRevision = candidate.selectionRevision; + } + return { set, wasLegacy: false }; } // Legacy single-credential value. const cred = normalizeCredential(raw); @@ -670,9 +679,35 @@ function serializeMutation(work: () => Promise, retainedValues: readonly u export function mutateStore(fn:(store:AuthStore)=>T|Promise, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void }):Promise{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{ const { store, hadLegacy } = loadAuthStoreInternal(); if (hadLegacy) backupLegacyOnce(); + const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, { + set, + accountId: set.activeAccountId, + revision: set.selectionRevision, + accountIds: set.accounts.map(account => account.id), + }])); const result = await fn(store); options?.assertBeforePersist?.(); + const changedProviders: string[] = []; + for (const provider of new Set([...selections.keys(), ...Object.keys(store)])) { + const before = selections.get(provider); + const after = store[provider]; + if (!after) { + if (before) changedProviders.push(provider); + continue; + } + const replaced = before?.set !== after; + const removed = before?.accountIds.some(id => !after.accounts.some(account => account.id === id)); + if (replaced || removed || before?.accountId !== after.activeAccountId) { + // Replacement/rollback must never restore a previous generation. A common + // selection commit already assigned its revision before forming its result. + if (replaced || before?.revision === after.selectionRevision) after.selectionRevision = randomUUID(); + } + if (!before || before.accountId !== after.activeAccountId || before.revision !== after.selectionRevision) { + changedProviders.push(provider); + } + } persist(store); + for (const provider of changedProviders) publishAccountSelection(provider, "oauth"); return result; }finally{guard.release();}}, retainedValues, options?.waitMs); } @@ -700,6 +735,8 @@ export async function saveCredential( if (!safe) return; await mutateStore(store => { const set = store[provider]; + // Login explicitly selects an account, including a re-login to the same slot. + if (set) set.selectionRevision = randomUUID(); const identity = safe.accountId ?? safe.email; if (!set || SINGLE_SLOT_PROVIDERS.has(provider)) { const id = newAccountId(safe); @@ -883,13 +920,60 @@ export async function saveAccountCredential( }, [provider, accountId, safe], { assertBeforePersist: opts.assertBeforePersist }); } -export async function setActiveAccount(provider: string, accountId: string): Promise { +function accountSelection(set: ProviderAccountSet): OAuthAccountSelection { + return { + accountId: set.activeAccountId, + ...(set.selectionRevision !== undefined ? { revision: set.selectionRevision } : {}), + }; +} + +export function captureOAuthAccountSelection(provider: string): OAuthAccountSelection | null { + const set = getAccountSet(provider); + return set ? accountSelection(set) : null; +} + +/** + * Shared manual/automatic selection owner. An expected snapshot marks an automatic + * proposal: validating an unchanged selection preserves its revision. An unconditional + * (manual) selection always advances it, even when reselecting the current account. + */ +export async function commitOAuthAccountSelection( + provider: string, + accountId: string, + options: { + expectedSelection?: OAuthAccountSelection; + expectedCredentialGeneration?: string; + requireUsableAccount?: boolean; + } = {}, +): Promise { + // Snapshot caller-owned options before waiting for the serialized writer. + const expected = options.expectedSelection ? { ...options.expectedSelection } : undefined; + const { expectedCredentialGeneration, requireUsableAccount } = options; + const valid = (set: ProviderAccountSet): boolean => { + if (expected && (set.activeAccountId !== expected.accountId || set.selectionRevision !== expected.revision)) return false; + const account = set.accounts.find(account => account.id === accountId); + if (!account || (requireUsableAccount && account.needsReauth === true)) return false; + return expectedCredentialGeneration === undefined || credentialGeneration(account.credential) === expectedCredentialGeneration; + }; + if (expected?.accountId === accountId) { + // Ordinary admission is one synchronous read/validation, with no await, writer + // queue, or persistence. A changed selection must still take the guarded writer. + const set = getAccountSet(provider); + return set && valid(set) ? accountSelection(set) : null; + } return await mutateStore(store => { const set = store[provider]; - if (!set || !set.accounts.some(a => a.id === accountId)) return false; - set.activeAccountId = accountId; - return true; - }, [provider, accountId]); + if (!set || !valid(set)) return null; + if (!expected || set.activeAccountId !== accountId) { + set.activeAccountId = accountId; + set.selectionRevision = randomUUID(); + } + return accountSelection(set); + }, [provider, accountId, expected, expectedCredentialGeneration]); +} + +export async function setActiveAccount(provider: string, accountId: string): Promise { + return (await commitOAuthAccountSelection(provider, accountId)) !== null; } export async function setAccountAlias(provider: string, accountId: string, alias: string | undefined): Promise { diff --git a/src/oauth/types.ts b/src/oauth/types.ts index 5c2dac541b..19cf435d54 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -59,9 +59,17 @@ export interface ProviderAccount { /** auth.json value per provider: N accounts + which one requests use. */ export interface ProviderAccountSet { activeAccountId: string; + /** Opaque selection generation; absent in legacy stores, independent of token refresh. */ + selectionRevision?: string; accounts: ProviderAccount[]; } +/** Non-secret snapshot used to condition a selection on the choice that started a request. */ +export interface OAuthAccountSelection { + accountId: string; + revision?: string; +} + export interface OAuthController { onAuth?(info: { url: string; instructions?: string; deviceCode?: string }): void; onProgress?(message: string): void; diff --git a/src/providers/api-key-selection.ts b/src/providers/api-key-selection.ts new file mode 100644 index 0000000000..8cf14cfcb3 --- /dev/null +++ b/src/providers/api-key-selection.ts @@ -0,0 +1,110 @@ +import { randomUUID } from "node:crypto"; +import { mutatePersistedConfig } from "../config"; +import { publishAccountSelection } from "../lib/account-selection-events"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { ProviderApiKeySelection } from "../types/provider"; +import { routedProviderConfig } from "../router"; +import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; +import { resolveProviderTransport, XAI_GROK_COMPATIBILITY, type OcxProviderTransport } from "./xai-transport"; + +export function captureProviderApiKeySelection(provider: OcxProviderConfig): ProviderApiKeySelection { + return { + entryId: provider.apiKeyPool?.find(entry => entry.key === provider.apiKey)?.id, + reference: provider.apiKey, + revision: provider.apiKeySelectionRevision, + }; +} + +function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean { + const current = captureProviderApiKeySelection(provider); + return current.entryId === expected.entryId && current.reference === expected.reference + && current.revision === expected.revision; +} + +function currentKeyProvider(config: OcxConfig, name: string): OcxProviderConfig | null { + const configured = config.providers[name]; + if (!configured || configured.disabled) return null; + const current = routedProviderConfig(name, { ...configured, _apiKeyAttempt: undefined }); + if (current.authMode === "oauth" || current.authMode === "forward") return null; + if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null; + return current; +} + +/** Physical-send check; stored references alone do not detect a changed env/keychain value. */ +export function providerApiKeySelectionIsCurrent( + config: OcxConfig, + name: string, + routedProvider: OcxProviderConfig, +): boolean { + const current = currentKeyProvider(config, name); + const expected = routedProvider._apiKeyAttempt; + return current !== null && expected !== undefined + && matchesSelection(config.providers[name]!, expected) + && current.apiKey === routedProvider.apiKey + && current.authMode === routedProvider.authMode + && current.baseUrl === routedProvider.baseUrl; +} + +/** Rebuild transport from the already committed choice; never allocate or publish a selection. */ +export function resolveCurrentProviderApiKeyTransport( + config: OcxConfig, + name: string, + routedProvider: OcxProviderConfig, +): OcxProviderConfig | null { + const current = currentKeyProvider(config, name); + if (!current) return null; + const runtime = routedProvider as OcxProviderTransport; + const headers = { ...current.headers }; + const affinityHeaders = name === "xai" + ? [XAI_GROK_COMPATIBILITY.headers.conversationId, XAI_GROK_COMPATIBILITY.headers.sessionId] + : [OPENCODE_GO_SESSION_HEADER]; + for (const header of affinityHeaders) { + const configured = Object.keys(headers).some(key => key.toLowerCase() === header.toLowerCase()); + const value = Object.entries(runtime.headers ?? {}).find(([key]) => key.toLowerCase() === header.toLowerCase())?.[1]; + if (!configured && value !== undefined) headers[header] = value; + } + const fetch = (current as OcxProviderTransport).fetch ?? runtime.fetch; + return resolveProviderTransport(name, { + ...current, + ...(Object.keys(headers).length ? { headers } : {}), + ...(fetch ? { fetch } : {}), + }); +} + +type SelectionMutation = { changed: boolean; value: T; selectionChanged?: boolean }; +export type ProviderApiKeyCommit = + | { status: "committed"; provider: OcxProviderConfig; value: T } + | { status: "superseded"; provider: OcxProviderConfig } + | { status: "unavailable" }; + +/** GUI and recovery share one persisted selection transaction and post-commit notification. */ +export function commitProviderApiKeySelection( + config: OcxConfig, + name: string, + mutation: (provider: OcxProviderConfig) => SelectionMutation, + expectedSelection?: ProviderApiKeySelection, +): ProviderApiKeyCommit { + const outcome = mutatePersistedConfig & { notify?: boolean }>(fresh => { + const provider = fresh.providers[name]; + if (!provider || provider.authMode === "oauth" || provider.authMode === "forward") { + return { changed: false, value: { status: "unavailable" } }; + } + if (expectedSelection && !matchesSelection(provider, expectedSelection)) { + return { changed: false, value: { status: "superseded", provider: structuredClone(provider) } }; + } + const before = provider.apiKey; + const result = mutation(provider); + const notify = result.selectionChanged === true || before !== provider.apiKey; + if (notify) provider.apiKeySelectionRevision = randomUUID(); + delete provider._apiKeyAttempt; + return { + changed: result.changed || notify, + value: { status: "committed", provider: structuredClone(provider), value: result.value, notify }, + }; + }); + if (outcome.status === "unavailable") return { status: "unavailable" }; + const committed = outcome.value; + if (committed.status !== "unavailable") config.providers[name] = structuredClone(committed.provider); + if (committed.status === "committed" && committed.notify) publishAccountSelection(name, "api-key"); + return committed; +} diff --git a/src/providers/api-keys.ts b/src/providers/api-keys.ts index c47d6c0b00..ceb5522df2 100644 --- a/src/providers/api-keys.ts +++ b/src/providers/api-keys.ts @@ -7,10 +7,10 @@ * A legacy bare `apiKey` is projected as one row on reads and seeded on first mutation. */ import { createHash } from "node:crypto"; -import { mutatePersistedConfig } from "../config"; -import { isAzureIdentityProvider } from "../config/provider-validation"; +import { saveConfigPreservingClaudeCode } from "../config"; import type { OcxConfig, OcxProviderConfig } from "../types"; import type { AccountQuotaFields } from "./quota-types"; +import { commitProviderApiKeySelection } from "./api-key-selection"; export interface ProviderApiKeyInfo extends AccountQuotaFields { id: string; @@ -40,7 +40,7 @@ export function apiKeyPoolEntryId(key: string): string { /** True for providers whose upstream auth is a configured API key (not oauth/forward). */ export function isKeyAuthProvider(provider: OcxProviderConfig): boolean { - return !isAzureIdentityProvider(provider) && provider.authMode !== "oauth" && provider.authMode !== "forward"; + return provider.authMode !== "oauth" && provider.authMode !== "forward"; } /** Trim and reject blank / CRLF-bearing secrets. Shared by pool writes and OAuth upsert. */ @@ -59,23 +59,6 @@ function ensurePool(provider: OcxProviderConfig): NonNullable( - config: OcxConfig, - name: string, - mutate: (provider: OcxProviderConfig) => T | null, -): T | null { - const outcome = mutatePersistedConfig(fresh => { - const provider = fresh.providers[name]; - if (!provider || !isKeyAuthProvider(provider)) return { changed: false, value: null }; - const before = JSON.stringify(provider); - const value = mutate(provider); - return { changed: value !== null && JSON.stringify(provider) !== before, value: value === null ? null : { provider, value } }; - }); - if (outcome.status === "unavailable" || outcome.value === null) return null; - config.providers[name] = structuredClone(outcome.value.provider); - return outcome.value.value; -} - export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } { const provider = config.providers[name]; if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] }; @@ -103,63 +86,62 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string, if (typeof key !== "string" || !key.trim()) return { error: "key is required" }; const trimmed = sanitizeApiKeyValue(key); if (!trimmed) return { error: "key must not include line breaks" }; - const saved = mutateProvider(config, name, fresh => { + const id = apiKeyPoolEntryId(trimmed); + const committed = commitProviderApiKeySelection(config, name, fresh => { const pool = ensurePool(fresh); - const existing = pool.find(entry => entry.key === trimmed); + const existing = pool.find(e => e.id === id); if (existing) { if (label?.trim()) existing.label = label.trim(); - fresh.apiKey = trimmed; - return { id: existing.id }; + } else { + pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() }); } - const id = apiKeyPoolEntryId(trimmed); - if (pool.some(entry => entry.id === id)) return { error: "key id collision" }; - pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() }); fresh.apiKey = trimmed; - return { id }; + return { changed: true, selectionChanged: true, value: id }; }); - return saved === null ? { error: "config is unavailable" } : saved; + return committed.status === "committed" ? { id } : { error: "provider selection unavailable" }; } /** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: string): boolean { - const provider = config.providers[name]; - if (!provider || !isKeyAuthProvider(provider)) return false; - return mutateProvider(config, name, fresh => { - const entry = ensurePool(fresh).find(candidate => candidate.id === id); - if (!entry) return null; - fresh.apiKey = entry.key; - return true; - }) === true; + const committed = commitProviderApiKeySelection(config, name, provider => { + const entry = provider.apiKeyPool?.find(e => e.id === id) + ?? (!provider.apiKeyPool?.length && provider.apiKey && apiKeyPoolEntryId(provider.apiKey) === id + ? { id, key: provider.apiKey } : undefined); + if (!entry) return { changed: false, value: false }; + ensurePool(provider); + provider.apiKey = entry.key; + return { changed: true, selectionChanged: true, value: true }; + }); + return committed.status === "committed" && committed.value; } /** Rename a key slot without changing its id, secret, or active routing state. */ export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: string, label: string | undefined): boolean { const provider = config.providers[name]; if (!provider || !isKeyAuthProvider(provider)) return false; - return mutateProvider(config, name, fresh => { - const entry = ensurePool(fresh).find(candidate => candidate.id === id); - if (!entry) return null; - if (label) entry.label = label; - else delete entry.label; - return true; - }) === true; + const entry = ensurePool(provider).find(e => e.id === id); + if (!entry) return false; + if (label) entry.label = label; + else delete entry.label; + saveConfigPreservingClaudeCode(config); + return true; } /** Remove one key; removing the active one promotes the first remaining. Persists config. */ export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean { - const provider = config.providers[name]; - if (!provider || !isKeyAuthProvider(provider)) return false; - return mutateProvider(config, name, fresh => { - const pool = ensurePool(fresh); - const entry = pool.find(candidate => candidate.id === id); - if (!entry) return null; - fresh.apiKeyPool = pool.filter(candidate => candidate.id !== id); - if (fresh.apiKey === entry.key) { - const next = fresh.apiKeyPool[0]; - if (next) fresh.apiKey = next.key; - else delete fresh.apiKey; + const committed = commitProviderApiKeySelection(config, name, provider => { + const pool = provider.apiKeyPool?.length ? provider.apiKeyPool + : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : []; + const entry = pool.find(e => e.id === id); + if (!entry) return { changed: false, value: false }; + provider.apiKeyPool = pool.filter(e => e.id !== id); + if (provider.apiKey === entry.key) { + const next = provider.apiKeyPool[0]; + if (next) provider.apiKey = next.key; + else delete provider.apiKey; } - if (fresh.apiKeyPool.length === 0) delete fresh.apiKeyPool; - return true; - }) === true; + if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool; + return { changed: true, value: true }; + }); + return committed.status === "committed" && committed.value; } diff --git a/src/providers/context-cap.ts b/src/providers/context-cap.ts index d10807ced2..15e11be599 100644 --- a/src/providers/context-cap.ts +++ b/src/providers/context-cap.ts @@ -43,13 +43,22 @@ export function globalContextCapValue(config: Pick return isValidContextCap(value) ? Math.floor(value) : DEFAULT_PROVIDER_CONTEXT_CAP; } +/** Active caps win over remembered values from an earlier switch-off. */ +export function selectedProviderContextCaps(config: Pick): Record { + return { ...providerContextCaps({ providerContextCaps: config.providerContextCapValues }), ...providerContextCaps(config) }; +} + export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean, value?: number): void { const next = providerContextCaps(config); + const selected = selectedProviderContextCaps(config); if (enabled) { - next[provider] = isValidContextCap(value) ? Math.floor(value) : globalContextCapValue(config); + const remembered = Object.hasOwn(selected, provider) ? selected[provider] : undefined; + next[provider] = isValidContextCap(value) ? Math.floor(value) : (isValidContextCap(remembered) ? remembered : globalContextCapValue(config)); + selected[provider] = next[provider]; } else { delete next[provider]; } + if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; if (Object.keys(next).length > 0) config.providerContextCaps = next; else deleteConfigTopLevelKey(config, "providerContextCaps"); } @@ -66,18 +75,33 @@ export function setGlobalContextCapValue(config: OcxConfig, value: number, apply if (!applyToAll) return; const caps = providerContextCaps(config); for (const provider of Object.keys(caps)) caps[provider] = next; - if (Object.keys(caps).length > 0) config.providerContextCaps = caps; + if (Object.keys(caps).length > 0) { + config.providerContextCaps = caps; + config.providerContextCapValues = { ...selectedProviderContextCaps(config), ...caps }; + } } /** Enable the cap for every named provider at the current value, or clear all caps. */ export function setAllProviderContextCaps(config: OcxConfig, providerNames: string[], enabled: boolean): void { + const selected = selectedProviderContextCaps(config); if (!enabled) { + if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; deleteConfigTopLevelKey(config, "providerContextCaps"); return; } const value = globalContextCapValue(config); const next: Record = {}; - for (const name of providerNames) next[name] = value; + for (const name of providerNames) { next[name] = value; selected[name] = value; } + if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; if (Object.keys(next).length > 0) config.providerContextCaps = next; else deleteConfigTopLevelKey(config, "providerContextCaps"); } + +/** Provider removal clears both the active limit and its remembered selection. */ +export function forgetProviderContextCap(config: OcxConfig, provider: string): void { + setProviderContextCap(config, provider, false); + const values = { ...config.providerContextCapValues }; + delete values[provider]; + if (Object.keys(values).length > 0) config.providerContextCapValues = values; + else deleteConfigTopLevelKey(config, "providerContextCapValues"); +} diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index 321377bbcc..ad4d61ba8d 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -8,11 +8,10 @@ * * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools. */ -import { mutatePersistedConfig } from "../config"; -import { isAzureIdentityProvider } from "../config/provider-validation"; +import { commitProviderApiKeySelection } from "./api-key-selection"; +import type { ProviderApiKeySelection } from "../types/provider"; import { routedProviderConfig } from "../router"; import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types"; -import { resolveProviderApiKey } from "./key-store"; import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; @@ -38,7 +37,10 @@ const DEFAULT_RATE_LIMIT_RETRY = { respectRetryAfter: true, } as const satisfies Required; -/** Total-send budget used by a bare transient-5xx retry opt-in. */ +/** + * Default transient-5xx retry used when a provider opts in with a bare + * `transientRetryOn5xx: {}`. `attempts` is a TOTAL send budget, not extra retries. + */ const DEFAULT_TRANSIENT_RETRY = { enabled: true, attempts: 3, @@ -94,7 +96,6 @@ function isKeyInCooldown(providerName: string, keyId: string, now = Date.now()): * Returns true only for key-auth providers with 2+ pool entries. */ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean { - if (isAzureIdentityProvider(provider)) return false; if (provider.authMode === "oauth" || provider.authMode === "forward") return false; return (provider.apiKeyPool?.length ?? 0) >= 2; } @@ -107,7 +108,7 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean { * callers never re-check fields. */ export function rateLimitRetryPolicyFor( - provider: Pick, + provider: Pick, ): Required | null { const policy = provider.retryOn429; if (!policy || policy.enabled === false) return null; @@ -116,7 +117,6 @@ export function rateLimitRetryPolicyFor( // same token, local runtimes have no remote key to preserve, and unknown/custom values are // rejected rather than guessed at. if (provider.authMode !== undefined && provider.authMode !== "key") return null; - if (isAzureIdentityProvider(provider)) return null; return { enabled: policy.enabled ?? DEFAULT_RATE_LIMIT_RETRY.enabled, attempts: policy.attempts ?? DEFAULT_RATE_LIMIT_RETRY.attempts, @@ -126,14 +126,23 @@ export function rateLimitRetryPolicyFor( }; } +/** + * Normalize a provider's `transientRetryOn5xx` policy, or return null when it is absent, + * explicitly disabled, not key-auth, or not the `openai-chat` adapter. + * + * The adapter gate is part of the accepted scope, not incidental: this first version covers + * key-auth `openai-chat` only, and without an explicit check any generic key-auth adapter + * could opt in. Auth mode follows the same fail-closed rule as `rateLimitRetryPolicyFor` — + * explicit `key` or the documented omitted default, never OAuth, forward, local, or an + * unknown value. + */ export function transientRetryPolicyFor( - provider: Pick, + provider: Pick, ): Required | null { const policy = provider.transientRetryOn5xx; if (!policy || policy.enabled === false) return null; if (provider.adapter !== "openai-chat") return null; if (provider.authMode !== undefined && provider.authMode !== "key") return null; - if (isAzureIdentityProvider(provider)) return null; return { enabled: policy.enabled ?? DEFAULT_TRANSIENT_RETRY.enabled, attempts: policy.attempts ?? DEFAULT_TRANSIENT_RETRY.attempts, @@ -178,37 +187,32 @@ function rotateKeyAfterFailure( retryAfterHeader: string | null | undefined, now = Date.now(), attemptedKey?: string, + attemptedSelection?: ProviderApiKeySelection, ): OcxProviderConfig | null { const provider = config.providers[providerName]; if (!provider) return null; - if (isAzureIdentityProvider(provider)) return null; if (provider.authMode === "oauth" || provider.authMode === "forward") return null; - const failedKey = attemptedKey ?? provider.apiKey; + const failedKey = attemptedSelection?.reference ?? attemptedKey ?? provider.apiKey; type Rotation = - | { provider: OcxProviderConfig; failedId?: string; candidateId?: string } + | { failedId?: string; candidateId?: string } | { exhaustedCount: number; failedId?: string }; - const outcome = mutatePersistedConfig(fresh => { - const freshProvider = fresh.providers[providerName]; - if (!freshProvider || isAzureIdentityProvider(freshProvider) - || freshProvider.authMode === "oauth" || freshProvider.authMode === "forward") { - return { changed: false, value: null }; - } + const outcome = commitProviderApiKeySelection(config, providerName, freshProvider => { const pool = freshProvider.apiKeyPool; if (!pool || pool.length < 2) return { changed: false, value: null }; + // The callback can be rerun after rebasing, so identify the failed key here but // defer the in-memory cooldown side effect until persistence has succeeded. - const failedEntry = pool.find(entry => entry.key === failedKey - || (failedKey !== undefined && resolveProviderApiKey(entry.key) === failedKey)); + const failedEntry = attemptedSelection?.entryId + ? pool.find(entry => entry.id === attemptedSelection.entryId && entry.key === failedKey) + : pool.find(entry => entry.key === failedKey); - const activeMatchesFailure = freshProvider.apiKey === failedKey - || (failedKey !== undefined && resolveProviderApiKey(freshProvider.apiKey) === failedKey); - if (!activeMatchesFailure) { + if (freshProvider.apiKey !== failedKey) { const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey); if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) { return { changed: false, - value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id }, + value: { failedId: failedEntry?.id }, }; } } @@ -222,15 +226,20 @@ function rotateKeyAfterFailure( return { changed: true, value: { - provider: structuredClone(freshProvider), failedId: failedEntry?.id, candidateId: candidate.id, }, }; } return { changed: false, value: { exhaustedCount: pool.length, failedId: failedEntry?.id } }; - }); - if (outcome.status === "unavailable" || outcome.value === null) return null; + }, attemptedSelection); + if (outcome.status === "unavailable") return null; + if (outcome.status === "superseded") { + // A newer manual selection (including A→B→A) owns subsequent dispatch. Reusing the + // same failed key here would loop forever; preserve its original failure instead. + return outcome.provider.apiKey !== failedKey ? structuredClone(outcome.provider) : null; + } + if (outcome.value === null) return null; if (outcome.value.failedId) { // A 401 is a verdict about the credential itself, not a timing signal: the key is rejected // until an operator replaces it, and upstreams send no Retry-After for it. Hold it for the @@ -246,7 +255,7 @@ function rotateKeyAfterFailure( return null; } - const committed = structuredClone(outcome.value.provider); + const committed = structuredClone(outcome.provider); config.providers[providerName] = committed; if (outcome.value.candidateId) { console.warn( @@ -263,8 +272,9 @@ export function rotateKeyOn429( retryAfterHeader: string | null | undefined, now = Date.now(), attemptedKey?: string, + attemptedSelection?: ProviderApiKeySelection, ): OcxProviderConfig | null { - return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey); + return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection); } /** @@ -280,8 +290,9 @@ export function rotateKeyOn401( providerName: string, now = Date.now(), attemptedKey?: string, + attemptedSelection?: ProviderApiKeySelection, ): OcxProviderConfig | null { - return rotateKeyAfterFailure(config, providerName, 401, null, now, attemptedKey); + return rotateKeyAfterFailure(config, providerName, 401, null, now, attemptedKey, attemptedSelection); } export function sweepExpiredApiKeyCooldowns(now = Date.now()): number { @@ -298,6 +309,7 @@ interface RotateProviderTransportOptions { retryAfter?: string | null; now?: number; attemptedKey?: string; + attemptedSelection?: ProviderApiKeySelection; promptCacheKey?: string; } @@ -319,6 +331,7 @@ export function rotateProviderTransportOn429( options.retryAfter, options.now, options.attemptedKey, + options.attemptedSelection ?? routedProvider._apiKeyAttempt, ); if (!rotated) return null; return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey); @@ -331,7 +344,8 @@ export function rotateProviderTransportOn401( routedProvider: OcxProviderTransport, options: Omit = {}, ): OcxProviderTransport | null { - const rotated = rotateKeyOn401(config, providerName, options.now, options.attemptedKey); + const rotated = rotateKeyOn401(config, providerName, options.now, options.attemptedKey, + options.attemptedSelection ?? routedProvider._apiKeyAttempt); if (!rotated) return null; return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey); } @@ -346,10 +360,6 @@ function applyRotatedTransport( const routedSession = routedProvider.headers?.[OPENCODE_GO_SESSION_HEADER]; const retryProvider: OcxProviderTransport = { ...committedRoute, - ...(routedProvider.promptCacheKey !== undefined ? { promptCacheKey: routedProvider.promptCacheKey } : {}), - ...(routedProvider.parallelToolCalls !== undefined ? { parallelToolCalls: routedProvider.parallelToolCalls } : {}), - ...(routedProvider.modelContextWindows !== undefined ? { modelContextWindows: routedProvider.modelContextWindows } : {}), - ...(routedProvider.noTemperatureModels !== undefined ? { noTemperatureModels: routedProvider.noTemperatureModels } : {}), ...(routedProvider.fetch !== undefined ? { fetch: routedProvider.fetch } : {}), ...(routedSession !== undefined ? { diff --git a/src/providers/provider-id-rewrite.ts b/src/providers/provider-id-rewrite.ts index bd600ec65a..6635b40432 100644 --- a/src/providers/provider-id-rewrite.ts +++ b/src/providers/provider-id-rewrite.ts @@ -119,14 +119,16 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s // Keys. `providerContextCaps` is KEYED by provider id — a prefix rewrite would // silently orphan the cap — and a destination key may already be occupied. - const caps = config.providerContextCaps; - if (caps && Object.hasOwn(caps, from)) { - if (Object.hasOwn(caps, to)) { - collisions.push(`providerContextCaps.${to}`); - } else { - caps[to] = caps[from]!; - delete caps[from]; - changed += 1; + for (const field of ["providerContextCaps", "providerContextCapValues"] as const) { + const caps = config[field]; + if (caps && Object.hasOwn(caps, from)) { + if (Object.hasOwn(caps, to)) { + collisions.push(`${field}.${to}`); + } else { + caps[to] = caps[from]!; + delete caps[from]; + changed += 1; + } } } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 1a25811707..ff3cad2824 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -10,7 +10,7 @@ import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { codexPlanKey } from "../codex/plan"; import { resolveProviderApiKey } from "./key-store"; -import { getValidAccessToken, getValidAccessTokenForAccount, getValidAccessTokenSnapshot, getValidAccessTokenSnapshotForAccount, type OAuthAccessSnapshot } from "../oauth"; +import { getValidAccessToken, getValidAccessTokenForAccount, getValidAccessTokenSnapshot, getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "../oauth"; import { getAccountCredential, getAccountSet } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { @@ -2658,11 +2658,22 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro } const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com"; -let antigravityOutboundDependencies: ProviderOutboundDependencies = {}; +const ANTIGRAVITY_QUOTA_SUMMARY_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; +const ANTIGRAVITY_QUOTA_MODELS_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; -/** Test seam: inject resolver/pinned transport for the per-account Antigravity probe. */ +/** Only these fixed accounting destinations may use transparent Fake-IP DNS. */ +export function isCanonicalAntigravityQuotaUrl(name: string, url: string): boolean { + return name === "google-antigravity" + && (url === ANTIGRAVITY_QUOTA_SUMMARY_URL || url === ANTIGRAVITY_QUOTA_MODELS_URL); +} + +let antigravityOutboundDependencies: ProviderOutboundDependencies = { + isCanonicalUrl: isCanonicalAntigravityQuotaUrl, +}; + +/** Test seam: inject resolver/pinned transport for provider and per-account probes. */ export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void { - antigravityOutboundDependencies = dependencies ?? {}; + antigravityOutboundDependencies = { ...dependencies, isCanonicalUrl: isCanonicalAntigravityQuotaUrl }; } type AntigravitySummaryProbe = @@ -2671,7 +2682,7 @@ type AntigravitySummaryProbe = | { kind: "unavailable" }; async function fetchAntigravitySummaryQuota(accessToken: string, projectId: string): Promise { - const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; + const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; try { const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { headers: { @@ -2705,7 +2716,7 @@ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: if (summary.kind === "terminal") return null; if (summary.kind === "quota") return summary.quota; - const url = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; + const url = ANTIGRAVITY_QUOTA_MODELS_URL; const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { Accept: "application/json", @@ -2726,7 +2737,7 @@ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: async function fetchAntigravityAccountQuota(accountId: string): Promise { let snapshot: OAuthAccessSnapshot; try { - snapshot = await getValidAccessTokenSnapshotForAccount("google-antigravity", accountId); + snapshot = await getValidAccessSnapshotForAccount("google-antigravity", accountId); } catch { return null; } diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 4e4bcad2ab..ce8f9591d1 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -287,6 +287,16 @@ export function restoreRoutedCustomCalls( const helper = aliased && sourceInput !== "" ? item.name : resolveCodeModeHelperName(undefined, targetName, sourceInput, itemNamespace, declaredNames); + // Native custom input is already the tool's raw grammar. Only a recognized + // helper/envelope may reinterpret it; a JSON-looking native body is not a wrapper. + if (item.type === "custom_tool_call" && !aliased && !helper) { + const input = repairNames.has(wireName) && typeof sourceInput === "string" + ? normalizeApplyPatchDelimiters(sourceInput) + : sourceInput; + return input !== sourceInput + ? { value: { ...item, input }, changed: true } + : { value: item, changed: false }; + } const restored: Record = { ...item, type: "custom_tool_call", diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts new file mode 100644 index 0000000000..c4888cf1f7 --- /dev/null +++ b/src/responses/function-call-compat.ts @@ -0,0 +1,173 @@ +import { coerceIntegerToolArguments } from "../lib/tool-argument-integers"; +import { namespacedToolName } from "../types/tools"; +import { rewriteRoutedNamespaceToolsForUpstream } from "./namespace-tool-compat"; +import { collectResponsesToolGroups } from "./tool-groups"; + +export interface FunctionCallRepairSchema { + name: string; + namespace?: string; + parameters?: Record; +} + +/** Keys are canonical original identities, never a bare-name fallback for a namespace. */ +export type FunctionCallRepairSchemas = ReadonlyMap; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** JSON object member order is immaterial; array elements retain their exact order. */ +function sameSchemaValue(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length + && left.every((value, index) => sameSchemaValue(value, right[index])); + } + if (!isObject(left) || !isObject(right)) return false; + const keys = Object.keys(left); + return keys.length === Object.keys(right).length + && keys.every(key => Object.hasOwn(right, key) && sameSchemaValue(left[key], right[key])); +} + +function namespaceOf(value: unknown): string | undefined { + return typeof value === "string" && value !== "functions" ? value : undefined; +} + +function selectorAllows( + selector: unknown, + lowered: unknown, + wireName: string, + identity: FunctionCallRepairSchema, +): boolean { + if (!isObject(selector) || selector.type !== "function" || typeof selector.name !== "string") return false; + if ("namespace" in selector) { + if (typeof selector.namespace !== "string" || selector.namespace.length === 0) return false; + return namespaceOf(selector.namespace) === identity.namespace && selector.name === identity.name; + } + return isObject(lowered) && lowered.type === "function" && lowered.name === wireName; +} + +/** Caller supplies currentTurnWireToolCatalogBody BEFORE provider schema lowering. */ +export function collectFunctionCallRepairSchemas(body: unknown): Map { + const schemas = new Map(); + if (!isObject(body)) return schemas; + const groups = collectResponsesToolGroups(body); + if (Array.isArray(body.input)) { + for (const entry of body.input) { + if (isObject(entry) && entry.type === "tool_search_output" && Array.isArray(entry.tools)) groups.push(entry.tools); + } + } + // Reuse namespace selector resolution, retaining schemas from the original objects below. + // This local catalog view includes loaded definitions without revisiting replay history or + // teaching the shared tool-group collector a new transport-wide interpretation. + const lowered = rewriteRoutedNamespaceToolsForUpstream({ ...body, tools: groups.flat(), input: [] }).body; + const choice = body.tool_choice; + const loweredChoice = isObject(lowered) ? lowered.tool_choice : undefined; + const occupied = new Map(); + const register = (tool: unknown, namespace?: string): void => { + if (!isObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return; + const identity: FunctionCallRepairSchema = { + name: tool.name, + ...(namespace ? { namespace } : {}), + ...(isObject(tool.parameters) ? { parameters: tool.parameters } : {}), + }; + const key = namespacedToolName(namespace, tool.name); + if (occupied.has(key)) { + const previous = occupied.get(key); + // Conflicting duplicate declarations cannot choose a schema by insertion order. + if (!previous || previous.kind !== tool.type + || previous.identity.namespace !== namespace + || previous.identity.name !== tool.name + || !sameSchemaValue(previous.identity.parameters, identity.parameters)) { + occupied.set(key, null); + } + } else occupied.set(key, { kind: tool.type, identity }); + }; + for (const group of groups) { + for (const tool of group) { + if (!isObject(tool)) continue; + if (tool.type === "namespace") { + if (typeof tool.name !== "string" || !tool.name || !Array.isArray(tool.tools)) continue; + for (const child of tool.tools) register(child, namespaceOf(tool.name)); + } else if (tool.type === "function" && isObject(tool.function)) { + register({ ...tool.function, type: "function" }); + } else register(tool); + } + } + for (const [key, entry] of occupied) { + if (!entry || entry.kind !== "function") continue; + let allowed = choice === undefined || choice === "auto" || choice === "required"; + if (isObject(choice)) { + if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + const selectors = isObject(loweredChoice) && Array.isArray(loweredChoice.tools) ? loweredChoice.tools : []; + allowed = choice.tools.some((selector, index) => selectorAllows(selector, selectors[index], key, entry.identity)); + } else allowed = selectorAllows(choice, loweredChoice, key, entry.identity); + } + if (allowed) schemas.set(key, entry.identity); + } + return schemas; +} + +function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed: boolean): unknown { + if (!isObject(item) || item.type !== "function_call" || typeof item.name !== "string" + || typeof item.arguments !== "string") return item; + if (item.status !== "completed" && !(item.status === undefined && completed)) return item; + if ("namespace" in item && (typeof item.namespace !== "string" || !item.namespace)) return item; + const namespace = namespaceOf(item.namespace); + const schema = schemas.get(namespacedToolName(namespace, item.name)); + if (!schema) return item; + // An explicit namespace is an identity coordinate, not another spelling to guess at. + if ("namespace" in item && (schema.namespace !== namespace || schema.name !== item.name)) return item; + const raw = item.arguments; + if (raw !== "") { + try { + let unsafe = false; + JSON.parse(raw, (_key, value: unknown) => { + if (typeof value === "number" && (!Number.isFinite(value) + || (Number.isInteger(value) && !Number.isSafeInteger(value)))) unsafe = true; + return value; + }); + // Re-stringifying another repaired field must not round an unsafe sibling number. + if (unsafe) return item; + } catch { return item; } + } + const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name); + return argumentsText === raw ? item : { ...item, arguments: argumentsText }; +} + +/** Only executable completion slots are visited; metadata and custom input are opaque. */ +export function repairFunctionCalls( + value: unknown, + schemas: FunctionCallRepairSchemas, +): { value: unknown; changed: boolean } { + if (schemas.size === 0 || !isObject(value)) return { value, changed: false }; + if (typeof value.status === "string" && ["failed", "incomplete", "cancelled", "in_progress", "queued"].includes(value.status)) return { value, changed: false }; + let next: unknown = value; + if (value.type === "function_call") next = repairItem(value, schemas, false); + else if (value.type === "response.output_item.done") { + const item = repairItem(value.item, schemas, true); + if (item !== value.item) next = { ...value, item }; + } else if (value.type === "response.completed" && isObject(value.response)) { + const response = value.response; + if ((response.status === undefined || response.status === "completed") && Array.isArray(response.output)) { + const original = response.output; + const output = original.map(item => repairItem(item, schemas, true)); + if (output.some((item, index) => item !== original[index])) next = { ...value, response: { ...response, output } }; + } + } else if (typeof value.type !== "string" || !value.type.startsWith("response.")) { + if (Array.isArray(value.output)) { + const original = value.output; + const output = original.map(item => repairItem(item, schemas, value.status === "completed")); + if (output.some((item, index) => item !== original[index])) next = { ...value, output }; + } + } + return { value: next, changed: next !== value }; +} + +export function repairFunctionCallsInJson(text: string, schemas: FunctionCallRepairSchemas): string { + if (schemas.size === 0) return text; + let payload: unknown; + try { payload = JSON.parse(text); } catch { return text; } + const repaired = repairFunctionCalls(payload, schemas); + return repaired.changed ? JSON.stringify(repaired.value) : text; +} diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3088ab4c5d..24b26e8a2e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,5 +1,6 @@ -import { namespacedToolName } from "../types"; +import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; +import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -340,7 +341,10 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se * `__` wire identity as the chat adapters. The returned request-local aliases * are the only names response restoration is allowed to expand. */ -export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { +export function rewriteRoutedNamespaceToolsForUpstream( + body: unknown, + convertedCustomToolNames?: ReadonlySet, +): { body: unknown; aliases: Map; } { @@ -365,6 +369,21 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { } const toolChoice = rewriteToolChoice(body.tool_choice, plan); + const aliases = authorizedAliases(plan.aliases, toolChoice); + const ambiguousDotted = collectAmbiguousDottedAliases(groups); + // Authorize canonical identities first, then add only unambiguous spellings. + // Selection cannot hide a collision elsewhere in the original declaration set. + for (const identity of [...aliases.values()]) { + const dotted = dottedToolName(identity.namespace, identity.name); + if (dottedAliasIsUnambiguous(identity.namespace, identity.name) + && !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted) + && !aliases.has(dotted)) aliases.set(dotted, identity); + } + // The adapter lowers custom tools before namespaces. Preserve their declared + // kind only in already-authorized response aliases; wire selectors remain lowered. + for (const identity of aliases.values()) { + if (convertedCustomToolNames?.has(namespacedToolName(identity.namespace, identity.name))) identity.kind = "custom"; + } return { body: { ...body, @@ -372,7 +391,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { ...(input !== body.input ? { input } : {}), ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), }, - aliases: authorizedAliases(plan.aliases, toolChoice), + aliases, }; } @@ -404,7 +423,11 @@ export function restoreRoutedNamespaceCalls( && typeof value.name === "string" ) { const identity = aliases.get(value.name); - if (identity) { + if (identity + // Custom declarations may be lowered to function calls upstream, but an + // ordinary function declaration never authorizes a custom call payload. + && (value.type !== "custom_tool_call" || identity.kind === "custom") + && (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) { restored.name = identity.name; restored.namespace = identity.namespace; changed = true; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 460174dac2..9ed3c0d708 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -25,6 +25,7 @@ import { toolSearchDescription, toolSearchParameters } from "./tool-search-compa import { isObj, inputContentParts, outputTextOf, outputToToolResultContent, toolOutputContainsEncryptedContent } from "./parser-content"; import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools"; import { parseTextFormat } from "./parser-text-format"; +import { externalTaskInputContent } from "./task-input"; /** * Wrap a remembered proxy-side signature as provider metadata for a replayed tool call. @@ -146,6 +147,7 @@ export function parseRequest( const item = data.input[inputIndex]; const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined); const itemRole = (item as { role?: string }).role; + const externalTaskInput = effectiveType === "function_call_output" ? externalTaskInputContent(item) : undefined; // Raw protocol items do not map one-to-one onto context messages. Capture the boundary while // both representations are available so later metadata can stay before conversation in both. if ( @@ -154,6 +156,7 @@ export function parseRequest( && continuationConversationMessageIndex === undefined && ( effectiveType === "agent_message" + || externalTaskInput !== undefined || (effectiveType === "message" && (itemRole === "user" || itemRole === "assistant")) ) ) { @@ -429,6 +432,11 @@ export function parseRequest( } if (effectiveType === "function_call_output") { + if (externalTaskInput !== undefined) { + pendingReasoning.length = 0; + messages.push({ role: "user", content: externalTaskInput, timestamp: now }); + continue; + } const output = item as { call_id: string; output?: string | unknown[] }; attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning); pendingReasoning.length = 0; diff --git a/src/responses/state.ts b/src/responses/state.ts index ed39eafb27..612bf0fe6c 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -364,7 +364,10 @@ async function snapshotOnDiskMatches(path: string, payload: string, payloadBytes return false; } } -const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 }; +const spillCounters = { + writes: 0, writeFailures: 0, readFailures: 0, + aclRetryReturnedTimeouts: 0, aclTimeoutMemoRefusals: 0, +}; export type ResponseSpillWriteFailureCode = | "EACLRETRYEXHAUSTED" @@ -379,9 +382,14 @@ export type ResponseSpillWriteFailureCode = export type ResponseSpillWriteStatus = "initial" | "healthy" | "degraded"; +export type ResponseSpillWriteFailureOrigin = + | "retry_returned_timeout" + | "timeout_memo_refusal"; + interface ResponseSpillWriteHealth { consecutiveFailures: number; lastFailureCode: ResponseSpillWriteFailureCode | null; + lastFailureOrigin: ResponseSpillWriteFailureOrigin | null; lastFailureAt: number | null; lastSuccessAt: number | null; } @@ -389,6 +397,7 @@ interface ResponseSpillWriteHealth { const spillWriteHealth: ResponseSpillWriteHealth = { consecutiveFailures: 0, lastFailureCode: null, + lastFailureOrigin: null, lastFailureAt: null, lastSuccessAt: null, }; @@ -421,6 +430,20 @@ function classifySpillWriteFailure(error: unknown): ResponseSpillWriteFailureCod return "EUNKNOWN"; } +/** The spill writer preserves ACL errors in cause; only a fixed memo marker is diagnostic. */ +function spillAclMemoRefusalOrigin(error: unknown): "timeout_memo_refusal" | null { + let cursor = error; + for (let depth = 0; depth < 4 && cursor && typeof cursor === "object"; depth += 1) { + const record = cursor as { code?: unknown; aclFailureOrigin?: unknown; cause?: unknown }; + if ((record.code === "ETIMEDOUT" || record.code === "EACLRETRYEXHAUSTED") + && record.aclFailureOrigin === "timeout_memo_refusal") { + return "timeout_memo_refusal"; + } + cursor = record.cause; + } + return null; +} + function noteSpillWriteSuccess(): void { spillCounters.writes += 1; spillWriteHealth.consecutiveFailures = 0; @@ -430,11 +453,20 @@ function noteSpillWriteSuccess(): void { function noteSpillWriteFailure( error: unknown, override?: ResponseSpillWriteFailureCode, + retryOrigin: ResponseSpillWriteFailureOrigin | null = null, ): void { + const code = override ?? classifySpillWriteFailure(error); + const origin = code === "ETIMEDOUT" || code === "EACLRETRYEXHAUSTED" + ? spillAclMemoRefusalOrigin(error) ?? retryOrigin + : null; spillCounters.writeFailures += 1; spillWriteHealth.consecutiveFailures += 1; - spillWriteHealth.lastFailureCode = override ?? classifySpillWriteFailure(error); + spillWriteHealth.lastFailureCode = code; + spillWriteHealth.lastFailureOrigin = origin; spillWriteHealth.lastFailureAt = now(); + // Count terminal publications, not ACL calls or a transient first attempt. + if (origin === "retry_returned_timeout") spillCounters.aclRetryReturnedTimeouts += 1; + else if (origin === "timeout_memo_refusal") spillCounters.aclTimeoutMemoRefusals += 1; } /** * Admission-boundary observability (test-visible). directSpills: oversized @@ -666,6 +698,7 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise let committed = false; let rejectedOversized = false; let exhaustedAclRetry = false; + let aclRetryFailureOrigin: ResponseSpillWriteFailureOrigin | null = null; try { if (legacyRetirementBlocked) return; const state = spillPayloadForResident(job.id, candidate); @@ -702,6 +735,9 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise }); } catch (retryError) { exhaustedAclRetry = isAclTimeout(retryError); + // A returned timeout can also mean an exhausted budget before the next OS command. + aclRetryFailureOrigin = spillAclMemoRefusalOrigin(retryError) + ?? (exhaustedAclRetry ? "retry_returned_timeout" : null); throw retryError; } } @@ -711,7 +747,7 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise if (rejectedOversized && job.directAdmission) admissionCounters.oversizedDrops += 1; if (isRetryableSpillPublicationError(error) && !rejectedOversized) return; if (states.get(job.id) === candidate && !job.cancelled) { - noteSpillWriteFailure(error, exhaustedAclRetry ? "EACLRETRYEXHAUSTED" : undefined); + noteSpillWriteFailure(error, exhaustedAclRetry ? "EACLRETRYEXHAUSTED" : undefined, aclRetryFailureOrigin); replaceWithSpillFailure(job.id, candidate); deferSupersededSpill(job.supersededSpill); } @@ -3146,6 +3182,9 @@ export interface ResponseStateMetrics { spillWriteStatus: ResponseSpillWriteStatus; spillWriteConsecutiveFailures: number; spillLastWriteFailureCode: ResponseSpillWriteFailureCode | null; + spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null; + spillAclRetryReturnedTimeouts: number; + spillAclTimeoutMemoRefusals: number; spillLastWriteFailureAt: number | null; spillLastWriteSuccessAt: number | null; spillReadFailures: number; @@ -3198,6 +3237,9 @@ export function responseStateMetrics(): ResponseStateMetrics { : "initial", spillWriteConsecutiveFailures: spillWriteHealth.consecutiveFailures, spillLastWriteFailureCode: spillWriteHealth.lastFailureCode, + spillLastWriteFailureOrigin: spillWriteHealth.lastFailureOrigin, + spillAclRetryReturnedTimeouts: spillCounters.aclRetryReturnedTimeouts, + spillAclTimeoutMemoRefusals: spillCounters.aclTimeoutMemoRefusals, spillLastWriteFailureAt: spillWriteHealth.lastFailureAt, spillLastWriteSuccessAt: spillWriteHealth.lastSuccessAt, spillReadFailures: spillCounters.readFailures, @@ -3370,8 +3412,11 @@ export function clearResponseStateMemoryForTests(): void { spillCounters.writes = 0; spillCounters.writeFailures = 0; spillCounters.readFailures = 0; + spillCounters.aclRetryReturnedTimeouts = 0; + spillCounters.aclTimeoutMemoRefusals = 0; spillWriteHealth.consecutiveFailures = 0; spillWriteHealth.lastFailureCode = null; + spillWriteHealth.lastFailureOrigin = null; spillWriteHealth.lastFailureAt = null; spillWriteHealth.lastSuccessAt = null; replayScopeMismatchDrops = 0; diff --git a/src/responses/task-input.ts b/src/responses/task-input.ts new file mode 100644 index 0000000000..e72973ab90 --- /dev/null +++ b/src/responses/task-input.ts @@ -0,0 +1,36 @@ +import type { OcxContentPart } from "../types"; +import { inputContentParts, isObj } from "./parser-content"; + +type TaskInputBlock = + | { type: "input_text" | "output_text" | "text"; text: string } + | { type: "input_image"; image_url: string; detail?: string }; + +const imageDetails = new Set(["auto", "low", "high", "original"]); + +function nonBlank(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function supportedBlock(value: unknown): value is TaskInputBlock { + if (!isObj(value)) return false; + if (value.type === "input_text" || value.type === "output_text" || value.type === "text") { + return typeof value.text === "string"; + } + if (value.type !== "input_image" || !nonBlank(value.image_url)) return false; + return value.detail === undefined || (typeof value.detail === "string" && imageDetails.has(value.detail)); +} + +/** Recognize Codex external task input without repairing ordinary orphaned tool results. */ +export function externalTaskInputContent(item: unknown): string | OcxContentPart[] | undefined { + if (!isObj(item) || item.type !== "function_call_output" || "call_id" in item) return undefined; + if (!nonBlank(item.id) || !nonBlank(item.name) || !nonBlank(item.namespace)) return undefined; + const output = item.output; + if (typeof output === "string") return nonBlank(output) ? output : undefined; + if (!Array.isArray(output) || output.length === 0 || !output.every(supportedBlock)) return undefined; + if (!output.some(block => block.type === "input_image" || nonBlank(block.text))) return undefined; + // Validate the entire array first: the general converter intentionally drops unknown + // blocks, while a partial external task would silently lose the caller's input. + return inputContentParts(output.map(block => + block.type === "output_text" ? { ...block, type: "input_text" } : block, + )); +} diff --git a/src/responses/tool-name-aliases.ts b/src/responses/tool-name-aliases.ts new file mode 100644 index 0000000000..1943a686bf --- /dev/null +++ b/src/responses/tool-name-aliases.ts @@ -0,0 +1,79 @@ +import { dottedToolName, namespacedToolName } from "../types"; + +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * A dotted spelling is a safe alias only when it cannot ALSO be read as some other identity's + * canonical `ns__name`. + * + * `{namespace: "x__y", name: "z"}` produces the dotted spelling "x__y.z", which is exactly the + * canonical wire name of `{namespace: "x", name: "y.z"}`. If only the latter is declared, an + * echoed call for the former would still find "x__y.z" in the declared set and be authorized as + * a tool the caller never granted. Requiring both halves to be free of the `__` separator keeps + * a dotted alias from ever impersonating a canonical name. + */ +export function dottedAliasIsUnambiguous(namespace: string, name: string): boolean { + return !namespace.includes("__") && !name.includes("__"); +} + +export function wireToolInnerName(tool: unknown): string | undefined { + if (!isPlainObject(tool)) return undefined; + const nestedFunction = tool.type === "function" && isPlainObject(tool.function) + ? tool.function + : undefined; + return typeof tool.name === "string" && tool.name.length > 0 + ? tool.name + : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0 + ? nestedFunction.name + : undefined; +} + +/** + * Dotted aliases that more than one declared identity would claim, plus dotted aliases that + * collide with a canonical or bare declared name. + * + * Resolved over the WHOLE catalog before any name is registered, so which identity "wins" can + * never depend on declaration order -- an order the caller controls. + */ +export function collectAmbiguousDottedAliases(specGroups: readonly unknown[]): Set { + const owners = new Map(); + const claim = (alias: string, identity: string): void => { + const owner = owners.get(alias); + if (owner === undefined) owners.set(alias, identity); + else if (owner !== identity) owners.set(alias, null); + }; + for (const specs of specGroups) { + if (!Array.isArray(specs)) continue; + for (const spec of specs) { + if (!isPlainObject(spec)) continue; + if (spec.type === "namespace" && Array.isArray(spec.tools)) { + const namespace = typeof spec.name === "string" ? spec.name : undefined; + if (!namespace) continue; + for (const inner of spec.tools) { + const name = wireToolInnerName(inner); + if (!name) continue; + if (namespace === BUILTIN_FUNCTIONS_NAMESPACE) { + claim(name, JSON.stringify([undefined, name])); + continue; + } + const identity = JSON.stringify([namespace, name]); + claim(dottedToolName(namespace, name), identity); + // A canonical or bare name already owned by a different identity poisons the dotted + // alias that would shadow it. + claim(namespacedToolName(namespace, name), identity); + claim(name, identity); + } + continue; + } + const name = wireToolInnerName(spec); + if (name) claim(name, JSON.stringify([undefined, name])); + } + } + const ambiguous = new Set(); + for (const [alias, owner] of owners) if (owner === null) ambiguous.add(alias); + return ambiguous; +} + diff --git a/src/router.ts b/src/router.ts index 251309c0f1..cdcd951e88 100644 --- a/src/router.ts +++ b/src/router.ts @@ -11,6 +11,7 @@ import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider } from "./config/provider-name"; import { isAzureIdentityProvider } from "./config/provider-validation"; import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./providers/key-store"; +import { captureProviderApiKeySelection } from "./providers/api-key-selection"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { @@ -298,6 +299,7 @@ function usableResolvedApiKey(apiKey: string | undefined): string | undefined { } export function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { + provider = { ...provider, _apiKeyAttempt: provider._apiKeyAttempt ?? captureProviderApiKeySelection(provider) }; const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); if (!registryEntry || !providerMatchesRegistryTransportWithStaticGuards(providerName, provider)) { assertProviderDestinationAllowed(providerName, provider); diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 6aa87b4340..a98a5ff09f 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -10,7 +10,7 @@ * how that affects eligibility. */ -import { modelInList, type OcxConfig } from "../types"; +import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { serviceTierSupportForModel } from "../providers/service-tier"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -149,14 +149,20 @@ function localRemoteEvidence(baseUrl: string | undefined): Pick entry.id === providerName); + const provider = resolvedProvider ?? config.providers[providerName]; + const registryEntry = resolvedProvider === undefined + ? PROVIDER_REGISTRY.find(entry => entry.id === providerName) + : undefined; const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId); const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/"); @@ -225,6 +231,7 @@ export function candidateCapabilityEvidence( ? [] : modelRecordValue(provider?.modelReasoningEfforts, modelId) ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId) + ?? provider?.reasoningEfforts ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); const tierSupport = provider diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts index 1d543690a4..5bcef26fc8 100644 --- a/src/routing/compatibility/assemble.ts +++ b/src/routing/compatibility/assemble.ts @@ -52,11 +52,26 @@ export function assemblePolicyCandidateEvidence( return profile.candidates.map(candidate => { const key = `${candidate.provider}/${candidate.model}`; const compatibility = compatibilityByCandidate?.get(key); + const provider = config.providers[candidate.provider]; + let routed: OcxProviderConfig | undefined; + let routeResolutionFailed = !provider || provider.disabled === true; + if (provider && provider.disabled !== true) { + try { + routed = options.routedProviderConfig(candidate.provider, provider); + } catch { + // This is known unavailability, not unknown capability evidence. Keep + // the failure separate so permissive unknown policies cannot select it. + routeResolutionFailed = true; + } + } return { provider: candidate.provider, model: candidate.model, - capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model), + ...(routeResolutionFailed ? { routeResolutionFailed: true } : {}), + capability: routed + ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) + : undefined, health: policyCandidateHealthEvidence(config, candidate, now), quota: quotaEvidenceForCandidate({ provider: candidate.provider, diff --git a/src/routing/evaluator.ts b/src/routing/evaluator.ts index a07b833063..7cf801bfe9 100644 --- a/src/routing/evaluator.ts +++ b/src/routing/evaluator.ts @@ -54,6 +54,8 @@ export interface PolicyCandidateEvidence { accountRef?: string; /** Codex pool account id (provider "openai"); used to derive account-scoped quota evidence. */ codexAccountId?: string; + /** A failed effective-transport resolution excludes the candidate under every unknown policy. */ + routeResolutionFailed?: boolean; capability?: RouteCapabilityEvidence; health?: RouteHealthEvidence; quota?: RouteQuotaEvidence; @@ -278,6 +280,8 @@ export function evaluatePolicyProfile( ...requestRequirementFor(requestEvidence, evidence.capability), ]; const exclusions: RouteExclusionReason[] = []; + const routeUnavailable = evidence.routeResolutionFailed === true; + if (routeUnavailable) exclusions.push({ code: "route-unavailable" }); const bad = unsatisfiedOrUnknown(requirements); for (const requirement of bad) { if (requirement.outcome === "unsatisfied") { @@ -310,7 +314,7 @@ export function evaluatePolicyProfile( if (unknownCostBlocked) { exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" }); } - let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; + let eligible = !routeUnavailable && !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; // Trace/dry-run copy only: report the profile cap that was applied and the // operator-visible outcome. Do not feed this copy into costScore() — that diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 67a77ddef0..ea88384a2c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -548,6 +548,7 @@ export function requireResponsesApiAuth(req: Request, config: RequestPolicyView) const FORBIDDEN_PROVIDER_RUNTIME_FIELDS = [ "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "_codexAccountOverride", "_codexAccountRequired", + "_apiKeyAttempt", ] as const; function sameCanonicalProviderSeed(actual: Record, expected: OcxProviderConfig): boolean { @@ -843,6 +844,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { azureCredential: "redacted", apiKeyTransport: "editor", apiKeyPool: "redacted", + apiKeySelectionRevision: "runtime", + _apiKeyAttempt: "runtime", defaultModel: "editor", models: "editor", liveModels: "editor", diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index ef0c3ddead..58e5f31401 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -47,6 +47,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native"; +import { jsonCompletionSse } from "./chat-native-sse"; import { parseRequestEffortRowId } from "./effort-row"; import { parseSyntheticRowId } from "./fast-row"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; @@ -80,6 +81,14 @@ export async function handleChatCompletions( ); } catch (error) { translatorBudget.dispose(); + if (isTranslatorBudgetExceededError(error)) { + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 502, { closeReason: "non_stream" }); + return chatCompletionsErrorResponse(502, "upstream translation buffer exceeded the safe limit", "upstream_error", "translation_buffer_limit"); + } + if (isChatCompletionsStreamError(error)) { + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, error.status, { closeReason: "non_stream" }); + return chatCompletionsErrorResponse(error.status, error.message, error.type, error.code); + } throw error; } } @@ -281,7 +290,7 @@ async function handleChatCompletionsWithBudget( }); let nativeLogged = false; - const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" }) => { + const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" | "non_stream" }) => { if (!logIds || nativeLogged) return; nativeLogged = true; addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); @@ -382,11 +391,14 @@ async function handleChatCompletionsWithBudget( : rewritten; } - const response = logIds + const contentType = upstream.headers.get("content-type") ?? ""; + // JSON is not complete for the client until its Chat projection succeeds. + // Logging the upstream JSON body here would persist 200 before a later + // conversion/serialization error, double-counting both the request and usage. + const response = logIds && contentType.includes("text/event-stream") ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; - const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("text/event-stream") && response.body) { const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel, { translatorBudget }); if (stream) { @@ -420,11 +432,15 @@ async function handleChatCompletionsWithBudget( } // Defensive: JSON despite stream:true. + const finishJson = (result: Response): Response => { + finalizeNativeLog(result.status, { closeReason: "non_stream" }); + return result; + }; let json: unknown; try { json = await response.json(); } catch { - return chatCompletionsErrorResponse(502, "internal replay returned a non-JSON response", "server_error"); + return finishJson(chatCompletionsErrorResponse(502, "internal replay returned a non-JSON response", "server_error")); } const status = (json as Rec)?.status; if (status === "failed") { @@ -442,44 +458,24 @@ async function handleChatCompletionsWithBudget( classified.code = "model_not_found"; classified.type = "invalid_request_error"; } - return chatCompletionsErrorResponse( + return finishJson(chatCompletionsErrorResponse( classified.code === "translation_buffer_limit" ? 502 : isCyberPolicyCode(classified.code) ? 400 : 502, message, classified.type, classified.code, - ); + )); } - const completion = responsesJsonToChatCompletion(json, requestedModel); - if (!stream) { - return new Response(JSON.stringify(completion), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - // Streaming client + JSON upstream: synthesize a minimal Chat Completions stream. - const encoder = new TextEncoder(); - const id = typeof completion.id === "string" ? completion.id : `chatcmpl-${Date.now()}`; - const created = typeof completion.created === "number" ? completion.created : Math.floor(Date.now() / 1000); - const message = isRec((completion.choices as Rec[] | undefined)?.[0]) - ? ((completion.choices as Rec[])[0] as Rec).message as Rec | undefined - : undefined; - const content = message && typeof message.content === "string" ? message.content : ""; - const frames = [ - `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: requestedModel, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }] })}\n\n`, - ...(content - ? [`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: requestedModel, choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\n`] - : []), - `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: requestedModel, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: completion.usage })}\n\n`, - "data: [DONE]\n\n", - ]; - return new Response(encoder.encode(frames.join("")), { + const completion = responsesJsonToChatCompletion(json, requestedModel, translatorBudget); + const body = stream + ? jsonCompletionSse(completion, requestedModel, translatorBudget) + : JSON.stringify(completion); + if (!stream) translatorBudget.chargeRetained(Buffer.byteLength(body) * 2, { kind: "live_transient" }); + return finishJson(new Response(body, { status: 200, - headers: { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache", - }, - }); + headers: stream + ? { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive" } + : { "Content-Type": "application/json" }, + })); } diff --git a/src/server/chat-native-sse.ts b/src/server/chat-native-sse.ts index fa9255369c..0d80733057 100644 --- a/src/server/chat-native-sse.ts +++ b/src/server/chat-native-sse.ts @@ -61,7 +61,7 @@ function normalizedChunk(value: Rec, requestedModel: string): Rec { }; } -export function jsonCompletionSse(value: Rec, requestedModel: string): string { +export function jsonCompletionSse(value: Rec, requestedModel: string, budget?: TranslatorBudget): string { const id = typeof value.id === "string" ? value.id : `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; const created = typeof value.created === "number" ? value.created : Math.floor(Date.now() / 1000); const model = requestedModel; @@ -77,11 +77,12 @@ export function jsonCompletionSse(value: Rec, requestedModel: string): string { }]; const delta: Rec = {}; if (typeof message.content === "string" && message.content.length > 0) delta.content = message.content; + if (typeof message.refusal === "string") delta.refusal = message.refusal; if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) { delta.reasoning_content = message.reasoning_content; } if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { - delta.tool_calls = message.tool_calls.map((tool, index) => isRec(tool) ? { index, ...tool } : tool); + delta.tool_calls = message.tool_calls.filter(isRec).map((tool, index) => ({ ...tool, index })); } if (Object.keys(delta).length > 0) { frames.push({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: null }] }); @@ -94,7 +95,26 @@ export function jsonCompletionSse(value: Rec, requestedModel: string): string { choices: [{ index: 0, delta: {}, finish_reason: typeof choice.finish_reason === "string" ? choice.finish_reason : "stop" }], ...(value.usage !== undefined ? { usage: value.usage } : {}), }); - return `${frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("")}data: [DONE]\n\n`; + // Keep the frame strings charged while the joined body is allocated. The final + // string and Response's UTF-8 body coexist until response ownership ends. + const scope = { kind: "live_transient" as const }; + let frameBytes = 0; + const serialized: string[] = []; + try { + for (const frame of frames) { + const text = `data: ${JSON.stringify(frame)}\n\n`; + const bytes = Buffer.byteLength(text); + budget?.chargeRetained(bytes, scope); + frameBytes += bytes; + serialized.push(text); + } + const done = "data: [DONE]\n\n"; + const outputBytes = frameBytes + Buffer.byteLength(done); + budget?.chargeRetained(outputBytes * 2, scope); + return serialized.join("") + done; + } finally { + budget?.releaseRetained(frameBytes, scope); + } } interface NativeChatSseOptions { diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 32f3daea8a..49e4beb61a 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -37,6 +37,8 @@ import { transientRetryPolicyFor, } from "../providers/key-failover"; import { fastPolicyForModel } from "../providers/service-tier"; +import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection"; +import type { OcxProviderTransport } from "../providers/xai-transport"; import type { RouteResult } from "../router"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers"; @@ -46,6 +48,7 @@ import { beginRequestAttempt, noteAttemptSend, recordFirstOutput, + recordAttemptCredentialSource, sealRequestAttemptIdentity, type RequestLogContext, } from "./request-log"; @@ -183,14 +186,17 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio translatorBudget.chargeRetained(bytes, { kind: "request_copies" }); retainedRequestBytes = bytes; }; - const buildActiveRequest = () => buildOpenAIChatPassthroughRequest( - activeProvider, - options.chatBody, - route.modelId, - requestedStream, - fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"), - config.fastMode, - ); + const buildActiveRequest = () => { + recordAttemptCredentialSource(attempt, route.providerName, activeProvider, activeAdapter.name); + return buildOpenAIChatPassthroughRequest( + activeProvider, + options.chatBody, + route.modelId, + requestedStream, + fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"), + config.fastMode, + ); + }; try { activeRequest = buildActiveRequest(); retainRequest(activeRequest); @@ -224,7 +230,6 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio const fetchWithPolicy = requestTransientPolicy ? fetchWithTransientRetry : fetchWithResetRetry; return await fetchWithPolicy( (transportRecovery?: UpstreamSendRecovery) => { - noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery); return fetchWithHeaderTimeout( request.url, applyUpstreamRecoveryInit({ @@ -238,6 +243,32 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio providerFetch(activeProvider, undefined, { providerName: route.providerName, modelId: route.modelId, + dispatchOverride: async (_input, init, execute) => { + if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) { + const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider); + if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, options.chatBody)) { + throw new Error("Provider key selection is no longer available for native Chat"); + } + activeProvider = current; + activeAdapter = createOpenAIChatAdapter(current); + activeRequest.releaseBodyObservation?.(); + releaseRetainedRequest(); + activeRequest = buildActiveRequest(); + try { retainRequest(activeRequest); } + catch (error) { activeRequest.releaseBodyObservation?.(); throw error; } + } + // The retry closure may still hold a pre-pacing request. Replace its entire + // wire shape, not just Authorization, and retain transport recovery flags. + request = activeRequest; + const headers = new Headers(request.headers); + const encoding = new Headers(init.headers).get("accept-encoding"); + if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding); + if (init.signal?.aborted) throw init.signal.reason; + noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery); + return ((activeProvider as OcxProviderTransport).fetch ?? execute)(request.url, applyUpstreamRecoveryInit({ + ...init, method: request.method, headers, body: request.body, + }, transportRecovery)); + }, }), ); }, @@ -282,6 +313,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio retryAfter: response.headers.get("retry-after"), now: Date.now(), attemptedKey: activeProvider.apiKey, + attemptedSelection: activeProvider._apiKeyAttempt, promptCacheKey: typeof options.chatBody.prompt_cache_key === "string" ? options.chatBody.prompt_cache_key : undefined, }); if (!rotated) break; @@ -302,6 +334,9 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio cleanupAbort(); upstream.abort(); if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled"); + if (isTranslatorBudgetExceededError(error)) { + return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } return fail(502, error instanceof Error ? error.message : String(error), "server_error"); } releaseRetainedRequest(); @@ -467,15 +502,22 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio attempt.usage = usage; } if (logIds) recordFirstOutput(logCtx, logIds.start); - finishLog(200); - if (requestedStream) { - return new Response(jsonCompletionSse(completion, requestedModel), { + try { + const serialized = requestedStream + ? jsonCompletionSse(completion, requestedModel, translatorBudget) + : JSON.stringify(completion); + if (!requestedStream) translatorBudget.chargeRetained(Buffer.byteLength(serialized) * 2, { kind: "live_transient" }); + finishLog(200); + return new Response(serialized, { status: 200, - headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" }, + headers: requestedStream + ? { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" } + : { "Content-Type": "application/json" }, }); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) { + return fail(502, "upstream translation buffer exceeded the safe limit", "upstream_error", "translation_buffer_limit"); + } + throw error; } - return new Response(JSON.stringify(completion), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); } diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index cc7bb03c06..20bc14e195 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -10,21 +10,15 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { sseFieldValue } from "../lib/sse-decoder"; import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; -import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, verifyAndExtractDirectives, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; -import { getOrCreateDirectiveSigningKey } from "../claude/directive-key"; -import { isAllowedLegacyDirective } from "../claude/agents-inject"; -import { resolveDesktop3pAlias } from "../claude/desktop-3p"; +import { AnthropicRequestError, DesktopModelMappingUnavailableError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; +import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; +import { resolveAlias, claudeCodeNativeAlias } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; -import { annotateClaudeInboundDecision, captureClaudeInbound } from "../claude/inbound-debug"; +import { captureClaudeInbound } from "../claude/inbound-debug"; +import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; -import { createHash } from "node:crypto"; -import { - analyzeClaudeCompatibility, - collectClaudeFeatureCodes, - resolveClaudeCompatibilityMode, -} from "../claude/compatibility"; import { anthropicErrorBody, anthropicErrorResponse, @@ -34,8 +28,10 @@ import { } from "../claude/outbound"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; -import { modelInList } from "../types/tools"; -import type { ClaudeSourceEnvelope, OcxConfig, OcxUsage } from "../types"; +import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; +import { evidenceFromBody } from "../routing/request-evidence"; +import { resolveWireProtocolOverride } from "./adapter-resolve"; +import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; import { conversationIdFromClaudeMetadata } from "./request-log-conversation"; @@ -66,47 +62,9 @@ import { parseSyntheticRowId, type ParsedFastRowId, } from "./fast-row"; -import { supportedLadderFor } from "./effort-policy"; -import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; -import { POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile"; -import { evidenceFromBody } from "../routing/request-evidence"; type Rec = Record; -function resolveClaudePolicySelector( - config: OcxConfig, - model: string, -): { decodedModel: string; isPolicy: boolean } { - // Claude Code sends the readable aliases published by /v1/models, not necessarily the - // underlying route (`claude-ocx-policy--daily` -> `policy/daily`). Policy detection must - // use the same identity the Messages translator will route, while preserving an exact - // operator alias such as `claude-smart` when it has no Claude model-map entry. - const decodedModel = resolveInboundModel(model, config.claudeCode); - return { - decodedModel, - isPolicy: resolvePolicyProfileId(config, decodedModel) !== null - || decodedModel.startsWith(`${POLICY_NAMESPACE}/`), - }; -} - -function isLocalPolicyRoutingError( - status: number, - message: string, - logCtx: Pick, -): boolean { - if (status !== 404) return false; - if ( - logCtx.routeDecision?.routeKind === "policy" - && logCtx.routeDecision.selected.reason === "no-eligible-candidate" - ) { - return true; - } - return ( - logCtx.requestedModel?.startsWith(`${POLICY_NAMESPACE}/`) === true - && message.startsWith("Unknown routing policy:") - ); -} - /** * Decode a Claude selector that may carry the fast marker. * @@ -116,17 +74,37 @@ function isLocalPolicyRoutingError( * resolve a synthetic one. */ function decodeClaudeFastSelector(raw: string, cc?: OcxConfig["claudeCode"]): string { - const exact = resolveInboundModel(raw, cc); - if (exact !== raw || !raw.endsWith("--fast")) return exact; - const bare = raw.slice(0, -"--fast".length); + const model = stripOneMillionMarker(raw); + const exact = resolveInboundModel(model, cc); + if (!model.endsWith("--fast")) return exact; + const fullMapping = cc?.modelMap?.[model]; + if (resolveAlias(model) || isKnownDesktop3pModelId(model) + || (typeof fullMapping === "string" && fullMapping.length > 0)) return exact; + const bare = model.slice(0, -"--fast".length); + // A classifier fallback is not an exact match for a registered Desktop base. + // Preserve established non-Desktop fallback behavior while decoding that base first. + if (exact !== model && !resolveDesktop3pAlias(bare)) return exact; const decodedBase = resolveInboundModel(bare, cc); return decodedBase === bare ? exact : `${decodedBase}--fast`; } +/** Restore the reversible Fable picker alias before Anthropic passthrough checks. */ +function decodeFablePickerAlias(raw: string, cc?: OcxConfig["claudeCode"]): string { + const decoded = resolveInboundModel(raw, cc); + if (!decoded.startsWith("claude-fable-")) return raw; + return claudeCodeNativeAlias(decoded) === raw ? decoded : raw; +} + function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); } +function desktopMappingUnavailableResponse(error: DesktopModelMappingUnavailableError): Response { + const response = anthropicErrorResponse(503, error.message, "api_error", "desktop_model_mapping_unavailable"); + response.headers.set("Retry-After", "1"); + return response; +} + /** Resolve Claude-only sidecar overrides without mutating the shared server config. */ export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig { return { @@ -142,25 +120,6 @@ export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig { }; } -/** - * Phase 4 plan 04-02: benchmark-only raw-usage observation. - * - * The optional benchmark observer receives a sanitized structural record only — - * final adapter kind, resolved model id, and the raw OcxUsage reported before - * Anthropic wire normalization. It never receives request bodies, headers, - * provider names/aliases, endpoint, account identity, raw provider response, or - * error text. Standard Messages behavior is unchanged when omitted. - */ -export interface ClaudeBenchmarkRawUsage { - adapterKind: string; - modelId: string; - usage: OcxUsage | undefined; -} - -export interface ClaudeBenchmarkObserverOptions { - onRawUsage?: (observation: ClaudeBenchmarkRawUsage) => void; -} - function claudeInboundDisabled(config: OcxConfig): Response | null { if (config.claudeCode?.enabled === false) { return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error"); @@ -168,149 +127,6 @@ function claudeInboundDisabled(config: OcxConfig): Response | null { return null; } -// ── Claude source envelope & session precedence (ingress slice) ──────────────── - -/** - * Capture sanitized immutable envelope before destructive translation. - * Charges the same TranslatorBudget for the retained copy + header bytes. - */ -export function captureClaudeSourceEnvelope( - req: Request, - rawBody: unknown, - budget: TranslatorBudget, -): ClaudeSourceEnvelope { - const beta = req.headers.get("anthropic-beta")?.trim() || undefined; - const rawVersion = req.headers.get("anthropic-version"); - const version = rawVersion === null ? undefined : rawVersion.trim(); - if (!isRec(rawBody)) throw new AnthropicRequestError("Anthropic request body must be an object"); - const bodyClone = structuredClone(rawBody); - const bodyBytes = new TextEncoder().encode(JSON.stringify(bodyClone)).byteLength; - let headerBytes = 0; - if (beta) headerBytes += new TextEncoder().encode(beta).byteLength; - if (version) headerBytes += new TextEncoder().encode(version).byteLength; - budget.chargeRetained(bodyBytes + headerBytes, { kind: "request_copies" }); - return { - body: bodyClone, - headers: { - ...(beta ? { "anthropic-beta": beta } : {}), - ...(version !== undefined ? { "anthropic-version": version } : {}), - }, - }; -} - -/** - * Session precedence: x-claude-code-session-id header > metadata.user_id > system cohort. - * Returns the canonical session id string or null when none is determinable before translation. - * Agent/parent IDs are NOT session ids — they are only HMAC8 debug tags (see inbound-debug). - */ -export function claudeSessionIdFromRequest(req: Request, body: unknown): string | null { - const headerSid = req.headers.get("x-claude-code-session-id")?.trim(); - if (headerSid) return headerSid; - if (body && typeof body === "object" && !Array.isArray(body)) { - const rec = body as Record; - const metadata = rec.metadata; - if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { - const uid = (metadata as Record).user_id; - if (typeof uid === "string" && uid.trim().length > 0) return uid.trim(); - } - } - return null; -} - -function claudeAgentIdsFromRequest(req: Request): { agentId?: string; parentAgentId?: string } { - const out: { agentId?: string; parentAgentId?: string } = {}; - const headerAgent = req.headers.get("x-claude-code-agent-id")?.trim(); - const headerParent = req.headers.get("x-claude-code-parent-agent-id")?.trim(); - if (headerAgent) out.agentId = headerAgent; - if (headerParent) out.parentAgentId = headerParent; - return out; -} - -/** - * Compute prompt_cache_key from a session id (header precedence path). - * Uses same sha256 hex slice as inbound.ts (32 hex chars) for per-session keys. - * Returns null when sessionId is null (caller should keep translation's system cohort key). - */ -export function promptCacheKeyForSession(sessionId: string | null): string | null { - if (!sessionId) return null; - return createHash("sha256").update(sessionId).digest("hex").slice(0, 32); -} - -/** Idempotent, synchronous work for every effective Responses route resolution. */ -export function claudeFinalRouteHandler( - parsed: { options: Record; modelId: string; _rawBody?: unknown; _promptCacheKeyIsSharedCohort?: boolean }, - route: { provider: OcxConfig["providers"][string]; providerName: string; modelId: string }, - ctx: { - sourceEnvelope: ClaudeSourceEnvelope; - cacheKeySource: ClaudeCacheKeySource; - config: OcxConfig; - logCtx: RequestLogContext; - }, -): { adapter: string; decision: ReturnType["decision"]; featureCodes: string[] } { - const adapter = route.provider.adapter; - // Route-aware fail-closed gate for auto-only models (e.g. muse-spark-1.2-contributor on opencode-go). - // Must run before any upstream; uses the routed modelId + provider list so a forced/named - // tool_choice never silently downgrades to auto (Responses core would do that for openai-chat). - { - const tc = (ctx.sourceEnvelope.body as Record).tool_choice as unknown; - const tcType = tc && typeof tc === "object" && !Array.isArray(tc) && typeof (tc as Record).type === "string" ? (tc as Record).type as string : undefined; - if ((tcType === "any" || tcType === "tool") && modelInList((route.provider as { autoToolChoiceOnlyModels?: string[] }).autoToolChoiceOnlyModels, route.modelId)) { - throw new AnthropicRequestError( - `tool_choice type '${tcType}' is not supported for model "${route.modelId}" (provider ${route.providerName}): this model supports only tool_choice auto or none. Remove tool_choice, use {"type":"auto"} or {"type":"none"}, or choose a different model.`, - ); - } - } - // Idempotent sampling strip for openai-responses forward (native ChatGPT pierce) - if (adapter === "openai-responses") { - const raw = parsed._rawBody as Record | undefined; - if (raw) { - // _rawBody is snake_case Responses JSON; be idempotent for both snake and camel keys - delete raw.max_output_tokens; - delete (raw as Record).maxOutputTokens; - delete raw.temperature; - delete raw.top_p; - delete (raw as Record).topP; - delete raw.stop; - delete (raw as Record).stopSequences; - delete raw.user; - } - // OcxRequestOptions is camelCase; _rawBody snake is already handled above. Strip both forms idempotently. - delete (parsed.options as Record).max_output_tokens; - delete (parsed.options as Record).maxOutputTokens; - delete (parsed.options as Record).temperature; - delete (parsed.options as Record).top_p; - delete (parsed.options as Record).topP; - delete (parsed.options as Record).stop; - delete (parsed.options as Record).stopSequences; - delete (parsed.options as Record).user; - } - // Usage estimate only for cursor/kiro (estimated-usage adapters) - if (adapter === "cursor" || adapter === "kiro") { - try { - const bodyRec = ctx.sourceEnvelope.body as Record; - const model = typeof bodyRec.model === "string" ? bodyRec.model : ctx.config.claudeCode?.model; - ctx.logCtx.usageLogInputTokens = estimateClaudeRequestTokens(bodyRec as { system?: unknown; messages?: unknown; tools?: unknown }, model); - } catch { - // ignore estimation failures - } - } - // Opus-shaped aliases can make every routed model look reasoning-capable to Claude - // clients. Strip a forced effort only when the final route explicitly has no ladder. - if (parsed.options.reasoning !== undefined) { - const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); - if (ladder !== undefined && ladder.length === 0) delete parsed.options.reasoning; - } - // Compatibility evaluation before network (enforce mode may reject) - const mode = resolveClaudeCompatibilityMode(ctx.config.claudeCode); - const anthropicBeta = ctx.sourceEnvelope.headers["anthropic-beta"]; - const result = analyzeClaudeCompatibility(ctx.sourceEnvelope.body, { mode, adapter, anthropicBeta }); - if (result.decision === "reject") { - throw new AnthropicRequestError(result.reason ?? "incompatible features for routed adapter"); - } - return { adapter, decision: result.decision, featureCodes: result.featureCodes }; -} - - async function readAnthropicBody(req: Request, budget: TranslatorBudget): Promise { try { return await readJsonRequestBody(req, budget); @@ -801,12 +617,11 @@ export async function handleClaudeMessages( logCtx: RequestLogContext, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, - benchmark?: ClaudeBenchmarkObserverOptions, ): Promise { const translatorBudget = createTranslatorBudget(); try { return finalizeTranslatorBudgetResponse( - await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy, benchmark), + await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy), translatorBudget, ); } catch (error) { @@ -822,7 +637,6 @@ async function handleClaudeMessagesWithBudget( translatorBudget: TranslatorBudget, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, - benchmark?: ClaudeBenchmarkObserverOptions, ): Promise { logCtx.surface = "claude"; const disabled = claudeInboundDisabled(config); @@ -838,11 +652,6 @@ async function handleClaudeMessagesWithBudget( let effortRow: ParsedEffortRowId | null = null; let fastRow: ParsedFastRowId | null = null; let requestedModel = ""; - let sourceEnvelope: ClaudeSourceEnvelope | null = null; - let headerSessionId: string | null = null; - let featureCodesEarly: string[] = []; - let agentIds: { agentId?: string; parentAgentId?: string } = {}; - let debugCaptureId: number | undefined; try { anthropicBody = await readAnthropicBody(req, translatorBudget); // Defensive [1m] strip (devlog 138): clients normally remove the context-variant @@ -851,23 +660,20 @@ async function handleClaudeMessagesWithBudget( if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { anthropicBody.model = stripOneMillionMarker(anthropicBody.model); } - // ocx-route override (devlog 072 + TRUST-01..05): injected agent bodies pin their model via a + // ocx-route override (devlog 072): injected agent bodies pin their model via a // system-prompt directive because 2.1.207 ignores custom ids in agent // frontmatter. Must run BEFORE the native-passthrough branch — the CLI sends // these subagent turns under a fallback claude model id. if (isRec(anthropicBody)) { - const directives = verifyAndExtractDirectives( - anthropicBody, - getOrCreateDirectiveSigningKey(), - (route, effort) => isAllowedLegacyDirective(route, effort, config), - ); - if (directives.route && typeof anthropicBody.model === "string") { - anthropicBody.model = stripOneMillionMarker(directives.route); - if (directives.effort) { - effortOverride = directives.effort; - } + const routeOverride = extractOcxRouteDirective(anthropicBody); + if (routeOverride && typeof anthropicBody.model === "string") { + anthropicBody.model = stripOneMillionMarker(routeOverride); + effortOverride = extractOcxEffortDirective(anthropicBody); } } + if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + anthropicBody.model = decodeFablePickerAlias(anthropicBody.model, config.claudeCode); + } if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { requestedModel = anthropicBody.model; // Decode for Fast only. A Claude alias is `claude-ocx---`, so it @@ -885,25 +691,15 @@ async function handleClaudeMessagesWithBudget( } if (fastRow) anthropicBody.model = fastRow.baseId; } - headerSessionId = claudeSessionIdFromRequest(req, anthropicBody); - agentIds = claudeAgentIdsFromRequest(req); - featureCodesEarly = collectClaudeFeatureCodes(anthropicBody, req.headers.get("anthropic-beta") ?? undefined); // Debug capture (opt-in allowlist scalars) BEFORE the passthrough branch so // native, routed, and disabled-alias paths are all observable (devlog 130 B1). - // Extended ring carries featureCodes/adapter/decision + HMAC8 session/agent tags. - debugCaptureId = captureClaudeInbound( + captureClaudeInbound( "messages", anthropicBody, isRec(anthropicBody) && typeof anthropicBody.model === "string" ? resolveInboundModel(anthropicBody.model, config.claudeCode) : undefined, req.headers.get("anthropic-beta") ?? undefined, - { - ...(headerSessionId ? { sessionId: headerSessionId } : {}), - ...(agentIds.agentId ? { agentId: agentIds.agentId } : {}), - ...(agentIds.parentAgentId ? { parentAgentId: agentIds.parentAgentId } : {}), - ...(featureCodesEarly.length > 0 ? { featureCodes: featureCodesEarly } : {}), - }, ); // Client surface discrimination: Desktop 3P aliases resolve through the // desktop registry; Code uses readable aliases or direct model names. @@ -921,19 +717,35 @@ async function handleClaudeMessagesWithBudget( // A fast row blocks passthrough, unlike the chat case: this path forwards to Anthropic's // own API, whose wire has no service_tier field and whose FastWire kind has an empty // adapter set by design, so the tier would be silently dropped. - const policySelector = isRec(anthropicBody) && typeof anthropicBody.model === "string" - ? resolveClaudePolicySelector(config, anthropicBody.model) - : null; - if ( - !effortRow - && !fastRow - && policySelector?.isPolicy !== true - && isRec(anthropicBody) - && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model) - ) { - annotateClaudeInboundDecision(debugCaptureId, "anthropic", "native", featureCodesEarly); + if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } + // Capture source semantics before effort rewriting or translation drops fields. + // This policy is uniform across translated targets, including later fallback attempts. + const compatibilityMode: unknown = config.claudeCode?.compatibility; + if (compatibilityMode !== undefined) { + if (!isClaudeCompatibilityMode(compatibilityMode)) { + logCtx.errorCode = "claude_compatibility_configuration"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 503, { closeReason: "non_stream" }); + return anthropicErrorResponse(503, "Invalid claudeCode.compatibility setting", "api_error"); + } + const compatibility = analyzeClaudeCompatibility(anthropicBody, { + mode: compatibilityMode, + anthropicBeta: req.headers.get("anthropic-beta") ?? undefined, + }); + if (compatibility.decision === "reject") { + logCtx.errorCode = "claude_compatibility_unsupported"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return anthropicErrorResponse(400, compatibility.reason!, "invalid_request_error"); + } + if (compatibility.decision === "shadow") { + logCtx.claudeCompatibility = { + decision: "shadow", + featureCodes: compatibility.featureCodes, + reason: compatibility.reason, + }; + } + } if (isRec(anthropicBody) && effortOverride) { anthropicBody.output_config = { ...(isRec(anthropicBody.output_config) ? anthropicBody.output_config : {}), @@ -941,9 +753,6 @@ async function handleClaudeMessagesWithBudget( }; delete anthropicBody.thinking; } - // Routed requests retain the post-directive source body. Native passthrough never - // pays for or observes this clone, preserving its existing byte-for-byte path. - sourceEnvelope = captureClaudeSourceEnvelope(req, anthropicBody, translatorBudget); const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode); internalBody = translation.body; // The Anthropic translator builds its body from model/input/store/stream plus sampling @@ -951,26 +760,13 @@ async function handleClaudeMessagesWithBudget( // inbound one. if (fastRow) internalBody.service_tier = "priority"; translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" }); - // Session header precedence feeds prompt_cache_key (header > metadata > system cohort). - // When the x-claude-code-session-id header is present, it replaces the - // translation's per-session/system key with a stable per-header sha256 key - // and promotes cacheKeySource to "metadata" so downstream affinity/session_id - // logic treats it as a real per-session key (not the shared system cohort). - if (headerSessionId) { - const sessionCacheKey = promptCacheKeyForSession(headerSessionId); - if (sessionCacheKey) { - internalBody.prompt_cache_key = sessionCacheKey; - cacheKeySource = "metadata"; - } else { - cacheKeySource = translation.cacheKeySource; - } - } else { - cacheKeySource = translation.cacheKeySource; - } + cacheKeySource = translation.cacheKeySource; } catch (err) { const overflow = isTranslatorBudgetExceededError(err); - const status = overflow ? 413 : err instanceof AnthropicRequestError ? 400 : 500; + const unavailable = err instanceof DesktopModelMappingUnavailableError; + const status = overflow ? 413 : unavailable ? 503 : err instanceof AnthropicRequestError ? 400 : 500; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" }); + if (unavailable) return desktopMappingUnavailableResponse(err); return anthropicErrorResponse( status, overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err), @@ -985,33 +781,54 @@ async function handleClaudeMessagesWithBudget( // the translated Anthropic SSE into a message JSON for non-streaming clients. internalBody.stream = true; - // ── Final-route callback (post-route, pre-network) ─────────────────────────────────────── - // Adapter-specific work runs only after Responses core owns the final route. - const claudeOnResolvedRoute = (info: import("./responses/core").ResolvedRouteInfo): void => { - if (!sourceEnvelope) return; - const result = claudeFinalRouteHandler( - info.parsed as unknown as Parameters[0], - { provider: { ...info.provider, adapter: info.adapterName }, providerName: info.route.providerName, modelId: info.modelId } as Parameters[1], - { - sourceEnvelope, - cacheKeySource, - config, - logCtx, - }, - ); - // Session_id header synthesis: only for openai-responses and only for a - // real per-session prompt_cache_key (metadata), never the system-hash - // cohort. Idempotent: check headers.has before set. - { - const pck = (info.parsed.options as Rec).promptCacheKey ?? (info.parsed.options as Rec).prompt_cache_key ?? ((info.parsed as unknown as Rec)._rawBody as Rec | undefined)?.prompt_cache_key; - if (info.adapterName === "openai-responses" && cacheKeySource === "metadata" && typeof pck === "string" && !info.headers.has("session_id")) { - info.headers.set("session_id", uuidFromHex(pck as string)); - } else if (info.adapterName !== "openai-responses") { - info.headers.delete("session_id"); - } + // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped + // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", + // verified live 2026-07-11). Strip them for that route; routed providers keep them. + let nativeRoute = false; + try { + const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); + // Settle the wire once so the sampling decision below reads the effective + // adapter rather than the provider-wide default (#404). + route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); + logCtx.routeDecision = route.routeDecision; + if (route.provider.adapter === "openai-responses") { + nativeRoute = true; + delete internalBody.max_output_tokens; + delete internalBody.temperature; + delete internalBody.top_p; + delete internalBody.stop; + delete internalBody.user; } - annotateClaudeInboundDecision(debugCaptureId, result.adapter, result.decision, result.featureCodes); - }; + // Estimated-usage adapters (cursor/kiro) report no per-turn input tokens; stash a + // request-side estimate so the log's in:0 rows get a floor. NEVER set this for + // accurate-usage adapters — the request-log merge is max(reported, estimate) and + // would overwrite real usage (audit 133 R1#7). + if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") { + logCtx.usageLogInputTokens = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel); + } + // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make + // every routed model look like a reasoning model to Claude clients, so a forced + // effort (CLAUDE_CODE_ALWAYS_ENABLE_EFFORT) would leak reasoning params to routes + // that affirmatively expose NO effort control. Strip only on a definitive [] from + // supportedLadderFor; unknown (undefined) passes through untouched. + if (internalBody.reasoning !== undefined) { + const { supportedLadderFor } = await import("./effort-policy"); + const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); + if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning; + } + } catch (err) { + if (err instanceof UnknownRoutingPolicyError) { + logCtx.requestedModel = requestedModel; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); + return anthropicErrorResponse(404, err.message, "invalid_request_error"); + } + if (err instanceof NoEligiblePolicyCandidateError) { + logCtx.routeDecision = err.trace; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); + return anthropicErrorResponse(404, err.message, "invalid_request_error"); + } + /* unknown model: let handleResponses shape the 404 */ + } const headers = new Headers({ "content-type": "application/json" }); for (const name of FORWARD_HEADERS) { @@ -1033,6 +850,18 @@ async function handleClaudeMessagesWithBudget( headers.set("chatgpt-account-id", token.chatgptAccountId); } } + if (nativeRoute) { + // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex + // clients always send their session uuid; devlog 090 follow-up: body-level + // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends + // the header, so synthesize a stable per-session uuid from the same cache key — + // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback + // key is shared across Desktop conversations, and a shared session_id's backend + // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there. + if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") { + headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); + } + } const internalBodyJson = JSON.stringify(internalBody); translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" }); const internalReq = new Request("http://localhost/v1/responses", { @@ -1064,18 +893,9 @@ async function handleClaudeMessagesWithBudget( inboundWire: "anthropic", stripClaudeMainAuthForNoncanonicalForward: true, translatorBudget, - // Forward the sanitized immutable source envelope (charged to the same budget) - // so core can perform fidelity-preserving work. The envelope is the - // pre-translation clone (body + anthropic-beta/version headers only). - ...(sourceEnvelope ? { claudeSourceEnvelope: sourceEnvelope } : {}), - // Adapter-aware final-route gate: idempotent strip / session_id synthesis / - // usage estimate / compatibility enforce. Core should invoke this after - // each routeModel+resolveWireProtocolOverride (see note above). - onResolvedRoute: claudeOnResolvedRoute, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), - ...(benchmark?.onRawUsage ? { claudeBenchmarkObserver: benchmark.onRawUsage } : {}), }); const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; @@ -1114,10 +934,7 @@ async function handleClaudeMessagesWithBudget( && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE; const transient = !nativeMainFence && isTransientUpstreamStatus(response.status); const outStatus = nativeMainFence ? 503 : transient ? 529 : response.status; - const errorType = isLocalPolicyRoutingError(response.status, message, logCtx) - ? "invalid_request_error" - : undefined; - const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message, errorType)), { + const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), { status: outStatus, headers: { "Content-Type": "application/json", @@ -1292,10 +1109,8 @@ export async function handleClaudeCountTokens( try { body = await readAnthropicBody(req, translatorBudget); } catch (err) { + if (err instanceof DesktopModelMappingUnavailableError) return desktopMappingUnavailableResponse(err); if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message); - if (isTranslatorBudgetExceededError(err)) { - return anthropicErrorResponse(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); - } return anthropicErrorResponse(500, err instanceof Error ? err.message : String(err)); } finally { translatorBudget.dispose(); } if (!body || typeof body !== "object" || Array.isArray(body)) { @@ -1305,71 +1120,44 @@ export async function handleClaudeCountTokens( if (typeof raw.model !== "string" || raw.model.length === 0) { return anthropicErrorResponse(400, "model is required"); } - let model = raw.model; - // Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i). - const stripped = stripOneMillionMarker(model); - if (stripped !== model) { - model = stripped; - raw.model = model; - } - // ocx-route override (devlog 072 + TRUST-01..05): keep count_tokens consistent with messages. - let directives; try { - directives = verifyAndExtractDirectives( - raw, - getOrCreateDirectiveSigningKey(), - (route, effort) => isAllowedLegacyDirective(route, effort, config), - ); - } catch (err) { - if (err instanceof AnthropicRequestError) { - return anthropicErrorResponse(400, err.message, "invalid_request_error"); + let model = raw.model; + // Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i). + const stripped = stripOneMillionMarker(model); + if (stripped !== model) { + model = stripped; + raw.model = model; } - throw err; - } - if (directives.route) { - model = stripOneMillionMarker(directives.route); - raw.model = model; - } - // Fast-only count requests carry a synthetic selector but never parsed an effort row. - // Normalize the identity before native passthrough or estimation; no tier is sent here. - const countFastRow = parseFastOnlyRowId( - config, () => decodeClaudeFastSelector(model, config.claudeCode), - ); - if (countFastRow) { - model = countFastRow.baseId; + // ocx-route override (devlog 072): keep count_tokens consistent with messages. + const countRoute = extractOcxRouteDirective(raw); + if (countRoute) { + model = stripOneMillionMarker(countRoute); + raw.model = model; + } + model = decodeFablePickerAlias(model, config.claudeCode); raw.model = model; - } - const policySelector = resolveClaudePolicySelector(config, model); - let resolvedPolicy = false; - if (policySelector.isPolicy) { - try { - const route = routeModel(config, policySelector.decodedModel, evidenceFromBody(raw)); - model = route.modelId; + // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a + // token estimate and sends no tier, so only the IDENTITY is corrected - without this the + // synthetic id reaches native passthrough as a model Anthropic has never heard of. + const countFastRow = parseFastOnlyRowId( + config, () => decodeClaudeFastSelector(model, config.claudeCode), + ); + if (countFastRow) { + model = countFastRow.baseId; raw.model = model; - resolvedPolicy = true; - } catch (err) { - if (err instanceof UnknownRoutingPolicyError || err instanceof NoEligiblePolicyCandidateError) { - return anthropicErrorResponse(404, err.message, "invalid_request_error"); - } - throw err; } + captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined); + if (wantsNativePassthrough(req, config, requestPolicy, model)) { + return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); + } + const inputTokens = estimateClaudeRequestTokens(raw, model); + return new Response(JSON.stringify({ input_tokens: inputTokens }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + if (error instanceof DesktopModelMappingUnavailableError) return desktopMappingUnavailableResponse(error); + if (error instanceof AnthropicRequestError) return anthropicErrorResponse(400, error.message); + throw error; } - const ctHeaderSessionId = claudeSessionIdFromRequest(req, raw); - const ctAgentIds = claudeAgentIdsFromRequest(req); - const ctAnthropicBeta = req.headers.get("anthropic-beta") ?? undefined; - const ctFeatureCodes = collectClaudeFeatureCodes(raw, ctAnthropicBeta); - captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), ctAnthropicBeta, { - ...(ctHeaderSessionId ? { sessionId: ctHeaderSessionId } : {}), - ...(ctAgentIds.agentId ? { agentId: ctAgentIds.agentId } : {}), - ...(ctAgentIds.parentAgentId ? { parentAgentId: ctAgentIds.parentAgentId } : {}), - ...(ctFeatureCodes.length > 0 ? { featureCodes: ctFeatureCodes } : {}), - }); - if (!resolvedPolicy && wantsNativePassthrough(req, config, requestPolicy, model)) { - return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); - } - const inputTokens = estimateClaudeRequestTokens(raw, model); - return new Response(JSON.stringify({ input_tokens: inputTokens }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); } diff --git a/src/server/grok-responses-snapshot-repair.ts b/src/server/grok-responses-snapshot-repair.ts new file mode 100644 index 0000000000..a667f44e55 --- /dev/null +++ b/src/server/grok-responses-snapshot-repair.ts @@ -0,0 +1,333 @@ +/** Strict terminal reconstruction selected by the Grok compatibility marker. */ +import type { TranslatorBudget } from "../lib/translator-budget"; +import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay"; +import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec"; + +type SparseTerminalOpenItem = { + type: string; + id?: string; + sourceBytes: number; +}; + +type SparseTerminalCompletedItem = RetainedOutputItem & { + visibleToGrok: boolean; +}; + +const MAX_GROK_OPEN_ITEM_IDENTITY_BYTES = MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES; + +const GROK_TERMINAL_OUTPUT_ITEM_TYPES = new Set([ + "message", + "reasoning", + "function_call", + "custom_tool_call", + "web_search_call", + "code_interpreter_call", + "mcp_call", +]); + +function hasValidOptionalId(item: Record): boolean { + return !("id" in item) + || (typeof item.id === "string" && item.id.trim().length > 0); +} + +function hasCompletedStatusWhenPresent(item: Record): boolean { + return !("status" in item) || item.status === "completed"; +} + +function isNullableString(value: unknown): boolean { + return value === null || typeof value === "string"; +} + +function isValidOutputMessagePart(part: unknown): boolean { + if (!isPlainObject(part)) return false; + if (part.type === "output_text") { + return typeof part.text === "string" + && (!("annotations" in part) || Array.isArray(part.annotations)) + && (!("logprobs" in part) || part.logprobs === null || Array.isArray(part.logprobs)); + } + return part.type === "refusal" && typeof part.refusal === "string"; +} + +function isValidReasoningPart(part: unknown, type: "summary_text" | "reasoning_text"): boolean { + return isPlainObject(part) && part.type === type && typeof part.text === "string"; +} + +function isValidWebSearchAction(value: unknown): boolean { + if (!isPlainObject(value)) return false; + if (value.type === "search") { + return typeof value.query === "string" + && (!("sources" in value) || value.sources === null || (Array.isArray(value.sources) + && value.sources.every(source => isPlainObject(source) + && typeof source.type === "string" && typeof source.url === "string"))); + } + if (value.type === "open_page") { + return !("url" in value) || isNullableString(value.url); + } + if (value.type === "find" || value.type === "find_in_page") { + return typeof value.url === "string" && typeof value.pattern === "string"; + } + return false; +} + +function isValidCodeInterpreterOutput(value: unknown): boolean { + return isPlainObject(value) + && ((value.type === "logs" && typeof value.logs === "string") + || (value.type === "image" && typeof value.url === "string")); +} + +/** + * Validate the pre-field-backfill item carried by a real output_item.done. + * Missing ids, message status, and output-text annotations are allowed because + * the always-on field backfill safely supplies only those schema defaults. + * Contradictory values and semantic content repairs are never accepted as + * proof that an empty terminal snapshot was sparse. + */ +function trustedGrokCompletedItem( + item: Record, +): { visibleToGrok: boolean } | null { + if (!hasValidOptionalId(item) || !hasCompletedStatusWhenPresent(item)) return null; + + if (item.type === "message") { + if (item.role !== "assistant" || !Array.isArray(item.content)) return null; + if (!(item.content as unknown[]).every(isValidOutputMessagePart)) return null; + if ("phase" in item && item.phase !== "commentary" && item.phase !== "final_answer") return null; + return { + // grok-build currently turns only output_text parts into final Assistant + // content; refusal parts do not satisfy its visible-content gate. + visibleToGrok: item.content.some(part => isPlainObject(part) + && part.type === "output_text" && typeof part.text === "string" && part.text.length > 0), + }; + } + + if (item.type === "reasoning") { + if (!Array.isArray(item.summary) + || !item.summary.every(part => isValidReasoningPart(part, "summary_text"))) return null; + if ("content" in item && item.content !== null + && (!Array.isArray(item.content) + || !item.content.every(part => isValidReasoningPart(part, "reasoning_text")))) return null; + if ("encrypted_content" in item && !isNullableString(item.encrypted_content)) return null; + return { visibleToGrok: false }; + } + + if (item.type === "function_call") { + if (typeof item.call_id !== "string" || item.call_id.trim().length === 0 + || typeof item.name !== "string" || item.name.trim().length === 0 + || typeof item.arguments !== "string") return null; + return { visibleToGrok: true }; + } + + if (item.type === "custom_tool_call") { + if (typeof item.call_id !== "string" || item.call_id.trim().length === 0 + || typeof item.name !== "string" || item.name.trim().length === 0 + || typeof item.input !== "string") return null; + return { visibleToGrok: false }; + } + + if (item.type === "web_search_call") { + if (item.status !== "completed" || !isValidWebSearchAction(item.action)) return null; + return { visibleToGrok: false }; + } + + if (item.type === "code_interpreter_call") { + if (item.status !== "completed" + || typeof item.container_id !== "string" || item.container_id.trim().length === 0 + || ("code" in item && !isNullableString(item.code)) + || ("outputs" in item && item.outputs !== null + && (!Array.isArray(item.outputs) || !item.outputs.every(isValidCodeInterpreterOutput)))) return null; + return { visibleToGrok: false }; + } + + if (item.type === "mcp_call") { + if (typeof item.arguments !== "string" + || typeof item.name !== "string" || item.name.trim().length === 0 + || typeof item.server_label !== "string" || item.server_label.trim().length === 0 + || ("approval_request_id" in item && !isNullableString(item.approval_request_id)) + || ("error" in item && !isNullableString(item.error)) + || ("output" in item && !isNullableString(item.output))) return null; + return { visibleToGrok: false }; + } + + return null; +} + +function plausibleGrokOpenItem( + item: Record, +): Omit | null { + const type = typeof item.type === "string" ? item.type : ""; + if (!GROK_TERMINAL_OUTPUT_ITEM_TYPES.has(type) || !hasValidOptionalId(item)) return null; + if ("status" in item && item.status !== "in_progress") return null; + if (type === "message") { + if ("role" in item && item.role !== "assistant") return null; + if ("content" in item && !Array.isArray(item.content)) return null; + } + return { + type, + ...(typeof item.id === "string" ? { id: item.id } : {}), + }; +} + +/** + * Narrow client repair for grok-build's Responses consumer. + * + * grok-build streams text deltas but builds its durable Assistant item only + * from response.completed.response.output. Some native Responses streams put + * the durable items in output_item.done and finish with a missing or explicit + * empty output. Reconstruct only from real, unique, contiguous, bounded done + * events whose raw semantics are already valid. Any ambiguity stays byte-level + * fail-closed; the provider-opt-in snapshot repair above is unchanged. + */ +export function createGrokResponsesSparseTerminalBlockRewrite( + budget?: TranslatorBudget, +): SseBlockRewrite { + const openItems = new Map(); + const completedItems = new Map(); + let aggregateItemBytes = 0; + let aggregateOpenItemBytes = 0; + let tainted = false; + let hasVisibleOutput = false; + + const clearRetained = (): void => { + const retainedBytes = aggregateItemBytes + aggregateOpenItemBytes; + if (retainedBytes > 0) { + budget?.releaseRetained(retainedBytes, { kind: "retained_collectors" }); + } + openItems.clear(); + completedItems.clear(); + aggregateItemBytes = 0; + aggregateOpenItemBytes = 0; + hasVisibleOutput = false; + }; + + const reset = (): void => { + clearRetained(); + tainted = false; + }; + + const taintAndRelease = (): void => { + clearRetained(); + tainted = true; + }; + + const retainCompletedItem = ( + index: number, + item: Record, + visibleToGrok: boolean, + ): void => { + if (tainted) return; + const sourceBytes = Buffer.byteLength(JSON.stringify(item), "utf8"); + if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES + || completedItems.size >= MAX_COMPLETED_OUTPUT_ITEMS + || aggregateItemBytes + sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) { + taintAndRelease(); + return; + } + budget?.chargeRetained(sourceBytes, { kind: "retained_collectors" }); + completedItems.set(index, { item, sourceBytes, visibleToGrok }); + aggregateItemBytes += sourceBytes; + hasVisibleOutput = hasVisibleOutput || visibleToGrok; + }; + + const closeOpenItem = (index: number): void => { + const open = openItems.get(index); + if (!open) return; + openItems.delete(index); + aggregateOpenItemBytes -= open.sourceBytes; + budget?.releaseRetained(open.sourceBytes, { kind: "retained_collectors" }); + }; + + const rewrite: SseBlockRewrite = (block: string): readonly string[] => { + const payload = sseDataPayload(block); + if (payload === null) return [block]; + if (payload === "[DONE]") { + reset(); + return [block]; + } + + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + taintAndRelease(); + return [block]; + } + if (!isPlainObject(parsed) || typeof parsed.type !== "string") { + taintAndRelease(); + return [block]; + } + + const type = parsed.type; + const outputIndex = Number.isInteger(parsed.output_index) && (parsed.output_index as number) >= 0 + ? parsed.output_index as number + : undefined; + + if (type === "response.output_item.added") { + const open = isPlainObject(parsed.item) ? plausibleGrokOpenItem(parsed.item) : null; + if (outputIndex === undefined || !open + || openItems.has(outputIndex) || completedItems.has(outputIndex) + || openItems.size >= MAX_COMPLETED_OUTPUT_ITEMS) { + taintAndRelease(); + } else if (!tainted) { + const sourceBytes = Buffer.byteLength(JSON.stringify(open), "utf8"); + if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES + || aggregateOpenItemBytes + sourceBytes > MAX_GROK_OPEN_ITEM_IDENTITY_BYTES) { + taintAndRelease(); + } else { + budget?.chargeRetained(sourceBytes, { kind: "retained_collectors" }); + openItems.set(outputIndex, { ...open, sourceBytes }); + aggregateOpenItemBytes += sourceBytes; + } + } + return [block]; + } + + if (type === "response.output_item.done") { + const item = isPlainObject(parsed.item) ? parsed.item : null; + const proof = item ? trustedGrokCompletedItem(item) : null; + if (outputIndex === undefined || !proof || completedItems.has(outputIndex)) { + taintAndRelease(); + return [block]; + } + const open = openItems.get(outputIndex); + const doneId = typeof item!.id === "string" ? item!.id : undefined; + if (open && (open.type !== item!.type || open.id !== doneId)) { + taintAndRelease(); + return [block]; + } + closeOpenItem(outputIndex); + retainCompletedItem(outputIndex, item!, proof.visibleToGrok); + return [block]; + } + + const isTerminal = type === "response.completed" + || type === "response.failed" + || type === "response.incomplete"; + if (!isTerminal) return [block]; + + let out = block; + if (type === "response.completed" && !tainted && isPlainObject(parsed.response)) { + const response = parsed.response; + const output = response.output; + const terminalStatusConsistent = !("status" in response) || response.status === "completed"; + const outputIsAuthoritative = Array.isArray(output) && output.length > 0; + const outputIsSparse = !("output" in response) + || (Array.isArray(output) && output.length === 0); + if (!outputIsAuthoritative && outputIsSparse && terminalStatusConsistent + && completedItems.size > 0 && openItems.size === 0 && hasVisibleOutput) { + const ordered = [...completedItems.entries()].sort(([left], [right]) => left - right); + if (ordered.every(([index], position) => index === position)) { + out = jsonBlock({ + ...parsed, + response: { ...response, output: ordered.map(([, retained]) => retained.item) }, + }); + } + } + } + reset(); + return [out]; + }; + + rewrite.dispose = reset; + return rewrite; +} + diff --git a/src/server/index.ts b/src/server/index.ts index 9a679fad73..f201d3ae4a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -195,7 +195,8 @@ export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses"; import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages"; import { handleChatCompletions } from "./chat-completions"; import { anthropicErrorResponse } from "../claude/outbound"; -import { buildDesktop3pRegistry } from "../claude/desktop-3p"; +import { buildDesktop3pRegistry, generateDesktop3pModels } from "../claude/desktop-3p"; +import { buildDesktopDiscoveryInputs } from "../claude/desktop-discovery-inputs"; import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration"; import { bindNativeMainStartupLifecycle, @@ -1513,6 +1514,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server [...slugs]), )]; - const desktopNativeSlugs = desktopVisibleNativeSlugs(config).filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) - )); - const goEnabled = filterCatalogVisibleModels(goModels, config); - const goOrdered = orderForSubagents(goEnabled, config.subagentModels); + const desktopInputs = buildDesktopDiscoveryInputs({ + config, models: goModels, modelEntitlements, + desktopNativeCandidates: desktopVisibleNativeSlugs(config), + }); + const desktopNativeSlugs = desktopInputs.nativeSlugs; + const goOrdered = desktopInputs.routedModels; // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with // Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official // ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can @@ -1598,7 +1604,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), + desktopInputs.routedModels, config.claudeCode?.desktopProfile, + desktopInputs.nativeContextCap, ); const { buildAnthropicModelInfos } = await import("../claude/model-info"); const { resolveAutoContext } = await import("../claude/context-windows"); @@ -1658,7 +1674,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { let response: Response; try { - response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission); + response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, { + onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), + }); } catch { response = formatErrorResponse(500, "server_error", "Unexpected compact request failure"); } diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 09127b0b58..58ae3f7d60 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -251,10 +251,26 @@ export function issueGuiSession( export interface ManagementSessionControl { revokeCurrent(req: Request): boolean; + /** Revalidate a long-lived request against current authority, without cached admission or renewal. */ + isCurrent(req: Request, config: OcxConfig): boolean; } export function createManagementSessionControl(state: ManagementAuthState): ManagementSessionControl { return { + isCurrent(req: Request, config: OcxConfig): boolean { + if (!state.available) return false; + const credential = requestManagementCredential(req); + if (!credential) return false; + if (equalSecret(credential, state.token)) return true; + const session = state.sessions.get(credential); + if (!session) return false; + // Reuse the full origin/expiry/CSRF predicate against the current record, but + // isolate its sliding-expiry mutation: SSE heartbeats are not browser activity. + return authorizeGuiSessionRequest(req, config, { + sessions: new Map([[credential, { ...session }]]), + pairingGrants: state.pairingGrants, + }).ok; + }, revokeCurrent(req: Request): boolean { if (!state.available) return false; const credential = requestManagementCredential(req); diff --git a/src/server/management/account-selection-stream.ts b/src/server/management/account-selection-stream.ts new file mode 100644 index 0000000000..e04e8a3e14 --- /dev/null +++ b/src/server/management/account-selection-stream.ts @@ -0,0 +1,70 @@ +import { currentAccountSelectionRevision, subscribeAccountSelections } from "../../lib/account-selection-events"; +import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks"; + +const MAX_SELECTION_STREAMS = 64; +const HEARTBEAT_MS = 15_000; +const encoder = new TextEncoder(); +const connections = new Set<() => void>(); + +/** The management boundary admits the request; every frame revalidates current authority. */ +export function accountSelectionStream(request: Request, validate: () => boolean): Response { + const authorized = () => { + try { return validate() === true; } catch { return false; } + }; + if (!authorized()) return Response.json({ error: "Management session is no longer authorized" }, { status: 401 }); + if (connections.size >= MAX_SELECTION_STREAMS) { + return Response.json({ error: "Too many account selection streams" }, { + status: 429, headers: { "Retry-After": "15" }, + }); + } + let cleanup = () => {}; + const body = new ReadableStream({ + start(controller) { + let closed = false; + let unsubscribe = () => {}; + let heartbeat: ReturnType | undefined; + const close = () => { + if (closed) return; + closed = true; + unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + request.signal.removeEventListener("abort", close); + connections.delete(close); + try { controller.close(); } catch { /* The consumer may already have cancelled. */ } + }; + cleanup = close; + const send = (frame: string) => { + if (closed) return; + if (!authorized()) { + // Error clears queued frames as well, so a revoked consumer cannot drain them. + try { controller.error(new DOMException("Management session is no longer authorized", "NotAllowedError")); } + finally { close(); } + return; + } + // Reconnection sends a ready event, so a slow reader can reconcile without an + // unbounded queue or silently dropping a provider's latest invalidation. + if (controller.desiredSize !== null && controller.desiredSize <= 0) { close(); return; } + try { controller.enqueue(encoder.encode(frame)); } catch { close(); } + }; + connections.add(close); + registerOptionalShutdownHook("account-selection-streams", () => { + for (const finish of [...connections]) finish(); + }); + if (request.signal.aborted) { close(); return; } + request.signal.addEventListener("abort", close, { once: true }); + unsubscribe = subscribeAccountSelections(event => { + send(`event: account-selection\ndata: ${JSON.stringify(event)}\n\n`); + }); + send(`event: ready\ndata: ${JSON.stringify({ revision: currentAccountSelectionRevision() })}\n\n`); + if (closed) return; + heartbeat = setInterval(() => send(": heartbeat\n\n"), HEARTBEAT_MS); + heartbeat.unref?.(); + }, + cancel() { cleanup(); }, + }, { highWaterMark: 16 }); + return new Response(body, { headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + } }); +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 251377bda7..a7cf017f3a 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -10,8 +11,10 @@ import { isValidProviderName, loadConfig, multiAgentGuidanceEnabled, + mutatePersistedConfig, providerBaseUrlConfigError, providerHeadersConfigError, + saveConfigPreservingClaudeCode, subagentDefaultSyncEffective, } from "../../config"; import { @@ -67,16 +70,8 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; -import { routeModel } from "../../router"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchInitializedModels as fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; -import { - agentRolesSyncEffective, - parseSubagentRoles, - routedOnV2Warnings, - unionRoleModelsIntoRoster, -} from "../../codex/agent-roles"; -import { syncCodexAgentRoles } from "../../codex/agent-roles-sync"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -87,123 +82,6 @@ let grokApplyFlight: { startedAt: number; promise: Promise; bytes: numb let grokApplyHighWaterBytes = 0; let grokApplyTestHooks: { now?: () => number; run?: () => Promise } | null = null; -type V2NativeParentOverrideInput = { enabled: boolean; model: string | null }; -type AgentTaskRecoveryInput = { enabled: boolean; model: string | null }; -const V2_CONFIG_KEYS = ["multiAgentMode", "keepNativeChatGptOnV1", "v2NativeParentOverride", "v2RoutedDelegationBridge", "agentTaskRecovery"] as const; -type V2ConfigKey = typeof V2_CONFIG_KEYS[number]; -type V2ConfigSnapshot = Pick; - -function v2ConfigSnapshot(config: OcxConfig): V2ConfigSnapshot { - return Object.fromEntries(V2_CONFIG_KEYS.map(key => [key, structuredClone(config[key])])) as V2ConfigSnapshot; -} - -function setV2ConfigField(config: OcxConfig, key: V2ConfigKey, value: OcxConfig[V2ConfigKey]): void { - if (value === undefined) delete (config as unknown as Record)[key]; - else (config as unknown as Record)[key] = structuredClone(value); -} - -function sameV2ConfigField(left: unknown, right: unknown): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - -function persistV2RoutedDelegationBridge( - deps: ManagementApiDeps, - config: OcxConfig, - enabled: boolean, -): { ok: true } | { ok: false; reason: string } { - const outcome = mutateManagementConfig(deps, persisted => { - const changed = persisted.v2RoutedDelegationBridge !== enabled; - if (changed) persisted.v2RoutedDelegationBridge = enabled; - return { changed, value: true }; - }); - if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - config.v2RoutedDelegationBridge = enabled; - return { ok: true }; -} - -function agentTaskRecoveryDto( - config: OcxConfig, -): { enabled: boolean; model: string | null } { - const recovery = config.agentTaskRecovery; - return { - enabled: recovery?.enabled === true, - model: recovery?.model ?? null, - }; -} - -function persistAgentTaskRecovery( - deps: ManagementApiDeps, - config: OcxConfig, - next: AgentTaskRecoveryInput, -): { ok: true } | { ok: false; reason: string } { - const outcome = mutateManagementConfig(deps, persisted => { - const nextPersisted = { - enabled: next.enabled, - ...(next.model === null ? {} : { model: next.model }), - }; - const previous = persisted.agentTaskRecovery; - const changed = previous?.enabled !== nextPersisted.enabled || previous?.model !== nextPersisted.model; - if (changed) persisted.agentTaskRecovery = nextPersisted; - return { changed, value: true }; - }); - if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - config.agentTaskRecovery = { - enabled: next.enabled, - ...(next.model === null ? {} : { model: next.model }), - }; - return { ok: true }; -} - -function v2NativeParentOverrideDto( - config: OcxConfig, - upstreamEnabled: boolean, -): { enabled: boolean; model: string | null; active: boolean } { - const override = config.v2NativeParentOverride; - const enabled = override?.enabled === true; - const model = override?.model ?? null; - return { - enabled, - model, - active: enabled - && model !== null - && v2NativeParentOverrideTargetIsNoncanonical(config, model) - && config.multiAgentMode === "v2" - && upstreamEnabled - && config.keepNativeChatGptOnV1 !== true, - }; -} - -function v2NativeParentOverrideTargetIsNoncanonical(config: OcxConfig, model: string): boolean { - try { - return !isCanonicalOpenAiForwardProvider(routeModel(config, model).provider); - } catch { - return false; - } -} - -function persistV2NativeParentOverride( - deps: ManagementApiDeps, - config: OcxConfig, - next: V2NativeParentOverrideInput, -): { ok: true } | { ok: false; reason: string } { - const outcome = mutateManagementConfig(deps, persisted => { - const nextPersisted = { - enabled: next.enabled, - ...(next.model === null ? {} : { model: next.model }), - }; - const previous = persisted.v2NativeParentOverride; - const changed = previous?.enabled !== nextPersisted.enabled || previous?.model !== nextPersisted.model; - if (changed) persisted.v2NativeParentOverride = nextPersisted; - return { changed, value: true }; - }); - if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - config.v2NativeParentOverride = { - enabled: next.enabled, - ...(next.model === null ? {} : { model: next.model }), - }; - return { ok: true }; -} - class GrokApplyBusyError extends Error {} /** @@ -235,11 +113,10 @@ function mirrorDesiredEnabledOntoSnapshot(config: OcxConfig, client: "claude-des * unrelated key another writer just committed. */ function persistDesktopProfileField( - deps: ManagementApiDeps, config: OcxConfig, desktopProfile: NonNullable["desktopProfile"], ): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { - const outcome = mutateManagementConfig(deps, persisted => { + const outcome = mutatePersistedConfig(persisted => { persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile }; return { changed: true, value: true }; }); @@ -302,7 +179,7 @@ export function setGrokApplyFlightTestHooks( grokApplyFlight = null; grokApplyHighWaterBytes = 0; } -import { ManagementPersistenceError, MissingManagementPersistenceError, mutateManagementConfig, saveManagementConfig, type ManagementApiDeps, type ManagementContext } from "./context"; +import type { ManagementContext } from "./context"; export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; @@ -342,7 +219,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise ); if (result.written && result.fingerprint) { current.claudeCode = { ...current.claudeCode, desktopProfile: { ...current.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; - saveManagementConfig(deps, current); + saveConfigPreservingClaudeCode(current); } } catch { /* best-effort */ } } @@ -359,7 +236,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise getMultiAgentModeHintText, } = await import("../../codex/features"); const enabled = isMultiAgentV2Enabled(); - const v2NativeParentOverride = v2NativeParentOverrideDto(config, enabled); return jsonResponse({ enabled, agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(), @@ -373,9 +249,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // max_depth is V1-only upstream; this is the global-flag statement, derived // server-side so no client can present it as an effective V2 limit. agentsMaxDepthAppliesWhenV2Disabled: !enabled, - v2NativeParentOverride, - v2RoutedDelegationBridge: config.v2RoutedDelegationBridge === true, - agentTaskRecovery: agentTaskRecoveryDto(config), }); } if (url.pathname === "/api/v2" && req.method === "PUT") { @@ -388,9 +261,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise agentsMaxDepth?: unknown; subagentDeveloperInstructions?: unknown; multiAgentModeHintText?: unknown; - v2NativeParentOverride?: unknown; - v2RoutedDelegationBridge?: unknown; - agentTaskRecovery?: unknown; }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } const wantsFlag = body.enabled !== undefined; @@ -401,11 +271,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const wantsMaxDepth = body.agentsMaxDepth !== undefined; const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined; const wantsModeHintText = body.multiAgentModeHintText !== undefined; - const wantsV2NativeParentOverride = body.v2NativeParentOverride !== undefined; - const wantsV2RoutedDelegationBridge = body.v2RoutedDelegationBridge !== undefined; - const wantsAgentTaskRecovery = body.agentTaskRecovery !== undefined; - if (!wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText && !wantsV2NativeParentOverride && !wantsV2RoutedDelegationBridge && !wantsAgentTaskRecovery) { - return jsonResponse({ error: "body must set enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, multiAgentModeHintText, v2NativeParentOverride, v2RoutedDelegationBridge, and/or agentTaskRecovery" }, 400); + if (!wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText) { + return jsonResponse({ error: "body must set enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, and/or multiAgentModeHintText" }, 400); } if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400); if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") { @@ -414,9 +281,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (wantsKeepNative && typeof body.keepNativeChatGptOnV1 !== "boolean") { return jsonResponse({ error: "body.keepNativeChatGptOnV1 must be a boolean" }, 400); } - if (wantsV2RoutedDelegationBridge && typeof body.v2RoutedDelegationBridge !== "boolean") { - return jsonResponse({ error: "body.v2RoutedDelegationBridge must be a boolean" }, 400); - } if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) { return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400); } @@ -457,98 +321,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise : `body.enabled conflicts with multiAgentMode '${mode}'`, }, 400); } - const { isMultiAgentV2Enabled: readMultiAgentV2Enabled } = await import("../../codex/features"); - const currentUpstreamEnabled = readMultiAgentV2Enabled(); - const prospectiveMode = mode ?? config.multiAgentMode ?? "default"; - const prospectiveKeepNative = wantsKeepNative - ? body.keepNativeChatGptOnV1 === true - : config.keepNativeChatGptOnV1 === true; - const prospectiveUpstreamEnabled = wantsFlag - ? body.enabled as boolean - : modeFlag ?? currentUpstreamEnabled; - let v2NativeParentOverride: V2NativeParentOverrideInput | undefined; - if (wantsV2NativeParentOverride) { - const raw = body.v2NativeParentOverride; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return jsonResponse({ error: "body.v2NativeParentOverride must be an object" }, 400); - } - const keys = Object.keys(raw as object); - if (keys.length !== 2 || !keys.includes("enabled") || !keys.includes("model")) { - return jsonResponse({ error: "body.v2NativeParentOverride must contain enabled and model" }, 400); - } - const candidate = raw as { enabled?: unknown; model?: unknown }; - if (typeof candidate.enabled !== "boolean") { - return jsonResponse({ error: "body.v2NativeParentOverride.enabled must be a boolean" }, 400); - } - if (candidate.model !== null && (typeof candidate.model !== "string" || candidate.model.trim().length === 0)) { - return jsonResponse({ error: "body.v2NativeParentOverride.model must be a nonblank string or null" }, 400); - } - v2NativeParentOverride = { - enabled: candidate.enabled, - model: candidate.model === null ? null : (candidate.model as string).trim(), - }; - if (v2NativeParentOverride.model !== null) { - let target; - try { - target = routeModel(config, v2NativeParentOverride.model); - } catch { - return jsonResponse({ error: "body.v2NativeParentOverride.model must resolve to a configured provider" }, 400); - } - if (isCanonicalOpenAiForwardProvider(target.provider)) { - return jsonResponse({ error: "body.v2NativeParentOverride.model must resolve to a noncanonical provider" }, 400); - } - } - if (v2NativeParentOverride.enabled) { - if (v2NativeParentOverride.model === null) { - return jsonResponse({ error: "enabling v2NativeParentOverride requires a model" }, 400); - } - if (prospectiveMode !== "v2") { - return jsonResponse({ error: "enabling v2NativeParentOverride requires multiAgentMode 'v2'" }, 400); - } - if (!prospectiveUpstreamEnabled) { - return jsonResponse({ error: "enabling v2NativeParentOverride requires the upstream V2 feature" }, 400); - } - if (prospectiveKeepNative) { - return jsonResponse({ error: "enabling v2NativeParentOverride conflicts with keepNativeChatGptOnV1" }, 400); - } - } - } - let agentTaskRecovery: AgentTaskRecoveryInput | undefined; - if (wantsAgentTaskRecovery) { - const raw = body.agentTaskRecovery; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return jsonResponse({ error: "body.agentTaskRecovery must be an object" }, 400); - } - const candidate = raw as { enabled?: unknown; model?: unknown }; - if (typeof candidate.enabled !== "boolean") { - return jsonResponse({ error: "body.agentTaskRecovery.enabled must be a boolean" }, 400); - } - if (candidate.model !== undefined && candidate.model !== null && (typeof candidate.model !== "string" || candidate.model.trim().length === 0)) { - return jsonResponse({ error: "body.agentTaskRecovery.model must be a nonblank string or null" }, 400); - } - agentTaskRecovery = { - enabled: candidate.enabled, - model: candidate.model === null || candidate.model === undefined ? null : (candidate.model as string).trim(), - }; - } - if (wantsV2RoutedDelegationBridge && !wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative - && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText && !wantsV2NativeParentOverride && !wantsAgentTaskRecovery) { - const persisted = persistV2RoutedDelegationBridge(deps, config, body.v2RoutedDelegationBridge as boolean); - if (!persisted.ok) return jsonResponse({ error: `persisting v2RoutedDelegationBridge failed: ${persisted.reason}` }, 502); - return jsonResponse({ ok: true, v2RoutedDelegationBridge: config.v2RoutedDelegationBridge === true }); - } - if (agentTaskRecovery && !wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative - && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText && !wantsV2NativeParentOverride) { - const persisted = persistAgentTaskRecovery(deps, config, agentTaskRecovery); - if (!persisted.ok) return jsonResponse({ error: `persisting agentTaskRecovery failed: ${persisted.reason}` }, 502); - return jsonResponse({ ok: true, agentTaskRecovery: agentTaskRecoveryDto(config) }); - } - if (v2NativeParentOverride && !wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative - && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText) { - const persisted = persistV2NativeParentOverride(deps, config, v2NativeParentOverride); - if (!persisted.ok) return jsonResponse({ error: `persisting v2NativeParentOverride failed: ${persisted.reason}` }, 502); - return jsonResponse({ ok: true, v2NativeParentOverride: v2NativeParentOverrideDto(config, readMultiAgentV2Enabled()) }); - } const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2, getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions, @@ -570,87 +342,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise error: "body.enabled=true conflicts with keepNativeChatGptOnV1: Codex's global multi_agent_v2 override outranks catalog pins", }, 400); } - let rollbackV2Config: (() => string | null) | undefined; - if (wantsMode || wantsKeepNative || v2NativeParentOverride || wantsV2RoutedDelegationBridge || agentTaskRecovery) { - const requestedKeys = V2_CONFIG_KEYS.filter(key => ( - (key === "multiAgentMode" && wantsMode) - || (key === "keepNativeChatGptOnV1" && wantsKeepNative) - || (key === "v2NativeParentOverride" && v2NativeParentOverride !== undefined) - || (key === "v2RoutedDelegationBridge" && wantsV2RoutedDelegationBridge) - || (key === "agentTaskRecovery" && agentTaskRecovery !== undefined) - )); - let before!: V2ConfigSnapshot; - let committed!: OcxConfig; - const persisted = mutateManagementConfig(deps, disk => { - before = v2ConfigSnapshot(disk); - if (wantsMode) { - if (mode === "default") deleteConfigTopLevelKey(disk, "multiAgentMode"); - else disk.multiAgentMode = mode; - } - if (wantsKeepNative) { - if (body.keepNativeChatGptOnV1 === true) disk.keepNativeChatGptOnV1 = true; - else deleteConfigTopLevelKey(disk, "keepNativeChatGptOnV1"); - } - if (v2NativeParentOverride) { - disk.v2NativeParentOverride = { - enabled: v2NativeParentOverride.enabled, - ...(v2NativeParentOverride.model === null ? {} : { model: v2NativeParentOverride.model }), - }; - } - if (wantsV2RoutedDelegationBridge) disk.v2RoutedDelegationBridge = body.v2RoutedDelegationBridge as boolean; - if (agentTaskRecovery) { - disk.agentTaskRecovery = { - enabled: agentTaskRecovery.enabled, - ...(agentTaskRecovery.model === null ? {} : { model: agentTaskRecovery.model }), - }; - } - committed = structuredClone(disk); - return { changed: true, value: true }; - }); - if (persisted.status === "unavailable") { - return jsonResponse({ error: `persisting V2 settings failed: ${persisted.reason}` }, 502); - } - if (wantsMode) { - if (committed.multiAgentMode === undefined) deleteConfigTopLevelKey(config, "multiAgentMode"); - else config.multiAgentMode = committed.multiAgentMode; - } - if (wantsKeepNative) { - if (committed.keepNativeChatGptOnV1 === undefined) deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1"); - else config.keepNativeChatGptOnV1 = committed.keepNativeChatGptOnV1; - } - if (v2NativeParentOverride) config.v2NativeParentOverride = committed.v2NativeParentOverride; - if (wantsV2RoutedDelegationBridge) config.v2RoutedDelegationBridge = committed.v2RoutedDelegationBridge; - if (agentTaskRecovery) config.agentTaskRecovery = committed.agentTaskRecovery; - const committedSnapshot = v2ConfigSnapshot(committed); - rollbackV2Config = () => { - let finalSnapshot!: V2ConfigSnapshot; - try { - const rollback = mutateManagementConfig(deps, disk => { - let changed = false; - for (const key of requestedKeys) { - if (!sameV2ConfigField(disk[key], committedSnapshot[key])) continue; - setV2ConfigField(disk, key, before[key]); - changed = true; - } - finalSnapshot = v2ConfigSnapshot(disk); - return { changed, value: true }; - }); - if (rollback.status === "unavailable") return rollback.reason; - } catch (error) { - return error instanceof Error ? error.message : String(error); - } - for (const key of requestedKeys) setV2ConfigField(config, key, finalSnapshot[key]); - return null; - }; - } - const rollbackDiagnostic = (message: string): string => { - const failure = rollbackV2Config?.(); - return failure ? `${message}; config rollback failed: ${failure}` : message; - }; - const externalChanged: string[] = []; - const scalarFailureDiagnostic = (message: string): string => externalChanged.length > 0 - ? `${message}; config retained because earlier external side effects were applied: ${externalChanged.join(", ")}` - : rollbackDiagnostic(message); const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag ?? (wantsKeepNative && hybridPinActive ? false : undefined); @@ -664,15 +355,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const result = transitionMultiAgentV2(targetFlag, toggle, { ...(wantsThreads ? { threadLimit: body.maxConcurrentThreadsPerSession as number } : {}), }); - if (!result.ok) return jsonResponse({ error: rollbackDiagnostic(`multi_agent_v2 transition failed: ${result.error}`) }, 502); - if (result.changed) externalChanged.push("multi_agent_v2"); + if (!result.ok) return jsonResponse({ error: `multi_agent_v2 transition failed: ${result.error}` }, 502); if (result.changed && result.threadLimit !== null) warnings.push(`Thread limit ${result.threadLimit} preserved for ${targetFlag ? "v2" : "v1"}.`); } if (wantsMode) { + if (mode === "default") deleteConfigTopLevelKey(config, "multiAgentMode"); + else config.multiAgentMode = mode; + saveConfigPreservingClaudeCode(config); warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`); } if (wantsKeepNative) { - const effectiveMode = mode ?? config.multiAgentMode ?? "default"; + if (body.keepNativeChatGptOnV1 === true) config.keepNativeChatGptOnV1 = true; + else deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1"); + saveConfigPreservingClaudeCode(config); warnings.push(body.keepNativeChatGptOnV1 === true ? (effectiveMode === "v2" ? "ChatGPT-native models stay on v1 while other models use v2. Applies to new sessions." @@ -687,22 +382,21 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // asserts this route file contains no direct write primitive, and matches on the // symbol name even inside a comment. const scalarWrites: Array<{ field: string; run: () => { ok: true; changed: boolean } | { ok: false; error: string } }> = []; - if (wantsAgentsEnabled) scalarWrites.push({ field: "agentsEnabled", run: () => (deps.v2ScalarWriters?.setAgentsEnabled ?? setAgentsEnabled)(body.agentsEnabled as boolean | null) }); - if (wantsMaxDepth) scalarWrites.push({ field: "agentsMaxDepth", run: () => (deps.v2ScalarWriters?.setAgentsMaxDepth ?? setAgentsMaxDepth)(body.agentsMaxDepth as number | null) }); - if (wantsSubagentInstructions) scalarWrites.push({ field: "subagentDeveloperInstructions", run: () => (deps.v2ScalarWriters?.setSubagentDeveloperInstructions ?? setSubagentDeveloperInstructions)(body.subagentDeveloperInstructions as string | null) }); - if (wantsModeHintText) scalarWrites.push({ field: "multiAgentModeHintText", run: () => (deps.v2ScalarWriters?.setMultiAgentModeHintText ?? setMultiAgentModeHintText)(body.multiAgentModeHintText as string | null) }); + if (wantsAgentsEnabled) scalarWrites.push({ field: "agentsEnabled", run: () => setAgentsEnabled(body.agentsEnabled as boolean | null) }); + if (wantsMaxDepth) scalarWrites.push({ field: "agentsMaxDepth", run: () => setAgentsMaxDepth(body.agentsMaxDepth as number | null) }); + if (wantsSubagentInstructions) scalarWrites.push({ field: "subagentDeveloperInstructions", run: () => setSubagentDeveloperInstructions(body.subagentDeveloperInstructions as string | null) }); + if (wantsModeHintText) scalarWrites.push({ field: "multiAgentModeHintText", run: () => setMultiAgentModeHintText(body.multiAgentModeHintText as string | null) }); const landed: string[] = []; for (const write of scalarWrites) { try { const result = write.run(); if (!result.ok) { - return jsonResponse({ error: scalarFailureDiagnostic(`writing ${write.field} failed: ${result.error}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}`) }, 502); + return jsonResponse({ error: `writing ${write.field} failed: ${result.error}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502); } landed.push(write.field); - if (result.changed) externalChanged.push(write.field); } catch (err) { const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: scalarFailureDiagnostic(`writing ${write.field} failed: ${message}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}`) }, 502); + return jsonResponse({ error: `writing ${write.field} failed: ${message}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502); } } // Derived from fresh post-write readers (readConfigText is uncached): upstream @@ -726,9 +420,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise subagentDeveloperInstructions: getSubagentDeveloperInstructions(), multiAgentModeHintText: getMultiAgentModeHintText(), agentsMaxDepthAppliesWhenV2Disabled: !enabled, - v2NativeParentOverride: v2NativeParentOverrideDto(config, enabled), - v2RoutedDelegationBridge: config.v2RoutedDelegationBridge === true, - agentTaskRecovery: agentTaskRecoveryDto(config), warnings, catalogRefresh, }); @@ -792,24 +483,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // dynamically injected into the v1 proactive prompt, plus an optional reasoning // effort the prompt tells the agent to pass to spawn_agent. GET returns the current // picks + available models/efforts; PUT sets or clears them. - if (url.pathname === "/api/subagent-model-authority" && req.method === "POST") { - let body: unknown; - try { body = await readManagementJsonBody(req); } catch (error) { - rethrowManagementBodyTooLarge(error); - return jsonResponse({ error: "invalid JSON body" }, 400); - } - const { parseSubagentModelAuthorityInput, resolveOpenCodexSubagentModelAuthority } = await import("../../codex/subagent-model-authority"); - const input = parseSubagentModelAuthorityInput(body); - if (!input) return jsonResponse({ error: "invalid subagent model authority input" }, 400); - return jsonResponse(await resolveOpenCodexSubagentModelAuthority(input, config)); - } - if (url.pathname === "/api/injection-model" && req.method === "GET") { const models = await fetchAllModels(config); const disabled = new Set(config.disabledModels ?? []); const { listCatalogNativeSlugs } = await import("../../codex/catalog"); const { CODEX_REASONING_LEVELS } = await import("../../reasoning-effort"); - const { resolveNativeDefaultState } = await import("../../codex/subagent-defaults"); const nativeModels = listCatalogNativeSlugs() .filter(slug => !disabled.has(slug)) .map(slug => ({ provider: "openai", model: slug, namespaced: slug })); @@ -824,7 +502,6 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null, - nativeDefaultState: await resolveNativeDefaultState(config), efforts: CODEX_REASONING_LEVELS.map(l => l.effort), available: [...nativeModels, ...routedModels], }); @@ -907,7 +584,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (nextPrompt) config.injectionPrompt = nextPrompt; else deleteConfigTopLevelKey(config, "injectionPrompt"); - saveManagementConfig(deps, config); + saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config), @@ -942,14 +619,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } config[key] = value; } - saveManagementConfig(deps, config); + saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, effortCap: config.effortCap ?? null, subagentEffortCap: config.subagentEffortCap ?? null }); } - // Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the - // first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead. + // Featured roster and saved picker order are separate settings. Native Codex advertises + // the first five eligible visible rows by display priority; OCX guidance uses natural ranks. if (url.pathname === "/api/subagent-models" && req.method === "GET") { - const models = await fetchAllModels(config); + const models = await (deps.fetchAllModels ?? fetchAllModels)(config); const disabled = new Set(config.disabledModels ?? []); // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the // catalog, just buried by priority. List them first so the user can feature them over routed. @@ -981,104 +658,105 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // in-memory catalog than the one on disk. const { collectCodexAppServerCatalogState } = await import("../../codex/app-server-processes"); const catalogState = collectCodexAppServerCatalogState(); - return jsonResponse({ chosen, available, catalogState }); - } - if (url.pathname === "/api/subagent-models" && req.method === "PUT") { - let body: { models?: unknown }; - try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : []; - config.subagentModels = chosen; - saveManagementConfig(deps, config); - const catalogRefresh = await convergeCodexCatalog(); - await syncClaudeAgentDefsBestEffort(); - await autoApplyDesktopBestEffort(); - return jsonResponse({ ok: true, applied: chosen, catalogRefresh }); - } - - if (url.pathname === "/api/subagent-roles" && req.method === "GET") { - const models = await fetchAllModels(config); - const disabled = new Set(config.disabledModels ?? []); - const { listCatalogNativeSlugs } = await import("../../codex/catalog"); - const { CODEX_REASONING_LEVELS } = await import("../../reasoning-effort"); - const nativeModels = listCatalogNativeSlugs() - .filter(slug => !disabled.has(slug)) - .map(slug => ({ provider: "openai", model: slug, namespaced: slug })); - const routedModels = uniqueCatalogModelsForPublicList(models) - .map(m => ({ provider: m.provider, model: m.id, namespaced: catalogModelSlug(m) })) - .filter(m => ![...disabled].some(stored => ( - stored === m.namespaced || slugEquals(stored, m.provider, m.model) - ))); return jsonResponse({ - roles: config.subagentRoles ?? [], - ...(config.syncCodexAgentRoles === undefined ? {} : { syncCodexAgentRoles: config.syncCodexAgentRoles }), - syncCodexAgentRolesEffective: agentRolesSyncEffective(config), - efforts: CODEX_REASONING_LEVELS.map(l => l.effort), - available: [...nativeModels, ...routedModels], + chosen, available, catalogState, + pickerAvailable: [...new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")))], + pickerOrder: config.modelPickerOrder ?? [], + pickerOrderMode: config.modelPickerOrderMode ?? null, }); } - if (url.pathname === "/api/subagent-roles" && req.method === "PUT") { - let parsedBody: unknown; - try { - parsedBody = await readManagementJsonBody(req); - } catch (error) { - rethrowManagementBodyTooLarge(error); - return jsonResponse({ error: "invalid JSON body" }, 400); + if (url.pathname === "/api/subagent-models" && req.method === "PUT") { + let rawBody: unknown; + try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!isPlainRecord(rawBody)) return jsonResponse({ error: "JSON body must be an object" }, 400); + const body = rawBody as { models?: unknown; pickerOrder?: unknown; pickerOrderMode?: unknown }; + const updatesRoster = body.models !== undefined; + const updatesPicker = body.pickerOrder !== undefined; + if (!updatesRoster && !updatesPicker) return jsonResponse({ error: "models or pickerOrder is required" }, 400); + let chosen: string[] | undefined; + if (updatesRoster) { + if (!Array.isArray(body.models) || body.models.some(model => typeof model !== "string")) { + return jsonResponse({ error: "models must be an array of strings" }, 400); + } + // Keep the original valid roster contract: no discovery validation, trimming or deduping. + chosen = body.models.slice(0, 5); + } + const mode = body.pickerOrderMode; + if (mode !== undefined && (!updatesPicker || (mode !== null + && mode !== "alphabetical" && mode !== "provider" && mode !== "most-used"))) { + return jsonResponse({ error: "pickerOrderMode requires pickerOrder and must be alphabetical, provider, most-used, or null" }, 400); + } + let pickerOrder: string[] | undefined; + if (updatesPicker) { + if (body.pickerOrder !== null && (!Array.isArray(body.pickerOrder) + || body.pickerOrder.some(model => typeof model !== "string" || model.trim() === ""))) { + return jsonResponse({ error: "pickerOrder must be an array of non-empty routed model ids, or null" }, 400); + } + pickerOrder = body.pickerOrder === null ? [] : (body.pickerOrder as string[]).map(model => model.trim()); + if (new Set(pickerOrder).size !== pickerOrder.length) { + return jsonResponse({ error: "pickerOrder must not contain duplicate ids" }, 400); + } + if (pickerOrder.length > 0) { + const models = await (deps.fetchAllModels ?? fetchAllModels)(config); + // Evaluate visibility AFTER discovery: a concurrent visibility write may have completed. + const visible = new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/"))); + if (pickerOrder.some(model => !visible.has(model))) { + return jsonResponse({ error: "pickerOrder must contain each visible routed model at most once" }, 400); + } + } } - if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) { - return jsonResponse({ error: "body must be a JSON object" }, 400); + + // Everything above can await. From this snapshot through persistence there is no yield. + // Stage deletion intent before adopting the touched fields through the canonical + // live deletion owner. A failed save restores both fields and pending intent. + if (updatesPicker && config.configRebaseProvenance !== undefined + && parsedConfigRebaseDeletionKeys(config) === null) { + // A newer provenance format must not silently discard this clear's intent on rebase. + return jsonResponse({ error: "unsupported config deletion provenance" }, 409); + } + const draft = { ...projectConfigRebaseProvenance(config) }; + if (chosen !== undefined) draft.subagentModels = chosen; + if (pickerOrder !== undefined) { + if (pickerOrder.length === 0) { + deleteConfigTopLevelKey(draft, "modelPickerOrder"); + deleteConfigTopLevelKey(draft, "modelPickerOrderMode"); + } else { + draft.modelPickerOrder = pickerOrder; + if (mode === "alphabetical" || mode === "provider" || mode === "most-used") draft.modelPickerOrderMode = mode; + else deleteConfigTopLevelKey(draft, "modelPickerOrderMode"); + } } - const body = parsedBody as { roles?: unknown; remove?: unknown; syncCodexAgentRoles?: unknown }; - if ("remove" in body) { - if ("roles" in body) return jsonResponse({ error: "body.remove cannot be combined with body.roles" }, 400); - if (typeof body.remove !== "string" || body.remove.trim().length === 0) { - return jsonResponse({ error: "body.remove must be a non-empty role id" }, 400); + const projected = projectConfigRebaseProvenance(draft); + const touched = [ + ...(updatesRoster ? ["subagentModels" as const] : []), + ...(updatesPicker ? ["modelPickerOrder" as const, "modelPickerOrderMode" as const] : []), + "configRebaseProvenance" as const, + ]; + const rollback = captureConfigTopLevelRollback(config, touched); + try { + for (const key of touched) { + if (Object.hasOwn(projected, key)) Object.defineProperty(config, key, { + value: projected[key], writable: true, enumerable: true, configurable: true, + }); + else deleteConfigTopLevelKey(config, key); } - const id = body.remove.trim(); - config.subagentRoles = (config.subagentRoles ?? []).filter(role => role.id !== id); - saveManagementConfig(deps, config); - const warnings = [...syncCodexAgentRoles(config).warnings]; - const catalogRefresh = await convergeCodexCatalog(); + (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config); + } catch (error) { + rollback(); + throw error; + } + // Capture the result before convergence yields to another settings mutation. + const saved = { + applied: [...(config.subagentModels ?? [])], + pickerOrder: [...(config.modelPickerOrder ?? [])], + pickerOrderMode: config.modelPickerOrderMode ?? null, + }; + const catalogRefresh = await convergeCodexCatalog(); + if (updatesRoster) { await syncClaudeAgentDefsBestEffort(); await autoApplyDesktopBestEffort(); - return jsonResponse({ - ok: true, - roles: config.subagentRoles, - ...(config.syncCodexAgentRoles === undefined ? {} : { syncCodexAgentRoles: config.syncCodexAgentRoles }), - syncCodexAgentRolesEffective: agentRolesSyncEffective(config), - warnings, - catalogRefresh, - }); - } - if (!("roles" in body)) return jsonResponse({ error: "body.roles is required" }, 400); - const parsed = parseSubagentRoles(body.roles); - if (!parsed.ok) return jsonResponse({ error: parsed.error, index: parsed.index }, 400); - if ("syncCodexAgentRoles" in body && body.syncCodexAgentRoles !== null && typeof body.syncCodexAgentRoles !== "boolean") { - return jsonResponse({ error: "syncCodexAgentRoles must be a boolean" }, 400); } - const warnings: string[] = []; - const union = unionRoleModelsIntoRoster(config.subagentModels, parsed.roles); - if (union.droppedRoleIds.length > 0) { - warnings.push(`Featured roster truncated to 5 models; dropped role id(s): ${union.droppedRoleIds.join(", ")}`); - } - warnings.push(...routedOnV2Warnings(parsed.roles, config)); - config.subagentRoles = parsed.roles; - config.subagentModels = union.models; - if ("syncCodexAgentRoles" in body && typeof body.syncCodexAgentRoles === "boolean") { - config.syncCodexAgentRoles = body.syncCodexAgentRoles; - } - saveManagementConfig(deps, config); - warnings.push(...syncCodexAgentRoles(config).warnings); - const catalogRefresh = await convergeCodexCatalog(); - await syncClaudeAgentDefsBestEffort(); - await autoApplyDesktopBestEffort(); - return jsonResponse({ - ok: true, - roles: config.subagentRoles, - ...(config.syncCodexAgentRoles === undefined ? {} : { syncCodexAgentRoles: config.syncCodexAgentRoles }), - syncCodexAgentRolesEffective: agentRolesSyncEffective(config), - warnings, - catalogRefresh, - }); + return jsonResponse({ ok: true, ...saved, catalogRefresh }); } // Priority-ordered subagent model fallback chain for quota-aware spawn routing. @@ -1143,7 +821,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise else deleteConfigTopLevelKey(config, "subagentModelFallback"); if (nextPollMs !== undefined) config.subagentModelFallbackPollMs = nextPollMs; else deleteConfigTopLevelKey(config, "subagentModelFallbackPollMs"); - saveManagementConfig(deps, config); + saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, models: config.subagentModelFallback ?? [], @@ -1185,7 +863,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (excluded.length > 2000) return jsonResponse({ error: "excluded list is too large" }, 400); if (excluded.length === 0) deleteConfigTopLevelKey(config, "grokExcludedModels"); else config.grokExcludedModels = excluded; - saveManagementConfig(deps, config); + saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, excluded }); } @@ -1226,35 +904,27 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); const parsed = parseDesktopProfile(body.profile); const current = await buildClaudeDesktopState(config); - const catalogChanged = (route: string, message: string) => { - const runtimePort = Number(url.port) || config.port; - return jsonResponse({ - error: { code: "catalog_changed", message, route }, - current: { ...current, port: runtimePort }, - }, 409); - }; for (const model of current.models.filter(item => !item.available)) { const before = current.profile.assignments[model.route]; const after = parsed.assignments[model.route]; if (JSON.stringify(before) !== JSON.stringify(after)) { - return catalogChanged(model.route, `현재 사용할 수 없는 모델은 옮길 수 없습니다: ${model.route}`); + throw new Error(`현재 사용할 수 없는 모델은 옮길 수 없습니다: ${model.route}`); } } for (const family of ["opus", "fable", "sonnet", "haiku"] as const) { const nextDefault = parsed.defaults[family]; const target = nextDefault ? current.models.find(model => model.route === nextDefault) : undefined; if (target && !target.available && current.profile.defaults[family] !== nextDefault) { - return catalogChanged(target.route, `현재 사용할 수 없는 모델은 기본값으로 지정할 수 없습니다: ${nextDefault}`); + throw new Error(`현재 사용할 수 없는 모델은 기본값으로 지정할 수 없습니다: ${nextDefault}`); } } const state = await buildClaudeDesktopState(config, parsed); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconcileDesktopProfile(state.profile, state.models) }; - saveManagementConfig(deps, config); + saveConfigPreservingClaudeCode(config); const saved = await buildClaudeDesktopState(config); const runtimePort = Number(url.port) || config.port; return jsonResponse({ ok: true, ...saved, port: runtimePort }); } catch (error) { - if (error instanceof MissingManagementPersistenceError || error instanceof ManagementPersistenceError) throw error; return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } } @@ -1303,7 +973,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // its stale `clientIntegrations` back over that write and turn the enable // action into an immediate self-cancelling OFF — the guard below would then // refuse the apply it was asked to perform. Persist ONLY the profile field. - const profileSaved = persistDesktopProfileField(deps, config, state.profile); + const profileSaved = persistDesktopProfileField(config, state.profile); if (!profileSaved.ok) { return jsonResponse({ error: `Claude Desktop profile could not be saved (${profileSaved.reason}); nothing was applied.`, @@ -1351,7 +1021,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (result.fingerprint) { // The Desktop write already landed, so a failed bookkeeping save is not // an apply failure: report the miss instead of claiming a clean apply. - const marked = persistDesktopProfileField(deps, config, { + const marked = persistDesktopProfileField(config, { ...state.profile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString(), @@ -1780,6 +1450,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise else delete next.modelMap; } } + if (body.fastMode !== undefined) config.fastMode = nextFastMode; + config.claudeCode = next; // Stamp the migration sentinel on EVERY persist of this block. The migration reads // "a claudeCode block with no authMode" as a pre-upgrade subscriber and pins it to // literal subscription — correct for a config written before `auto` existed, fatal @@ -1788,46 +1460,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // would be converted into a sticky manual subscription by the next startServer, and // auto would survive exactly one proxy lifetime with no way back. if (!next.authModeMigratedAt) next.authModeMigratedAt = new Date().toISOString(); - let committedClaude!: OcxClaudeCodeConfig; - const persisted = mutateManagementConfig(deps, disk => { - const latest = { ...(disk.claudeCode ?? {}) }; - for (const field of ["enabled", "authMode", "model", "smallFastModel", "modelMap", "classifierModel", "classifierFallbacks", "systemEnv", "alwaysEnableEffort", "maxContextTokens", "autoContext", "injectAgents", "autoCompactWindow", "blockedSkills", "tierModels"] as const) { - if (!Object.hasOwn(body, field)) continue; - if (Object.hasOwn(next, field)) latest[field] = next[field] as never; - else delete latest[field]; - } - for (const field of ["webSearchSidecar", "visionSidecar"] as const) { - const section = body[field]; - if (section === undefined) continue; - if (section === null || Object.keys(section as Record).length === 0) { - delete latest[field]; - continue; - } - const override = { ...latest[field] } as { backend?: string; model?: string }; - const desired = next[field] as { backend?: string; model?: string } | undefined; - for (const key of ["backend", "model"] as const) { - if (!Object.hasOwn(section, key)) continue; - if (Object.hasOwn(desired ?? {}, key)) override[key] = desired![key]; - else delete override[key]; - } - if (Object.keys(override).length > 0) latest[field] = override as never; - else delete latest[field]; - } - latest.authModeMigratedAt = next.authModeMigratedAt; - disk.claudeCode = latest; - committedClaude = structuredClone(latest); - if (body.fastMode !== undefined) { - if (nextFastMode === undefined) delete disk.fastMode; - else disk.fastMode = nextFastMode; - } - return { changed: true, value: true }; - }); - if (persisted.status === "unavailable") return jsonResponse({ error: "management persistence unavailable" }, 500, req, config); - config.claudeCode = committedClaude; - if (body.fastMode !== undefined) { - if (nextFastMode === undefined) deleteConfigTopLevelKey(config, "fastMode"); - else config.fastMode = nextFastMode; - } + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + save(config); const warnings: string[] = []; // authMode changes must reconcile the injected system env too: switching back to // Subscription has to remove the opencodex-owned dummy ANTHROPIC_AUTH_TOKEN diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts new file mode 100644 index 0000000000..8047e6c7a8 --- /dev/null +++ b/src/server/management/aside-profile-routes.ts @@ -0,0 +1,231 @@ +import { redactSecretString } from "../../lib/redact"; +import { ClientPathError } from "../../clients/config-export"; +import { IntegrationMutationBusyError } from "../../integrations/mutation-flight"; +import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock"; +import { + getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles, + type AsideProfilesInput, +} from "../../integrations/aside-profiles"; +import { + listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation, + asideOperationMatchesCurrent, +} from "../../integrations/aside-profile-journal"; +import type { WriteRefused } from "../../integrations/writer"; +import type { ManagementContext } from "./context"; +import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { jsonResponse } from "../auth-cors"; + +export interface AsideProfileRouteOptions { + input: () => AsideProfilesInput; + failure: (result: WriteRefused) => Response; +} + +class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; } + +const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside"; +const ASIDE_PROFILES_PATH = "/api/client-integrations/aside/profiles"; + +function profileId(ctx: ManagementContext): number | undefined { + const raw = ctx.url.searchParams.get("profile"); + if (raw === null) return undefined; + if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) { + throw new ProfileQueryError("profile must be a nonnegative integer account ID"); + } + return Number(raw); +} + +function errorResponse(error: unknown, ctx: ManagementContext): Response { + rethrowManagementBodyTooLarge(error); + const detail = error as { status?: unknown; code?: unknown } | null; + const busy = error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError; + const status = busy ? 409 : typeof detail?.status === "number" && [400,404,409,410,500].includes(detail.status) + ? detail.status : error instanceof ClientPathError ? 409 : 500; + const code = busy ? "integration_mutation_busy" + : typeof detail?.code === "string" ? detail.code : "aside_profile_error"; + return jsonResponse({ + error: redactSecretString(error instanceof Error ? error.message : "Aside profile operation failed"), + code, clientId: "aside", + }, status, ctx.req, ctx.config); +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +async function readProfileBody(req: Request, optional = false): Promise { + try { return await (optional ? readOptionalManagementJsonBody(req) : readManagementJsonBody(req)); } + catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } +} + +function validateClientSelector(ctx: ManagementContext): void { + const client = ctx.url.searchParams.get("client"); + if (client !== null && client !== "aside") throw new ProfileQueryError("client/profile selectors must identify Aside"); +} + +/** Dedicated scoped paths fail closed even when a newer client reaches an older server. */ +function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; action?: string } { + const prefix = "/api/client-integrations/aside/profiles/"; + if (!ctx.url.pathname.startsWith(prefix)) return { ctx }; + validateClientSelector(ctx); + const parts = ctx.url.pathname.slice(prefix.length).split("/"); + const url = new URL(ctx.url); + if (parts.length === 1 && parts[0] === "journal") { + if (url.searchParams.has("profile")) throw new ProfileQueryError("Use a profile-specific journal path"); + url.pathname = "/api/client-integrations/journal"; + url.searchParams.set("client", "aside"); + return { ctx: { ...ctx, url }, action: "journal" }; + } + if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) { + throw new ProfileQueryError("Invalid Aside profile path"); + } + const prior = url.searchParams.get("profile"); + if (prior !== null && prior !== parts[0]) throw new ProfileQueryError("Conflicting Aside profile selectors"); + url.searchParams.set("profile", parts[0]); + url.searchParams.set("client", "aside"); + url.pathname = parts[1] ? `/api/client-integrations/${parts[1]}` : "/api/client-integrations/aside"; + return { ctx: { ...ctx, url }, action: parts[1] }; +} + +/** Own only Aside status/toggle paths; other clients keep the existing adapter. */ +export async function handleAsideProfileRoutes( + ctx: ManagementContext, options: AsideProfileRouteOptions, +): Promise { + if (ctx.url.pathname !== ASIDE_INTEGRATION_PATH + && !ctx.url.pathname.startsWith(`${ASIDE_INTEGRATION_PATH}/`)) return null; + try { + const normalized = nestedProfileContext(ctx); + ctx = normalized.ctx; + const { req, url } = ctx; + validateClientSelector(ctx); + const id = profileId(ctx); + if (normalized.action === "journal") { + if (req.method === "GET") return asideJournalResponse(ctx, "aside", options); + if (req.method === "DELETE") { + const opId = url.searchParams.get("opId")?.trim(); + if (!opId) throw new ProfileQueryError("opId is required"); + return asideJournalDeleteResponse(ctx, opId, options); + } + return null; + } + if (normalized.action === "restore") { + if (req.method !== "POST") return null; + const body = await readProfileBody(req); + if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim() + || (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request"); + return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options); + } + if (url.pathname === "/api/client-integrations/aside/sync") { + if (req.method !== "POST") return null; + if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles"); + const body = await readProfileBody(req, true); + if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object"); + const results = await refreshAsideProfiles(options.input()); + const ok = results.every(result => result.ok); + return jsonResponse({ ok, clientId: "aside", results }, ok ? 200 : 207, req, ctx.config); + } + if (url.pathname !== ASIDE_INTEGRATION_PATH && url.pathname !== ASIDE_PROFILES_PATH) return null; + if (req.method !== "GET" && req.method !== "PUT") return null; + if (url.pathname.endsWith("/profiles") && id !== undefined) throw new ProfileQueryError("Use a profile-specific path"); + if (req.method === "GET") { + const state = id === undefined ? await listAsideProfileStates(options.input()) : await getAsideProfileState(options.input(), id); + return jsonResponse(state, 200, req, ctx.config); + } + const body = await readProfileBody(req); + if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean"); + if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean"); + if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); + const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }); + if (id !== undefined) { + const result = batch.results[0]; + if (!result) throw new Error("Aside profile mutation returned no result"); + return result.ok ? jsonResponse(result, 200, req, ctx.config) : options.failure(result); + } + return jsonResponse(batch, batch.ok ? 200 : 207, req, ctx.config); + } catch (error) { return errorResponse(error, ctx); } +} + +/** Profile-qualified history, including source-store provenance for imported legacy entries. */ +export async function asideJournalResponse( + ctx: ManagementContext, requestedClient: string | null, options: AsideProfileRouteOptions, +): Promise { + if (requestedClient === null && !ctx.url.searchParams.has("profile")) return null; + if (requestedClient !== null && requestedClient !== "aside") { + return ctx.url.searchParams.has("profile") ? errorResponse(new ProfileQueryError("profile applies only to Aside"), ctx) : null; + } + try { + const id = profileId(ctx); + if (id !== undefined && requestedClient !== "aside") throw new ProfileQueryError("profile requires client=aside"); + const input = options.input(); + const aside = await listAsideOperations(input, id); + const rows = [...aside].sort((a, b) => b.entry.at.localeCompare(a.entry.at)); + const newest = new Map(); + const ownerKey = (row: typeof rows[number]) => `${row.entry.clientId}:${row.profileId ?? row.entry.configPath}`; + for (const row of rows) if (!newest.has(ownerKey(row))) newest.set(ownerKey(row), row.entry.opId); + const operations = rows.map(row => { + const { entry, store } = row; + const snapshot = store.readSnapshot(entry).kind; + const latest = newest.get(ownerKey(row)) === entry.opId; + return { + opId: entry.opId, clientId: entry.clientId, kind: entry.kind, at: entry.at, + configPath: entry.configPath, snapshot, + ...(row.profileId !== undefined ? { profileId: row.profileId } : {}), + undoable: snapshot !== "expired" && latest && row.profileId !== undefined && asideOperationMatchesCurrent(input, row), + deletable: !latest, + }; + }); + return jsonResponse({ operations }, 200, ctx.req, ctx.config); + } catch (error) { + return requestedClient === null && !ctx.url.searchParams.has("profile") ? null : errorResponse(error, ctx); + } +} + +export async function asideRestoreResponse( + ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions, +): Promise { + try { + validateClientSelector(ctx); + const id = profileId(ctx); + const input = options.input(); + const rootEntry = input.store?.findOperation(body.opId); + if (rootEntry && rootEntry.clientId !== "aside") { + if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation"); + return null; + } + const operation = await findAsideOperation(input, body.opId, id); + if (!operation) { + if (id === undefined) return null; + return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config); + } + const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId }); + return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result); + } catch (error) { + if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") + && options.input().store?.findOperation(body.opId)?.clientId !== "aside") return null; + return errorResponse(error, ctx); + } +} + +export async function asideJournalDeleteResponse( + ctx: ManagementContext, opId: string, options: AsideProfileRouteOptions, +): Promise { + try { + validateClientSelector(ctx); + const id = profileId(ctx); + const input = options.input(); + const rootEntry = input.store?.findOperation(opId); + if (rootEntry && rootEntry.clientId !== "aside") { + if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation"); + return null; + } + const operation = await findAsideOperation(input, opId, id); + if (!operation) { + if (id === undefined) return null; + return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId }, 404, ctx.req, ctx.config); + } + return jsonResponse(await deleteAsideOperation(input, { opId, profileId: operation.profileId, principal: ctx.principal ?? "admin-token" }), 200, ctx.req, ctx.config); + } catch (error) { + if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") + && options.input().store?.findOperation(opId)?.clientId !== "aside") return null; + return errorResponse(error, ctx); + } +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 885b155e82..b9d6a5921b 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,3 +1,4 @@ +import type { IntegrationClientId } from "../../integrations/registry"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; @@ -8,6 +9,7 @@ import { deleteConfigTopLevelKey, hasOwnProvider, isValidProviderName, + loadConfig, multiAgentGuidanceEnabled, providerBaseUrlConfigError, providerHeadersConfigError, @@ -186,10 +188,11 @@ function serverSettings( /** One client's outcome from a fan-out sync. Absent from the list means "left alone". */ interface ClientIntegrationSyncOutcome { - readonly client: "grok" | "claude-desktop" | "mcode"; + readonly client: "grok" | "claude-desktop" | IntegrationClientId; readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; + readonly profileId?: number; } /** @@ -206,9 +209,10 @@ interface ClientIntegrationSyncOutcome { * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file * should surface as a warning, not as a 500 on a command that did its main job. */ -async function syncEnabledClientIntegrations( +export async function syncEnabledClientIntegrations( port: number | undefined, config: OcxConfig, + deps: Pick = {}, ): Promise { if (port === undefined) return []; const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } = await import("../../codex/desired-state"); @@ -231,49 +235,40 @@ async function syncEnabledClientIntegrations( const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); const { desktopVisibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog"); const { fetchAllModels } = await import("../management-api"); - const routed = filterCatalogVisibleModels(await fetchAllModels(config), config) - .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })); - const r = writeDesktop3pConfig( - port, - [...desktopVisibleNativeSlugs(config)], - routed, - config.apiKeys?.[0]?.key, - "static", - config.claudeCode?.desktopProfile, - nativeContextLimits(config), - ); - out.push(r.written - ? { client: "claude-desktop", ok: true, changed: true } - : { client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); + const models = await (deps.fetchAllModels ?? fetchAllModels)(config); + // Discovery admits a concurrent OFF or settings edit. Re-read outside C: + // the writer facade owns L and its final desired-state check under L→C. + const latest = loadConfig(); + if (claudeDesktopIntegrationEnabled(latest)) { + const routed = filterCatalogVisibleModels(models, latest) + .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })); + const r = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + port, + [...desktopVisibleNativeSlugs(latest)], + routed, + latest.apiKeys?.[0]?.key, + "static", + latest.claudeCode?.desktopProfile, + nativeContextLimits(latest), + ); + out.push(r.written + ? { client: "claude-desktop", ok: true, changed: true } + : { client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); + } } catch (error) { out.push({ client: "claude-desktop", ok: false, reason: error instanceof Error ? error.message : String(error) }); } } - try { - const { refreshOwnedIntegration } = await import("../../integrations/owned-refresh"); - const result = await refreshOwnedIntegration({ - clientId: "mcode", - models: async () => { - const { loadExportModels } = await import("./model-rows"); - return loadExportModels(config); - }, - config, - port, - }); - if (result) { - out.push(result.ok - ? { - client: "mcode", - ok: true, - changed: result.changed === true, - ...(result.reason ? { reason: result.reason } : {}), - } - : { client: "mcode", ok: false, reason: result.reason }); - } - } catch (error) { - out.push({ client: "mcode", ok: false, reason: error instanceof Error ? error.message : String(error) }); - } + const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh"); + out.push(...await refreshOwnedCatalogIntegrations({ + models: async () => { + const { loadExportModels } = await import("./model-rows"); + return loadExportModels(config); + }, + config, + port, + }, ["mcode", "pi", "aside"])); return out; } @@ -749,7 +744,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; + /** Isolates automatic owned-client writes in route tests. */ + refreshOwnedCatalogIntegrations?: typeof refreshOwnedCatalogIntegrations; /** Platform seam for capability projections; does not alter host-level startup behavior. */ platform?: NodeJS.Platform; toggleCodexMultiAgentV2?: (enabled: boolean) => void; diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 0d8a5c1e55..b332718e07 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -9,6 +9,12 @@ * Design of record: devlog/_fin/260802_client_toggle_api/040_wp4_management_api.md. */ import { readFileSync } from "node:fs"; +import { saveConfigPreservingClaudeCode } from "../../config"; +import { listAsideProfileStates, type AsideProfilesInput } from "../../integrations/aside-profiles"; +import { + handleAsideProfileRoutes, asideJournalResponse, asideRestoreResponse, + asideJournalDeleteResponse, type AsideProfileRouteOptions, +} from "./aside-profile-routes"; import type { IntegrationIO } from "../../integrations/config-io"; import { matchesOperationResult } from "../../integrations/journal"; import { @@ -41,6 +47,8 @@ import { loadExportModels } from "./model-rows"; const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/"; +const INTEGRATION_COLLECTION_PATH = "/api/client-integrations"; +const INTEGRATION_HISTORY_PATHS = ["/api/client-integrations/journal", "/api/client-integrations/restore"]; export { INTEGRATION_MUTATION_TERMINAL_MS }; type IntegrationStateRecord = Awaited>; @@ -85,6 +93,7 @@ export interface IntegrationJournalRow { * is what a user reaches for right after the mistake. */ deletable: boolean; + profileId?: number; } export interface IntegrationToggleBody { @@ -181,6 +190,23 @@ function integrationStore(): IntegrationStateStore { return integrationMutationTestHooks?.store ?? createIntegrationStateStore(); } +function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions { + let input: AsideProfilesInput | undefined; + return { + input: () => input ??= { + config: ctx.config, + port: Number(ctx.url.port) || ctx.config.port, + models: () => loadExportModels(ctx.config), + store: integrationStore(), + ...pathOverrides(), + io: integrationMutationTestHooks?.io, + lockSeams: integrationMutationTestHooks?.lockSeams, + persistConfig: ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode, + }, + failure: result => writerFailureResponse("aside", result, ctx), + }; +} + async function buildIntegrationWriteInput( clientId: IntegrationClientId, ctx: ManagementContext, @@ -331,6 +357,8 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { code: "invalid_op_id", }, 400, req, ctx.config); } + const aside = await asideJournalDeleteResponse(ctx, opId, asideOptions(ctx)); + if (aside) return aside; try { const store = integrationStore(); const operation = store.findOperation(opId); @@ -392,6 +420,14 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url } = ctx; + const profileOptions = asideOptions(ctx); + const aside = await handleAsideProfileRoutes(ctx, profileOptions); + if (aside) return aside; + if (url.searchParams.has("profile") + && (url.pathname === INTEGRATION_COLLECTION_PATH || url.pathname.startsWith(INTEGRATION_ROUTE_PREFIX)) + && !INTEGRATION_HISTORY_PATHS.includes(url.pathname)) { + return jsonResponse({ error: "profile applies only to Aside", code: "invalid_aside_profile" }, 400, req, ctx.config); + } if (url.pathname === "/api/client-integrations" && req.method === "GET") { try { @@ -408,8 +444,13 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise - readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() })); + const clients = await Promise.all(INTEGRATION_CLIENT_IDS.map(async clientId => { + if (clientId === "aside") { + try { return await listAsideProfileStates({ ...profileOptions.input(), models }); } + catch { /* Existing status projection retains a safe unresolved-path diagnostic. */ } + } + return readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() }); + })); return jsonResponse({ clients } satisfies IntegrationStateListEnvelope, 200, req, ctx.config); } catch (error) { return internalErrorResponse(error, ctx); @@ -426,6 +467,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + let operations: IntegrationJournalRow[] = storedOperations.map(operation => { /* * Resolved against the DISK, not read off the row. * @@ -481,6 +524,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise row.clientId !== "aside"), ...body.operations] + .sort((a, b) => b.at.localeCompare(a.at)); + } + } return jsonResponse({ operations } satisfies IntegrationJournalEnvelope, 200, req, ctx.config); } catch (error) { return internalErrorResponse(error, ctx); @@ -506,6 +557,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise deps.storageCleanupPolicyJob?.getState() ?? { status: "idle" as const }; if (url.pathname === "/api/logs" && req.method === "GET") { + const rawCursor = url.searchParams.get("cursor"); + const cursor = rawCursor === null ? null : decodeRequestLogCursor(rawCursor); + if (rawCursor !== null && cursor === null) { + return jsonResponse({ error: { code: "invalid_cursor", message: "invalid cursor" } }, 400); + } const all = getRequestLogEntries(); const total = filteredRequestLogCount(all, url.searchParams); - const logs = filterRequestLogs(all, url.searchParams); + const logs = filterRequestLogs(all, url.searchParams).map(requestLogDto); + const poll = selectRequestLogPoll(logs, url.searchParams, cursor); return jsonResponse({ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + generatedAt: Date.now(), total, - logs: logs.map(requestLogDto), + ...poll, }); } diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 27aca949cb..28d1bef0ec 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -import { isDeepStrictEqual } from "node:util"; /** * Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum @@ -71,9 +70,11 @@ function readDefaultReasoningEffort(raw: unknown, efforts: string[] | undefined) return { value: raw }; } import type { CatalogModel } from "../../codex/catalog"; -import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; import { clearModelCache, getProviderLiveModelCount } from "../../codex/model-cache"; +import { NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; + import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -83,8 +84,8 @@ import { multiAgentGuidanceEnabled, providerBaseUrlConfigError, providerHeadersConfigError, + saveConfigPreservingClaudeCode, } from "../../config"; -import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits"; import { clearLoginState, getLoginStatus, @@ -102,6 +103,7 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec"; import { knownModelIdsForProvider } from "../../router"; import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases"; +import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits"; import { comboPublicModelId } from "../../combos/types"; import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -150,7 +152,7 @@ import type { import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; -import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context"; +import type { ManagementContext } from "./context"; import { listManagementModelRows, loadExportModels } from "./model-rows"; import { initialModelSelectionPending } from "../../providers/initial-model-selection"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -174,139 +176,24 @@ function summarizeExportedModels(client: ExportClientId, document: unknown): { m return EXPORT_CLIENTS[client].summarize(document); } -type ModelMutationValue = - | { config: OcxConfig; alias?: string | null; aliases?: Record; selected?: string[] } - | { error: string; code?: string; conflicts?: Array<{ alias: string; heldBy: string }>; status?: number }; - -function providerDiscoveryFingerprint(provider: OcxProviderConfig): OcxProviderConfig { - return structuredClone(provider); -} - -function adoptCommittedConfig(target: OcxConfig, source: OcxConfig): void { - for (const key of Object.keys(target)) delete (target as unknown as Record)[key]; - Object.assign(target, structuredClone(source)); -} - -function unavailableMutationResponse(reason: "missing" | "invalid" | "conflict", req: Request, config: OcxConfig): Response { - const message = reason === "conflict" - ? "config changed while applying this update; retry" - : `config is ${reason}`; - return jsonResponse({ error: message }, reason === "conflict" ? 409 : 500, req, config); -} - -function applyModelVisibility( - config: OcxConfig, - scope: "models" | "provider", - provider: string, - enabled: boolean, - rawTargets: unknown[], -): { ok: true; disabled: string[] } | { ok: false; error: string; status?: number; code?: string } { - const providerConfig = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined; - if (initialModelSelectionPending(providerConfig)) { - return { - ok: false, - error: "Initial model discovery is pending. Refresh the model list and retry.", - status: 409, - code: "initial_model_selection_pending", - }; - } - const isVirtualComboNamespace = provider === COMBO_NAMESPACE && !preservesPhysicalComboProvider(config); - if (!providerConfig && provider !== "openai" && !isVirtualComboNamespace) { - return { ok: false, error: "unknown model visibility provider" }; - } - const accountNativeQualified = shouldIncludeAccountBoundNativeOpenAi(config) - ? [...accountBoundNativeOpenAiSlugsBySelector(config).entries()].flatMap(([selector, slugs]) => - slugs.filter(slug => !nativeModelRows(config).some(row => row.slug === slug)).map(slug => `${selector}/${slug}`)) - : []; - const supportedNative = new Set([ - ...nativeModelRows(config).map(row => row.slug), - ...accountNativeQualified, - ...NATIVE_OPENAI_MODELS, - ]); - const targets: Array<{ id: string; native: boolean }> = []; - const seen = new Set(); - for (const value of rawTargets) { - if (!isPlainRecord(value) || typeof value.id !== "string" || (value.native !== undefined && typeof value.native !== "boolean")) { - return { ok: false, error: "invalid model visibility target" }; - } - const id = value.id.trim(); - const native = value.native === true; - if (!id || (provider === "openai") !== native || (native && !supportedNative.has(id))) { - return { ok: false, error: "invalid model visibility target" }; - } - const key = `${native ? "native" : "routed"}:${id}`; - if (!seen.has(key)) { - seen.add(key); - targets.push({ id, native }); - } - } - if (targets.length === 0) return { ok: false, error: "model visibility targets required" }; - - const knownComboSelectors = new Set( - Object.entries(config.combos ?? {}).flatMap(([id, combo]) => comboDisabledModelSelectors(id, combo)), - ); - const targetComboSelectors = new Map>(); - if (isVirtualComboNamespace) { - for (const target of targets) { - const combo = config.combos && Object.hasOwn(config.combos, target.id) ? config.combos[target.id] : undefined; - if (!combo) return { ok: false, error: "invalid model visibility target" }; - targetComboSelectors.set(target.id, new Set(comboDisabledModelSelectors(target.id, combo))); - } - } - const matchesTarget = (stored: string, target: { id: string; native: boolean }) => target.native - ? stored === target.id || slugEquals(stored, "openai", target.id) - : isVirtualComboNamespace - ? targetComboSelectors.get(target.id)!.has(stored) - : slugEquals(stored, provider, target.id); - - let disabled = [...new Set(config.disabledModels ?? [])]; - if (enabled) { - if (scope === "provider") { - if (providerConfig && !isVirtualComboNamespace) delete providerConfig.selectedModels; - if (isVirtualComboNamespace) { - disabled = disabled.filter(stored => !knownComboSelectors.has(stored)); - } else { - const nativeIds = provider === "openai" ? disabledNativeSlugs({ disabledModels: disabled }) : new Set(); - const accountNativeIds = provider === "openai" ? new Set(accountNativeQualified) : new Set(); - const nativeAliasSlugs = provider === "openai" ? configuredNativeAliasSlugs(config) : new Set(); - disabled = disabled.filter(stored => ( - knownComboSelectors.has(stored) - || nativeAliasSlugs.has(stored) - || (!stored.startsWith(`${provider}/`) && !nativeIds.has(stored) && !accountNativeIds.has(stored)) - )); - } - } else { - if (!isVirtualComboNamespace && providerConfig?.selectedModels && providerConfig.selectedModels.length > 0) { - const additions = targets.filter(target => !target.native).map(target => target.id); - providerConfig.selectedModels = [...new Set([...providerConfig.selectedModels, ...additions])]; - } - disabled = disabled.filter(stored => !targets.some(target => matchesTarget(stored, target))); - const arrivals = config.modelDiscovery?.recentArrivals?.[provider]; - if (arrivals) config.modelDiscovery!.recentArrivals![provider] = arrivals.filter(row => ( - !targets.some(target => !target.native && target.id === row.id) - )); - } - } else { - for (const target of targets) { - const canonical = target.native - ? target.id - : isVirtualComboNamespace - ? comboModelId(target.id) - : routedSlug(provider, target.id); - if (!disabled.some(stored => matchesTarget(stored, target))) disabled.push(canonical); - } - } - config.disabledModels = disabled; - return { ok: true, disabled }; -} - export async function handleModelRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; // A handler persists the exact config object passed in. Production defaults to // the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not // bypass this seam with a dynamic config import — doing so replaced a user's // ~/.opencodex/config.json with the `existing-uuid` test fixture. - const persistConfig = (candidate: OcxConfig) => saveManagementConfig(deps, candidate); + const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + const convergeVisibleCatalogs = async () => { + const catalogRefresh = await convergeCodexCatalog(); + const refresh = deps.refreshOwnedCatalogIntegrations + ?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations; + const clientIntegrations = await refresh({ + config, + port: Number(url.port) || config.port, + models: () => loadExportModels(config), + }); + return { catalogRefresh, clientIntegrations }; + }; if (url.pathname === "/api/model-discovery" && req.method === "GET") { const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [ @@ -332,25 +219,16 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(deps, fresh => { - const target = fresh.providers[provider]; - if (!target) return { changed: false, value: { error: "unknown provider" } }; - target.newModelPolicy = policy; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, 404); - adoptCommittedConfig(config, outcome.value.config); + config.providers[provider].newModelPolicy = body.policy; } else { const wasAbsent = config.modelDiscovery?.newModelPolicy === undefined; config.modelDiscovery ??= {}; - config.modelDiscovery.newModelPolicy = policy; - if (policy === "off" && wasAbsent) { + config.modelDiscovery.newModelPolicy = body.policy; + if (body.policy === "off" && wasAbsent) { const models = await fetchAllModels(config); const known = config.modelDiscovery.knownModels ??= {}; const at = new Date().toISOString(); @@ -360,9 +238,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(deps, fresh => { - const provider = fresh.providers[name]; - if (!provider) return { changed: false, value: { error: `provider '${name}' not found`, status: 404 } }; - const lower = alias?.toLowerCase(); - const collision = lower && Object.entries(fresh.providers).find(([other, p]) => - other !== name && (other.toLowerCase() === lower || p.alias?.toLowerCase() === lower)); - const comboCollision = lower && Object.entries(fresh.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower); - const accountCollision = lower && Object.keys(fresh.codexAccountNamespaces ?? {}).find(value => value.toLowerCase() === lower); - if (collision || comboCollision || accountCollision) { - return { changed: false, value: { error: `alias conflicts with '${collision?.[0] ?? comboCollision?.[0] ?? accountCollision}'`, status: 409 } }; - } - if (alias) provider.alias = alias; else delete provider.alias; - return { changed: true, value: { config: structuredClone(fresh), alias } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 400, req, config); - adoptCommittedConfig(config, outcome.value.config); + const lower = alias?.toLowerCase(); + const collision = lower && Object.entries(config.providers).find(([other, p]) => + other !== name && (other.toLowerCase() === lower || p.alias?.toLowerCase() === lower)); + const comboCollision = lower && Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower); + const accountCollision = lower && Object.keys(config.codexAccountNamespaces ?? {}).find(value => value.toLowerCase() === lower); + if (collision || comboCollision || accountCollision) return jsonResponse({ error: `alias conflicts with '${collision?.[0] ?? comboCollision?.[0] ?? accountCollision}'` }, 409, req, config); + if (alias) provider.alias = alias; else delete provider.alias; + persistConfig(config); const catalogRefresh = await convergeCodexCatalog(); - return jsonResponse({ ok: true, provider: name, alias: outcome.value.alias ?? null, catalogRefresh }); + return jsonResponse({ ok: true, provider: name, alias, catalogRefresh }); } const modelAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-aliases$/); @@ -444,58 +313,36 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(deps, fresh => { - const provider = fresh.providers[name]; - if (!provider) return { changed: false, value: { error: `provider '${name}' not found` } }; - const next = { ...(provider.modelAliases ?? {}) }; - for (const id of (raw.remove ?? []) as unknown[]) if (typeof id === "string") delete next[id]; - const conflicts: Array<{ alias: string; heldBy: string }> = []; - const known = knownModelIdsForProvider(name, provider, fresh); - for (const [id, value] of Object.entries((raw.set ?? {}) as Record)) { - if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value)) return { changed: false, value: { error: `invalid model alias for '${id}'`, status: 400 } }; - const lower = value.toLowerCase(); - const heldBy = Object.entries(next).find(([other, alias]) => other !== id && alias.toLowerCase() === lower)?.[0] - ?? known.find(native => native.toLowerCase() === lower) - ?? Object.entries(fresh.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower)?.[0]; - if (heldBy || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) conflicts.push({ alias: value, heldBy: heldBy ?? "native OpenAI family" }); - else next[id] = value; - } - if (conflicts.length) return { changed: false, value: { error: "model alias collision", conflicts, status: 409 } }; - provider.modelAliases = next; - return { changed: true, value: { config: structuredClone(fresh), aliases: next } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ - error: outcome.value.error, - ...(outcome.value.conflicts ? { conflicts: outcome.value.conflicts } : {}), - }, outcome.value.status ?? 404, req, config); - adoptCommittedConfig(config, outcome.value.config); + const next = { ...(provider.modelAliases ?? {}) }; + for (const id of (raw.remove ?? []) as unknown[]) if (typeof id === "string") delete next[id]; + const conflicts: Array<{ alias: string; heldBy: string }> = []; + const known = knownModelIdsForProvider(name, provider, config); + for (const [id, value] of Object.entries((raw.set ?? {}) as Record)) { + if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value)) return jsonResponse({ error: `invalid model alias for '${id}'` }, 400, req, config); + const lower = value.toLowerCase(); + const heldBy = Object.entries(next).find(([other, alias]) => other !== id && alias.toLowerCase() === lower)?.[0] + ?? known.find(native => native.toLowerCase() === lower) + ?? Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower)?.[0]; + if (heldBy || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) conflicts.push({ alias: value, heldBy: heldBy ?? "native OpenAI family" }); + else next[id] = value; + } + if (conflicts.length) return jsonResponse({ error: "model alias collision", conflicts }, 409, req, config); + provider.modelAliases = next; + persistConfig(config); const catalogRefresh = await convergeCodexCatalog(); - return jsonResponse({ ok: true, aliases: outcome.value.aliases ?? {}, catalogRefresh }); + return jsonResponse({ ok: true, aliases: next, catalogRefresh }); } if (url.pathname === "/api/default-aliases" && req.method === "PUT") { let raw: unknown; try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } if (!isPlainRecord(raw) || typeof raw.enabled !== "boolean" || (raw.provider !== undefined && typeof raw.provider !== "string")) return jsonResponse({ error: "enabled must be boolean" }, 400, req, config); - const enabled = raw.enabled; if (typeof raw.provider === "string") { - const providerName = raw.provider; - const provider = config.providers[providerName]; - if (!provider) return jsonResponse({ error: `provider '${providerName}' not found` }, 404, req, config); - const outcome = mutateManagementConfig(deps, fresh => { - const target = fresh.providers[providerName]; - if (!target) return { changed: false, value: { error: `provider '${providerName}' not found` } }; - target.defaultAliases = enabled; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, 404, req, config); - adoptCommittedConfig(config, outcome.value.config); - } else { - config.defaultModelAliases = enabled; - persistConfig(config); - } + const provider = config.providers[raw.provider]; + if (!provider) return jsonResponse({ error: `provider '${raw.provider}' not found` }, 404, req, config); + provider.defaultAliases = raw.enabled; + } else config.defaultModelAliases = raw.enabled; + persistConfig(config); const catalogRefresh = await convergeCodexCatalog(); return jsonResponse({ ok: true, catalogRefresh }); } @@ -540,13 +387,18 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise, previousDisplayNames ?? {}); + const nextDisplayNames = Object.assign( + Object.create(null) as Record, + previousDisplayNames ?? {}, + ); if (displayName === null) delete nextDisplayNames[modelId]; else nextDisplayNames[modelId] = displayName; const mergedValidationError = modelDisplayNamesConfigError(nextDisplayNames); @@ -575,7 +427,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise ( - candidate.native !== true && candidate.custom !== true && candidate.provider === name && candidate.id === modelId + candidate.native !== true + && candidate.custom !== true + && candidate.provider === name + && candidate.id === modelId )); return jsonResponse({ ok: true, @@ -674,8 +529,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise typeof m === "string") : []; config.disabledModels = disabled; persistConfig(config); - const catalogRefresh = await convergeCodexCatalog(); - return jsonResponse({ ok: true, disabled, catalogRefresh }); + return jsonResponse({ ok: true, disabled, ...await convergeVisibleCatalogs() }); } // One user-facing visibility switch spans two persisted filters: a provider allowlist and the @@ -691,32 +545,116 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise + slugs.filter(slug => !nativeModelRows(config).some(row => row.slug === slug)).map(slug => `${selector}/${slug}`)) + : []; + const supportedNative = new Set([ + ...nativeModelRows(config).map(row => row.slug), + ...accountNativeQualified, + // A model suppressed by an unconfirmed entitlement roster is absent from + // nativeModelRows, so validating against those rows alone rejected a model this build + // knows perfectly well and left the operator with no way to clear its disable key + // (#2886). Accepting the target says "this build knows this model", not "this account + // may use it" — visibility only writes disabledModels and routing stays gated. + ...NATIVE_OPENAI_MODELS, + ]); + const targets: Array<{ id: string; native: boolean }> = []; + const seen = new Set(); + for (const value of body.targets) { + if (!isPlainRecord(value) || typeof value.id !== "string" || (value.native !== undefined && typeof value.native !== "boolean")) { + return jsonResponse({ error: "invalid model visibility target" }, 400); + } + const id = value.id.trim(); + const native = value.native === true; + const configuredOpenAiCustom = provider === "openai" && !native && providerConfig + && (config.customModels ?? []).some(model => model.provider === provider && model.modelId === id); + if (!id || (native && (provider !== "openai" || !supportedNative.has(id))) + || (provider === "openai" && !native && !configuredOpenAiCustom)) { + return jsonResponse({ error: "invalid model visibility target" }, 400); + } + const key = `${native ? "native" : "routed"}:${id}`; + if (!seen.has(key)) { + seen.add(key); + targets.push({ id, native }); + } + } + if (targets.length === 0) return jsonResponse({ error: "model visibility targets required" }, 400); + + const knownComboSelectors = new Set( + Object.entries(config.combos ?? {}).flatMap(([id, combo]) => ( + comboDisabledModelSelectors(id, combo) + )), + ); + const targetComboSelectors = new Map>(); + if (isVirtualComboNamespace) { + for (const target of targets) { + const combo = config.combos && Object.hasOwn(config.combos, target.id) ? config.combos[target.id] : undefined; + if (!combo) return jsonResponse({ error: "invalid model visibility target" }, 400); + targetComboSelectors.set(target.id, new Set(comboDisabledModelSelectors(target.id, combo))); + } + } + const matchesTarget = (stored: string, target: { id: string; native: boolean }) => target.native + ? stored === target.id + : isVirtualComboNamespace + ? targetComboSelectors.get(target.id)!.has(stored) + : slugEquals(stored, provider, target.id); + + let disabled = [...new Set(config.disabledModels ?? [])]; + if (body.enabled) { + if (scope === "provider") { + if (providerConfig && !isVirtualComboNamespace) delete providerConfig.selectedModels; + if (isVirtualComboNamespace) { + disabled = disabled.filter(stored => !knownComboSelectors.has(stored)); + } else { + const nativeIds = provider === "openai" + ? disabledNativeSlugs({ disabledModels: disabled }) + : new Set(); + const accountNativeIds = provider === "openai" ? new Set(accountNativeQualified) : new Set(); + const nativeAliasSlugs = provider === "openai" + ? configuredNativeAliasSlugs(config) + : new Set(); + disabled = disabled.filter(stored => ( + knownComboSelectors.has(stored) + || nativeAliasSlugs.has(stored) + || (!stored.startsWith(`${provider}/`) && !nativeIds.has(stored) && !accountNativeIds.has(stored)) + )); + } + } else { + if (!isVirtualComboNamespace && providerConfig?.selectedModels && providerConfig.selectedModels.length > 0) { + const additions = targets.filter(target => !target.native).map(target => target.id); + providerConfig.selectedModels = [...new Set([...providerConfig.selectedModels, ...additions])]; + } + disabled = disabled.filter(stored => !targets.some(target => matchesTarget(stored, target))); + const arrivals = config.modelDiscovery?.recentArrivals?.[provider]; + if (arrivals) config.modelDiscovery!.recentArrivals![provider] = arrivals.filter(row => ( + !targets.some(target => !target.native && target.id === row.id) + )); + } + } else { + for (const target of targets) { + const canonical = target.native + ? target.id + : isVirtualComboNamespace + ? comboModelId(target.id) + : routedSlug(provider, target.id); + const alreadyDisabled = disabled.some(stored => matchesTarget(stored, target)); + if (!alreadyDisabled) disabled.push(canonical); + } } - const outcome = mutateManagementConfig< - { config: OcxConfig; disabled: string[] } | { error: string; status?: number; code?: string } - >(deps, fresh => { - const applied = applyModelVisibility(fresh, scope, provider, body.enabled as boolean, body.targets as unknown[]); - if (!applied.ok) return { changed: false, value: { error: applied.error, status: applied.status, code: applied.code } }; - return { - changed: true, - value: { config: structuredClone(fresh), disabled: applied.disabled }, - }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ - error: outcome.value.error, - ...(outcome.value.code ? { code: outcome.value.code } : {}), - }, outcome.value.status ?? 400); - adoptCommittedConfig(config, outcome.value.config); - const disabled = outcome.value.disabled; - const catalogRefresh = await convergeCodexCatalog(); - return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, catalogRefresh }); + config.disabledModels = disabled; + persistConfig(config); + return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, ...await convergeVisibleCatalogs() }); } if (url.pathname === "/api/custom-models" && req.method === "GET") { @@ -922,57 +860,19 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(deps, fresh => { - const target = fresh.providers[provider]; - if (!target) return { changed: false, value: { error: "unknown provider" } }; - if (initialModelSelectionPending(target)) { - return { changed: false, value: { - error: "Initial model discovery is pending. Refresh the model list and retry.", - code: "initial_model_selection_pending", - status: 409, - } }; - } - // Same effect as today's empty-list PUT: no allowlist, no marker to reconcile. - delete target.selectedModels; - delete target.modelPreset; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ - error: outcome.value.error, - ...(outcome.value.code ? { code: outcome.value.code } : {}), - }, outcome.value.status ?? 404); - adoptCommittedConfig(config, outcome.value.config); - return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() }); + // Same effect as today's empty-list PUT: no allowlist, no marker to reconcile. + delete target.selectedModels; + delete target.modelPreset; + persistConfig(config); + return jsonResponse({ ok: true, provider, mode, selected: [], ...await convergeVisibleCatalogs() }); } if (mode === "custom") { - const outcome = mutateManagementConfig(deps, fresh => { - const target = fresh.providers[provider]; - if (!target) return { changed: false, value: { error: "unknown provider" } }; - if (initialModelSelectionPending(target)) { - return { changed: false, value: { - error: "Initial model discovery is pending. Refresh the model list and retry.", - code: "initial_model_selection_pending", - status: 409, - } }; - } - // Keep whatever is selected; only the marker changes, so a user can pin their edits - // without the proxy re-materializing over them. - target.modelPreset = { ...(target.modelPreset ?? {}), mode: "custom" }; - return { changed: true, value: { config: structuredClone(fresh), selected: [...(target.selectedModels ?? [])] } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ - error: outcome.value.error, - ...(outcome.value.code ? { code: outcome.value.code } : {}), - }, outcome.value.status ?? 404); - adoptCommittedConfig(config, outcome.value.config); - return jsonResponse({ ok: true, provider, mode, selected: outcome.value.selected }); - } - if (!hasModelPreset(provider)) { - return jsonResponse({ error: `no model preset is shipped for provider '${provider}'` }, 400); + // Keep whatever is selected; only the marker changes, so a user can pin their edits + // without the proxy re-materializing over them. + target.modelPreset = { ...(target.modelPreset ?? {}), mode: "custom" }; + persistConfig(config); + return jsonResponse({ ok: true, provider, mode, selected: [...(target.selectedModels ?? [])] }); } - const admittedProviderFingerprint = providerDiscoveryFingerprint(target); const models = await fetchAllModels(config); const catalogIds = models.filter(m => m.provider === provider).map(m => m.id); const presetIds = materializeModelPreset(provider, catalogIds); @@ -981,57 +881,35 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(deps, fresh => { - const target = fresh.providers[provider]; - if (!target) return { changed: false, value: { error: "unknown provider" } }; - if (!isDeepStrictEqual(providerDiscoveryFingerprint(target), admittedProviderFingerprint)) { - return { changed: false, value: { error: "provider changed during model discovery; retry", status: 409 } }; - } - target.modelPreset = { - mode: "all", - appliedVersion: preset.version, - appliedAt, - fallback: "preset-empty", - }; - return { changed: true, value: { config: structuredClone(fresh), selected: [...(target.selectedModels ?? [])] } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 404); - adoptCommittedConfig(config, outcome.value.config); + target.modelPreset = { + mode: "all", + appliedVersion: preset.version, + appliedAt: new Date().toISOString(), + fallback: "preset-empty", + }; + persistConfig(config); return jsonResponse({ ok: true, provider, mode: "all", fallback: "preset-empty", - selected: outcome.value.selected, + selected: [...(target.selectedModels ?? [])], }); } - const appliedAt = new Date().toISOString(); - const outcome = mutateManagementConfig(deps, fresh => { - const target = fresh.providers[provider]; - if (!target) return { changed: false, value: { error: "unknown provider" } }; - if (!isDeepStrictEqual(providerDiscoveryFingerprint(target), admittedProviderFingerprint)) { - return { changed: false, value: { error: "provider changed during model discovery; retry", status: 409 } }; - } - target.selectedModels = presetIds; - target.modelPreset = { - mode: "preset", - appliedVersion: preset.version, - appliedAt, - }; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 404); - adoptCommittedConfig(config, outcome.value.config); + target.selectedModels = presetIds; + target.modelPreset = { + mode: "preset", + appliedVersion: preset.version, + appliedAt: new Date().toISOString(), + }; + persistConfig(config); return jsonResponse({ ok: true, provider, mode: "preset", appliedVersion: preset.version, selected: presetIds, - catalogRefresh: await convergeCodexCatalog(), + ...await convergeVisibleCatalogs(), }); } if (url.pathname === "/api/selected-models" && req.method === "PUT") { @@ -1047,33 +925,15 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise typeof m === "string"))] : []; - const outcome = mutateManagementConfig(deps, fresh => { - const target = fresh.providers[provider]; - if (!target) return { changed: false, value: { error: "unknown provider" } }; - if (initialModelSelectionPending(target)) { - return { changed: false, value: { - error: "Initial model discovery is pending. Refresh the model list and retry.", - code: "initial_model_selection_pending", - status: 409, - } }; - } - // Empty list clears the allowlist (provider reverts to exposing all models). - if (models.length > 0) target.selectedModels = models; - else delete target.selectedModels; - // Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the - // provider is in preset mode makes the selection theirs, and the proxy must never - // re-materialize over it afterwards. - markModelPresetDiverged(target); - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ - error: outcome.value.error, - ...(outcome.value.code ? { code: outcome.value.code } : {}), - }, outcome.value.status ?? 404); - adoptCommittedConfig(config, outcome.value.config); - const catalogRefresh = await convergeCodexCatalog(); - return jsonResponse({ ok: true, provider, selected: models, catalogRefresh }); + // Empty list clears the allowlist (provider reverts to exposing all models). + if (models.length > 0) config.providers[provider].selectedModels = models; + else delete config.providers[provider].selectedModels; + // Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the + // provider is in preset mode makes the selection theirs, and the proxy must never + // re-materialize over it afterwards. + markModelPresetDiverged(config.providers[provider]); + persistConfig(config); + return jsonResponse({ ok: true, provider, selected: models, ...await convergeVisibleCatalogs() }); } return null; } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 4a3fbeaa64..6d9ec08853 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -11,6 +11,7 @@ import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, + filterCatalogVisibleModels, accountBoundNativeOpenAiSlugsBySelector, nativeDefaultReasoningEffort, NATIVE_OPENAI_MODELS, @@ -169,7 +170,11 @@ export async function listManagementModelRows( ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), }; }).filter((row): row is ManagementModelRow => row !== null); - const rows = [...native, ...dedupedRouted, ...visibleCustomModels]; + // Manual OpenAI rows retain their routed selector but replace the bare dashboard row. + // Account-qualified rows remain distinct, explicitly selected routes. + const visibleNative = native.filter(model => model.id.includes("/") + || !customNamespaced.has(routedSlug(model.provider, model.id))); + const rows = [...visibleNative, ...dedupedRouted, ...visibleCustomModels]; // Include disabled rows and configured aliases before the export visibility filter: // a hidden real `x--fast` must never become a synthetic selector for another model. const knownIds = config.fastRows === false ? new Set() : knownEffortRowIds(config); @@ -213,5 +218,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { */ export async function loadExportModels(config: OcxConfig): Promise { const rows = await listManagementModelRows(config); - return rows.filter(row => !row.disabled).map(toExportModel); + // Management deliberately lists the full roster so hidden models can be enabled. + // A client picker must also honor the provider selection, not just its blocklist. + const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); + return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index a7e19527f7..4aae612dd4 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -180,6 +180,11 @@ function validateKeyName( export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; + if (url.pathname === "/api/accounts/events" && req.method === "GET") { + const { accountSelectionStream } = await import("./account-selection-stream"); + return accountSelectionStream(req, () => ctx.sessionControl?.isCurrent(req, config) === true); + } + // Which providers support real OAuth login (drives the GUI's "Log in with …" buttons). if (url.pathname === "/api/oauth/providers" && req.method === "GET") { return jsonResponse({ providers: listOAuthProviders() }); @@ -371,6 +376,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400); const { setActiveAccount } = await import("../../oauth/store"); if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404); + const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover"); + forgetGenericFailoverRoster(provider); if (provider === "anthropic") { const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing"); resetAnthropicRoutingForManualSelection(body.accountId); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 0c43f845a6..37785ac17b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -12,12 +12,14 @@ import { isValidProviderName, modelDisplayNamesConfigError, multiAgentGuidanceEnabled, + mutatePersistedConfig, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, providerBaseUrlConfigError, providerHeadersConfigError, requestPacingConfigError, readConfigAdmissionSnapshot, + saveConfigPreservingClaudeCode, upstreamHttpVersionConfigError, validateConfigCandidate, withConfigMutationLockSync, @@ -33,14 +35,9 @@ import { } from "../../oauth"; import { replaceProviderAccountSet } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { - antigravityOAuthDestinationConfigError, - isAntigravityOAuthProvider, -} from "../../lib/provider-tls-profile"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { DAILY_ANTIGRAVITY_HOST, PROD_ANTIGRAVITY_HOST } from "../../adapters/google-antigravity-hosts"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; @@ -59,12 +56,11 @@ import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; -import { comboPublicModelId } from "../../combos/types"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; +import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, selectedProviderContextCaps, forgetProviderContextCap, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -98,12 +94,7 @@ import { type ProviderEditorProviderDTO, } from "../auth-cors"; import { providerServiceTierConfigError } from "./provider-capability-config"; -import { - maxWsFrameBytesConfigError, - providerEmptyToolOutputConfigError, - wsUpstreamConfigError, -} from "../../config/provider-validation"; -import { getProviderTlsProfileStatus } from "../../lib/provider-tls-profile"; +import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -117,14 +108,11 @@ import { xaiResponsesOptInState, } from "../../providers/xai-responses-opt-in"; import { dropProviderCustomModels } from "../../providers/provider-id-rewrite"; -import { apiKeyPoolEntryId } from "../../providers/api-keys"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; -import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context"; +import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; -import { resolveAiStudioCredentials } from "../../oauth/aistudio-credentials"; -import { buildAiStudioHeaders, parseGoogleCookieJar } from "../../oauth/google-aistudio-auth"; type ProviderPatchApplication = | { error: string } @@ -136,132 +124,6 @@ type ProviderPatchApplication = headersTouched: boolean; }; -type ProviderMutationValue = - | { config: OcxConfig; fallbackDefault?: string; droppedCustomModels?: number } - | { error: string; code?: string; status?: number; combos?: string[]; routingProfiles?: string[] }; - -function reconcileSubmittedApiKey(provider: OcxProviderConfig): string | undefined { - if (!provider.apiKey || !provider.apiKeyPool) return; - const key = provider.apiKey.trim(); - if (!key || /[\r\n]/.test(key)) return; - if (provider.apiKeyPool.some(entry => entry.key === key)) { - provider.apiKey = key; - return; - } - const id = apiKeyPoolEntryId(key); - if (provider.apiKeyPool.some(entry => entry.id === id)) return "API-key pool ID collision"; - provider.apiKeyPool.push({ id, key, addedAt: Date.now() }); - provider.apiKey = key; -} - -function providerNamespaceCollisionError(config: OcxConfig, name: string): string | undefined { - const accountCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); - if (accountCollision) return accountCollision; - const comboCollision = (name === "combo" && Object.keys(config.combos ?? {}).length > 0) - || Object.entries(config.combos ?? {}).some(([id, combo]) => { - const publicId = comboPublicModelId(id, combo); - return id === name || publicId === name || publicId.startsWith(`${name}/`); - }); - if (comboCollision) return "provider name must not collide with a configured combo namespace"; - const profileCollision = Object.values(config.routingProfiles ?? {}).some(profile => { - const alias = profile.alias?.trim(); - return alias === name || alias?.startsWith(`${name}/`); - }); - return profileCollision - ? "provider name must not collide with a configured routing profile namespace" - : undefined; -} - -function adoptCommittedConfig(target: OcxConfig, source: OcxConfig): void { - for (const key of Object.keys(target)) delete (target as unknown as Record)[key]; - Object.assign(target, structuredClone(source)); -} - -function unavailableMutationResponse(reason: "missing" | "invalid" | "conflict", req: Request, config: OcxConfig): Response { - const message = reason === "conflict" ? "config changed while applying this update; retry" : `config is ${reason}`; - return jsonResponse({ error: message }, reason === "conflict" ? 409 : 500, req, config); -} - -const AI_STUDIO_REAUTH_ERROR = "Session expired or missing — re-authentication required"; -const AI_STUDIO_PROBE_TIMEOUT_MS = 8_000; -const AI_STUDIO_PROBE_MODEL = "gemini-2.5-flash"; -const AI_STUDIO_ORIGIN = "https://aistudio.google.com"; - -let aiStudioProbeFetchForTests: typeof fetch | undefined; - -export function setAiStudioProbeFetchForTests(fetchImpl?: typeof fetch): void { - aiStudioProbeFetchForTests = fetchImpl; -} - -function isAiStudioHtmlSignIn(text: string): boolean { - const lower = text.trim().toLowerCase(); - return lower.startsWith(" { - const credentials = resolveAiStudioCredentials(prov); - if (credentials.kind !== "ready") { - return { ok: false, latencyMs: 0, error: AI_STUDIO_REAUTH_ERROR }; - } - const base = (prov.baseUrl || "https://alkalimakersuite-pa.clients6.google.com").replace(/\/+$/, ""); - const url = base + "/v1internal:generateContent"; - const jar = parseGoogleCookieJar(credentials.cookieHeader); - const headers = await buildAiStudioHeaders(jar, AI_STUDIO_ORIGIN); - const body = JSON.stringify({ - model: AI_STUDIO_PROBE_MODEL, - contents: [{ role: "user", parts: [{ text: "ping" }] }], - generationConfig: { maxOutputTokens: 1 }, - }); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), AI_STUDIO_PROBE_TIMEOUT_MS); - const started = Date.now(); - try { - const outboundProvider = aiStudioProbeFetchForTests - ? { ...prov, fetch: aiStudioProbeFetchForTests } - : prov; - const response = await providerOutboundPost(name, outboundProvider, url, { - headers, - body, - signal: controller.signal, - }); - const latencyMs = Date.now() - started; - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); - const text = await response.text().catch(() => ""); - - if ((response.status >= 300 && response.status < 400) || response.status === 401 || response.status === 403) { - return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR }; - } - if (contentType.includes("text/html") || isAiStudioHtmlSignIn(text)) { - return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR }; - } - if (response.status !== 200) { - return { ok: false, latencyMs, error: "AI Studio connection probe failed" }; - } - try { - JSON.parse(text); - } catch { - return { ok: false, latencyMs, error: "AI Studio connection probe failed" }; - } - return { - ok: true, - latencyMs, - authState: "connected", - message: "AI Studio session verified", - }; - } catch (error) { - if (error instanceof ProviderOutboundPolicyError && /\breturned 3\d\d redirect\b/.test(error.message)) { - return { ok: false, latencyMs: Date.now() - started, error: AI_STUDIO_REAUTH_ERROR }; - } - return { ok: false, latencyMs: Date.now() - started, error: "AI Studio connection probe failed" }; - } finally { - clearTimeout(timer); - } -} - const PROVIDER_ALIAS_OVERLAY_FIELDS = ["alias", "modelAliases", "defaultAliases"] as const; type ProviderAliasOverlayField = typeof PROVIDER_ALIAS_OVERLAY_FIELDS[number]; @@ -406,7 +268,7 @@ function providerEditorCandidate( candidate.providers = providers; for (const name of removedProviders) { dropProviderCustomModels(candidate, name); - setProviderContextCap(candidate, name, false); + forgetProviderContextCap(candidate, name); } const validated = validateConfigCandidate(candidate); if (!validated.ok) { @@ -427,6 +289,8 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo else live.customModels = structuredClone(persisted.customModels); if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; else live.providerContextCaps = structuredClone(persisted.providerContextCaps); + if (persisted.providerContextCapValues === undefined) delete live.providerContextCapValues; + else live.providerContextCapValues = structuredClone(persisted.providerContextCapValues); if (persisted.disabledModels === undefined) delete live.disabledModels; else live.disabledModels = [...persisted.disabledModels]; if (persisted.modelDiscovery === undefined) delete live.modelDiscovery; @@ -500,20 +364,6 @@ function applyProviderPatchFields( return { error: "apiKeyTransport must be x-api-key, bearer, or empty to clear" }; } } - if (Object.hasOwn(rawBody, "azureCredential")) { - const value = rawBody.azureCredential; - if (value === null) { - delete next.azureCredential; - } else { - if (!isPlainRecord(value)) return { error: "azureCredential must be an object or null" }; - const credential = structuredClone(value) as Record; - if (typeof credential.managedIdentityClientId === "string") { - credential.managedIdentityClientId = credential.managedIdentityClientId.trim(); - } - next.azureCredential = credential as OcxProviderConfig["azureCredential"]; - } - touched = true; - } if (Object.hasOwn(rawBody, "note")) { if (typeof rawBody.note !== "string") return { error: "note must be a string" }; const note = rawBody.note.trim(); @@ -542,28 +392,6 @@ function applyProviderPatchFields( } touched = true; } - if (Object.hasOwn(rawBody, "wsUpstream")) { - const value = rawBody.wsUpstream; - if (value === null) { - delete next.wsUpstream; - } else { - const error = wsUpstreamConfigError(value); - if (error) return { error }; - next.wsUpstream = value as boolean; - } - touched = true; - } - if (Object.hasOwn(rawBody, "maxWsFrameBytes")) { - const value = rawBody.maxWsFrameBytes; - if (value === null) { - delete next.maxWsFrameBytes; - } else { - const error = maxWsFrameBytesConfigError(value); - if (error) return { error }; - next.maxWsFrameBytes = value as number; - } - touched = true; - } if (Object.hasOwn(rawBody, "xaiResponsesOptIn")) { if (name !== "xai") return { error: "xaiResponsesOptIn is valid only for provider xai" }; if (typeof rawBody.xaiResponsesOptIn !== "boolean") { @@ -593,17 +421,6 @@ function applyProviderPatchFields( } touched = true; } - if (Object.hasOwn(rawBody, "tlsProfile")) { - const value = rawBody.tlsProfile; - if (value === null || value === "") { - delete next.tlsProfile; - } else if (value === "antigravity-browser") { - next.tlsProfile = value; - } else { - return { error: "tlsProfile must be antigravity-browser or null" }; - } - touched = true; - } if (Object.hasOwn(rawBody, "upstreamHttpVersion")) { const value = rawBody.upstreamHttpVersion; if (value === null || value === "") { @@ -867,15 +684,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0, allowPrivateNetwork: p.allowPrivateNetwork === true, - replayTransientFailures: p.replayTransientFailures === true, liveModels: p.liveModels !== false, requestPacing: p.requestPacing, models: p.models ?? [], contextWindow: p.contextWindow, modelContextWindows: p.modelContextWindows, modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits, - wsUpstream: p.wsUpstream, - maxWsFrameBytes: p.maxWsFrameBytes, modelSupportsServiceTier: p.modelSupportsServiceTier, noStructuredOutputModels: p.noStructuredOutputModels, retainModels: p.retainModels, @@ -884,8 +698,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(deps, persisted => { + const outcome = mutatePersistedConfig(persisted => { if (!isDeepStrictEqual(providerEditorConfigDTO(persisted), baselineResult.value)) { return { changed: false, @@ -1039,7 +851,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; - const submittedCredential = isPlainRecord(submittedProvider.azureCredential) - ? submittedProvider.azureCredential as Record - : undefined; - if (typeof submittedCredential?.managedIdentityClientId === "string") { - submittedCredential.managedIdentityClientId = submittedCredential.managedIdentityClientId.trim(); - } const existing = config.providers[name]; const aliasOwnershipError = providerAliasOverlayOwnershipError(body.provider, existing); if (aliasOwnershipError) return jsonResponse({ error: aliasOwnershipError }, 400); @@ -1104,8 +909,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise).upstreamWebsocket === null) delete (prov as unknown as Record).upstreamWebsocket; if (!name || !prov?.adapter || !prov?.baseUrl) { return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400); } @@ -1114,7 +917,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(deps, fresh => { - const namespaceCollision = providerNamespaceCollisionError(fresh, name); - if (namespaceCollision) { - return { changed: false, value: { error: namespaceCollision, status: 409 } }; + // The add/edit form omits wire choices. Read after DNS so a concurrent switch + // remains authoritative, including the marker that protects it on the next boot. + if (name === "xai") { + const latest = config.providers[name]; + if (!Object.hasOwn(body.provider, "modelAdapters") && latest?.modelAdapters) { + prov.modelAdapters = { ...latest.modelAdapters }; } - const persisted = fresh.providers[name]; - const nextSubmitted = structuredClone(submitted); - // The editor omits dedicated alias and xAI wire-choice state. Re-read it under - // the persistence mutation so a concurrent switch remains authoritative. - restorePersistedAliasOverlays(nextSubmitted, persisted); - if (name === "xai") { - if (!Object.hasOwn(body.provider as object, "modelAdapters") && persisted?.modelAdapters) { - nextSubmitted.modelAdapters = { ...persisted.modelAdapters }; - } - if (persisted?.xaiResponsesDefaultVersion !== undefined) { - nextSubmitted.xaiResponsesDefaultVersion = persisted.xaiResponsesDefaultVersion; - } - } - initializeProviderModelSelection(name, nextSubmitted, persisted, fresh); - const committed = { - ...(persisted ? structuredClone(persisted) : {}), - ...nextSubmitted, - } as OcxProviderConfig; - if (nextSubmitted.azureCredential) { - // Switching identity modes is an explicit credential replacement. The atomic merge - // preserves omitted secrets generally, but Azure identity cannot coexist with a stale - // key or key pool from the prior row. - delete committed.apiKey; - delete committed.apiKeyPool; + if (latest?.xaiResponsesDefaultVersion !== undefined) { + prov.xaiResponsesDefaultVersion = latest.xaiResponsesDefaultVersion; } - const keyCollision = reconcileSubmittedApiKey(committed); - if (keyCollision) return { changed: false, value: { error: keyCollision, status: 409 } }; - fresh.providers[name] = committed; - if (body.setDefault === true) fresh.defaultProvider = name; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) { - return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 409, req, config); } - adoptCommittedConfig(config, outcome.value.config); + initializeProviderModelSelection(name, prov, config.providers[name], config); + config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov); + if (body.setDefault === true) config.defaultProvider = name; + save(config); reconcileLiveStateStores(); + if (prov.apiKey && prov.apiKeyPool) { + const { addProviderApiKey } = await import("../../providers/api-keys"); + addProviderApiKey(config, name, prov.apiKey); + } const { clearModelCache } = await import("../../codex/model-cache"); clearModelCache(name); const catalogRefresh = await convergeCodexCatalog(); @@ -1296,17 +1066,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(deps, fresh => { - const persisted = fresh.providers.openai; - if (!persisted || !isCanonicalOpenAiForwardProvider(persisted)) { - return { changed: false, value: { error: "provider openai must be the canonical built-in provider", status: 409 } }; - } - fresh.providers.openai = { ...persisted, codexAccountMode: mode }; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + config.providers.openai = { ...provider, codexAccountMode: mode }; + save(config); reconcileLiveStateStores(); (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)(); (deps.clearThreadAccountMap ?? clearThreadAccountMap)(); @@ -1331,19 +1093,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(deps, fresh => { - if (!hasOwnProvider(fresh.providers, name)) { - return { changed: false, value: { error: "unknown provider", status: 404 } }; - } - if (fresh.providers[name]!.disabled) { - return { changed: false, value: { error: "cannot set a disabled provider as default", code: "default_provider_disabled", status: 400 } }; - } - fresh.defaultProvider = name; - return { changed: true, value: { config: structuredClone(fresh) } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse(outcome.value, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + config.defaultProvider = name; + save(config); reconcileLiveStateStores(); return jsonResponse({ success: true, name, defaultProvider: name }); } @@ -1395,42 +1147,42 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(deps, fresh => { - if (!hasOwnProvider(fresh.providers, name)) { - return { changed: false, value: { error: "unknown provider", status: 404 } }; - } - const replay = applyProviderPatchFields(name, fresh.providers[name]!, rawBody, keys, fresh); + let replayError: string | undefined; + withConfigMutationLockSync(() => { + const replay = applyProviderPatchFields(name, config.providers[name]!, rawBody, keys, config); if ("error" in replay) { - return { changed: false, value: { error: replay.error, status: 409 } }; + replayError = replay.error; + return; } if (replay.editorTouched && !pacingOnly) { const syncError = canonicalBudgetOnly - ? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, fresh) + ? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, config) : providerManagementConfigError( name, providerTransportValidationCandidate(replay.next as unknown as Record), ) ?? providerEmptyToolOutputConfigError(name, replay.next); if (syncError) { - return { changed: false, value: { error: syncError, status: 409 } }; + replayError = syncError; + return; } if (!canonicalBudgetOnly) { const serviceTierError = providerServiceTierConfigError(name, replay.next); if (serviceTierError) { - return { changed: false, value: { error: serviceTierError, status: 409 } }; + replayError = serviceTierError; + return; } } } else if (replay.enablingOpenAi && !isCanonicalOpenAiForwardProvider(replay.next)) { - return { changed: false, value: { error: "provider openai must be the canonical built-in provider", status: 409 } }; + replayError = "provider openai must be the canonical built-in provider"; + return; } // A PATCH that managed headers owns the resulting block: the clear path restores // registry static headers, so exact-match stripping must not erase them again. - fresh.providers[name] = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next); - return { changed: replay.touched, value: { config: structuredClone(fresh) } }; + config.providers[name] = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next); + saveConfigPreservingClaudeCode(config); }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); + if (replayError !== undefined) return jsonResponse({ error: replayError }, 409); reconcileLiveStateStores(); if (applied.editorTouched && !pacingOnly) { const { clearModelCache } = await import("../../codex/model-cache"); @@ -1462,8 +1214,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; const apiKey = snapshot?.accessToken ?? await resolveModelsAuthToken(name, prov); - if ((prov.authMode === "oauth" || antigravity) && !apiKey) { + if (prov.authMode === "oauth" && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); } if (prov.adapter === "cursor") { @@ -1510,7 +1257,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(deps, fresh => { - if (!hasOwnProvider(fresh.providers, name)) { - return { changed: false, value: { error: "unknown provider", status: 404 } }; - } - const routingProfiles = Object.entries(fresh.routingProfiles ?? {}) - .filter(([, profile]) => profile.candidates.some(candidate => candidate.provider === name)) - .map(([id]) => id) - .sort((a, b) => a.localeCompare(b)); - if (routingProfiles.length > 0) { - return { changed: false, value: { error: `cannot delete provider "${name}" while routing profiles depend on it`, code: "provider_has_dependent_routing_profiles", routingProfiles, status: 409 } }; - } - const combos = Object.entries(fresh.combos ?? {}) - .filter(([, combo]) => combo.targets.some(target => target.provider === name)) - .map(([id]) => id) - .sort((a, b) => a.localeCompare(b)); - if (combos.length > 0) { - return { changed: false, value: { error: `cannot delete provider "${name}" while combos depend on it`, code: "provider_has_dependent_combos", combos, status: 409 } }; - } - const persistedFallback = name === fresh.defaultProvider - ? Object.entries(fresh.providers).find(([id, provider]) => id !== name && provider.disabled !== true)?.[0] - : undefined; - if (name === fresh.defaultProvider && !persistedFallback) { - return { changed: false, value: { error: "cannot delete the default provider when no enabled replacement remains", code: "last_provider", status: 409 } }; - } - if (persistedFallback) fresh.defaultProvider = persistedFallback; - delete fresh.providers[name]; - const droppedCustomModels = dropProviderCustomModels(fresh, name); - setProviderContextCap(fresh, name, false); - return { changed: true, value: { config: structuredClone(fresh), fallbackDefault: persistedFallback, droppedCustomModels } }; - }); - if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config); - if ("error" in outcome.value) return jsonResponse(outcome.value, outcome.value.status ?? 409); - adoptCommittedConfig(config, outcome.value.config); - const committedFallbackDefault = outcome.value.fallbackDefault; - const droppedCustomModels = outcome.value.droppedCustomModels ?? 0; + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + if (fallbackDefault) config.defaultProvider = fallbackDefault; + delete config.providers[name]; + const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite"); + const droppedCustomModels = dropProviderCustomModels(config, name); + forgetProviderContextCap(config, name); + save(config); await replaceProviderAccountSet(name, null); reconcileLiveStateStores(); const { clearModelCache: clearCache } = await import("../../codex/model-cache"); @@ -1677,14 +1392,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0 ? { droppedCustomModels } : {}), catalogRefresh, }); } if (url.pathname === "/api/provider-context-caps" && req.method === "GET") { - return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) }); + return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config), values: selectedProviderContextCaps(config) }); } if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") { @@ -1694,12 +1409,13 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise>) => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), - caps: providerContextCaps(config), + caps: providerContextCaps(config), values: selectedProviderContextCaps(config), catalogRefresh, }); @@ -1718,8 +1434,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise boolean; /** Record a synthetic terminal (caller decides incomplete vs failed-502). */ - onSynthetic: (kind: "incomplete" | "failed") => void; + onSynthetic: (kind: "incomplete" | "failed", reason?: "upstream_error") => void; /** Client cancelled and NO terminal arrived within the drain bounds. */ onClientCancel: () => void; /** Exactly once, after the producer fully stops (unregisterTurn parity). */ @@ -81,6 +82,8 @@ export type EagerRelayOptions = { postCancelDrainMs?: number; /** Post-cancel discard-drain byte bound. Default 32 MiB. */ postCancelDrainBytes?: number; + /** Last known upstream failure to preserve when EOF would otherwise become adapter_eof. */ + upstreamError?: string; /** Injectable clock for tests. */ now?: () => number; }; @@ -239,6 +242,7 @@ export function relaySseEagerBounded( const producer = async () => { let syntheticKind: "incomplete" | "failed" | null = null; + let syntheticReason: "upstream_error" | undefined; let deliveryFallbackSent = false; let priorRewriteFailure = false; let priorRewriteError: unknown; @@ -303,12 +307,17 @@ export function relaySseEagerBounded( } else if (!hooks.sawTerminal() && canDeliver()) { // A clean 200 EOF without a Responses terminal must be visible to // Codex as one incomplete turn, followed by the normal sentinel. - queuedBytes += adapterEofFrame.byteLength + terminalSentinel.byteLength; + const upstreamError = terminalBoundary.upstreamError() ?? opts?.upstreamError; + const upstreamErrorFrame = upstreamError === undefined + ? adapterEofFrame + : upstreamErrorTailFrame(terminalEncoder, upstreamError); + queuedBytes += upstreamErrorFrame.byteLength + terminalSentinel.byteLength; try { - controllerRef?.enqueue(adapterEofFrame); + controllerRef?.enqueue(upstreamErrorFrame); controllerRef?.enqueue(terminalSentinel); } catch { /* client already gone */ } - syntheticKind = "incomplete"; + syntheticKind = upstreamError === undefined ? "incomplete" : "failed"; + syntheticReason = upstreamError === undefined ? undefined : "upstream_error"; } break; } @@ -449,7 +458,10 @@ export function relaySseEagerBounded( frameBufferBytes = 0; } terminalBoundary.dispose(); - if (syntheticKind && canDeliver()) hooks.onSynthetic(syntheticKind); + if (syntheticKind && canDeliver()) { + if (syntheticReason === undefined) hooks.onSynthetic(syntheticKind); + else hooks.onSynthetic(syntheticKind, syntheticReason); + } if (cancelled && !hooks.sawTerminal()) { hooks.onClientCancel(); } diff --git a/src/server/relay.ts b/src/server/relay.ts index 090d1f15d0..a483d88f20 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -5,6 +5,7 @@ import { CYBER_POLICY_FALLBACK_MESSAGE, isCyberPolicyCode, isCyberPolicyMessage, + upstreamErrorMessageFromPayload, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; @@ -147,11 +148,31 @@ export function failedTailFrame(encoder: TextEncoder, err: unknown): Uint8Array return encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`); } +export function upstreamErrorTailFrame(encoder: TextEncoder, message: string): Uint8Array { + const error = { + type: "upstream_error", + code: "upstream_server_error", + message: redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS), + }; + return encoder.encode(`event: response.failed\ndata: ${JSON.stringify({ + type: "response.failed", + response: { status: "failed", error, last_error: error }, + })}\n\n`); +} + +function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object" || Array.isArray(payload) + || (payload as { type?: unknown }).type !== "error") return undefined; + const message = upstreamErrorMessageFromPayload(payload); + return message ? redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS) : undefined; +} + export type SseTerminalOutputBoundary = { feed(chunk: Uint8Array): Uint8Array; finish(): Uint8Array; terminalSeen(): boolean; doneSeen(): boolean; + upstreamError(): string | undefined; dispose(): void; }; @@ -170,6 +191,7 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { let done = false; let pendingDone: { block: Uint8Array; delimiter: Uint8Array } | null = null; let disposed = false; + let upstreamError: string | undefined; const processFrames = ( frames: ReturnType, @@ -181,6 +203,10 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { const payload = sseDataPayload(decoder.decode(frame.block)); const isDone = payload === "[DONE]"; const parsed = payload === null ? undefined : parseSsePayload(payload); + // Observe on the client reader itself: a tee inspection branch may lag + // behind EOF, so its log context cannot determine the outgoing terminal. + const message = boundedBareUpstreamErrorMessage(parsed); + if (message !== undefined) upstreamError = message; const policyError = parsed !== undefined && isPolicyRewriteType(parsed) ? cyberPolicyTerminalError(parsed) : undefined; @@ -239,6 +265,7 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { }, terminalSeen: () => terminal, doneSeen: () => done, + upstreamError: () => upstreamError, dispose() { if (disposed) return; disposed = true; @@ -260,7 +287,7 @@ export function relaySseWithFailedTail( body: ReadableStream, upstream: AbortController, onClientGone?: (reason?: unknown) => void, - options?: { synthesizeMissingTerminal?: boolean }, + opts?: { upstreamError?: string }, ): ReadableStream { const reader = body.getReader(); const encoder = new TextEncoder(); @@ -303,12 +330,14 @@ export function relaySseWithFailedTail( if (tail.byteLength > 0) controller.enqueue(tail); if (terminalBoundary.terminalSeen()) { if (!terminalBoundary.doneSeen()) controller.enqueue(doneFrame(encoder)); - } else if (options?.synthesizeMissingTerminal !== false) { + } else { // A clean upstream EOF is still a failed Responses turn when no // protocol terminal arrived. Make that state explicit so Codex // does not treat HTTP 200 + bare EOF as a retryable disconnect. - const incomplete = adapterEofIncompleteFrame(encoder); - controller.enqueue(incomplete); + const upstreamError = terminalBoundary.upstreamError() ?? opts?.upstreamError; + controller.enqueue(upstreamError === undefined + ? adapterEofIncompleteFrame(encoder) + : upstreamErrorTailFrame(encoder, upstreamError)); controller.enqueue(doneFrame(encoder)); } terminalBoundary.dispose(); @@ -335,13 +364,9 @@ export function relaySseWithFailedTail( if (partial.byteLength > 0) controller.enqueue(partial); if (tailTerminal) { if (!terminalBoundary.doneSeen()) controller.enqueue(doneFrame(encoder)); - } else if (options?.synthesizeMissingTerminal !== false) { + } else { // Leading blank line terminates a partial SSE block so the failed frame parses cleanly. controller.enqueue(failedTailFrame(encoder, err)); - } else { - controller.error(err); - upstream.abort(); - return; } controller.close(); } catch { /* client already torn down */ } @@ -1359,11 +1384,16 @@ export function consumeForInspection( options?: InspectionConsumerOptions, ): void { const reader = body.getReader(); + let bareUpstreamError: string | undefined; const inspector = (options?.inspectorFactory ?? createSseInspector)({ onTerminal, logCtx, onCompletedResponse, - onParsedPayload: options?.onParsedPayload, + onParsedPayload: payload => { + const message = boundedBareUpstreamErrorMessage(payload); + if (message !== undefined) bareUpstreamError = message; + options?.onParsedPayload?.(payload); + }, onFirstOutput, pinCompletedResponseIdToFirstSeen: options?.pinCompletedResponseIdToFirstSeen, }); @@ -1377,7 +1407,11 @@ export function consumeForInspection( onCleanEof: () => { if (!inspector.reported()) { if (logCtx) logCtx.terminalSource = "synthetic"; - onTerminal("incomplete"); + if (bareUpstreamError !== undefined) { + onTerminal("failed", httpStatusForRequestLogTerminal("failed", logCtx)); + } else { + onTerminal("incomplete"); + } } }, onReadError: () => { diff --git a/src/server/request-log-cursor.ts b/src/server/request-log-cursor.ts new file mode 100644 index 0000000000..571c200e81 --- /dev/null +++ b/src/server/request-log-cursor.ts @@ -0,0 +1,84 @@ +import { createHash, randomBytes } from "node:crypto"; + +const MAX_CURSOR_LENGTH = 512; +const MAX_WINDOW_ROWS = 2000; +// A restart must invalidate even an identical window hydrated from usage.jsonl. +const processEpoch = randomBytes(16).toString("hex"); + +interface SnapshotCursor { + v: 2; + e: string; + n: number; + q: string; + h: string; +} + +interface LegacyCursor { + v: 1; + t: number; + id: string; +} + +export type RequestLogCursor = SnapshotCursor | LegacyCursor; + +/** A cursor is a bounded freshness hint, never an admission credential. */ +export function decodeRequestLogCursor(raw: string): RequestLogCursor | null { + if (!raw || raw.length > MAX_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/.test(raw)) return null; + try { + const bytes = Buffer.from(raw, "base64url"); + if (bytes.toString("base64url") !== raw) return null; + const value: unknown = JSON.parse(bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const keys = Object.keys(row).sort().join(","); + if (row.v === 1 && keys === "id,t,v" + && typeof row.t === "number" && Number.isFinite(row.t) && row.t >= 0 + && typeof row.id === "string" && row.id.length > 0 && row.id.length <= 256) { + return { v: 1, t: row.t, id: row.id }; + } + if (row.v !== 2 || keys !== "e,h,n,q,v" + || typeof row.e !== "string" || !/^[a-f0-9]{32}$/.test(row.e) + || typeof row.n !== "number" || !Number.isSafeInteger(row.n) || row.n < 0 || row.n > MAX_WINDOW_ROWS + || typeof row.q !== "string" || !/^[a-f0-9]{64}$/.test(row.q) + || typeof row.h !== "string" || !/^[a-f0-9]{64}$/.test(row.h)) return null; + return { v: 2, e: row.e, n: row.n, q: row.q, h: row.h }; + } catch { + return null; + } +} + +/** + * Compare the current projected window, not ring identities: live entries and + * display-time pricing can change without append. This saves response bytes for + * stable prefixes; DTO projection and hashing still cost O(window bytes). + * No per-client rows or history are retained. The route calls this synchronously + * after projecting the full filtered/paginated window. + */ +export function selectRequestLogPoll( + rows: readonly T[], + params: URLSearchParams, + cursor: RequestLogCursor | null, + epoch = processEpoch, +): { logs: T[]; cursor: string; reset: boolean } { + const query = new URLSearchParams(params); + query.delete("cursor"); + query.sort(); + const queryDigest = createHash("sha256").update(query.toString()).digest("hex"); + const candidate = cursor?.v === 2 && cursor.e === epoch && cursor.q === queryDigest + && cursor.n <= rows.length ? cursor : null; + const full = createHash("sha256"); + const prefix = createHash("sha256"); + for (let index = 0; index < rows.length; index++) { + // JSON escapes embedded newlines, so the delimiter frames each whole row. + const serialized = JSON.stringify(rows[index]) + "\n"; + full.update(serialized); + if (candidate && index < candidate.n) prefix.update(serialized); + } + const unchangedPrefix = candidate !== null && prefix.digest("hex") === candidate.h; + const next: SnapshotCursor = { v: 2, e: epoch, n: rows.length, q: queryDigest, h: full.digest("hex") }; + return { + logs: rows.slice(unchangedPrefix ? candidate.n : 0), + cursor: Buffer.from(JSON.stringify(next)).toString("base64url"), + reset: cursor !== null && !unchangedPrefix, + }; +} diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 637a03a8b7..9cc6814faa 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -8,11 +8,12 @@ import { isClientClosedMessage, isCyberPolicyCode, isCyberPolicyMessage, + isRateLimitOrQuotaFailureMessage, upstreamErrorMessageFromPayload, } from "../lib/errors"; import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths"; import { readCodexCatalogPath } from "../codex/catalog"; -import type { AttemptTierOutcome, OcxUsage } from "../types"; +import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import type { AdapterRequest } from "../adapters/base"; import type { AdapterTierMetadata } from "../providers/fastwire"; @@ -29,6 +30,7 @@ import { isCodexUsageAccountLogLabel, isPersistableAccountLogLabel, isValidReasoningWireValue, + normalizeClaudeCompatibilityUsageLog, readRecentUsageEntries, usageForFinalLog, usageStatusForFinalLog, @@ -36,6 +38,7 @@ import { type AttemptRecoveryKind, type PersistedUsageAttempt, type PersistedUsageEntry, + type PersistedClaudeCompatibilityLog, type UsageStatus, } from "../usage/log"; import type { AgentKind } from "./effort-policy"; @@ -157,6 +160,8 @@ export interface RequestLogContext { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Opt-in shadow evidence, normalized again at the logging boundary. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } export interface RequestLogEntry { @@ -227,6 +232,8 @@ export interface RequestLogEntry { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Closed Claude protocol codes; no request or header values. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } const requestLog: RequestLogEntry[] = []; @@ -295,6 +302,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R const terminalStatus = asTerminalStatus(entry.terminalStatus); const closeReason = asCloseReason(entry.closeReason); const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); return { requestId: entry.requestId, timestamp: entry.timestamp, @@ -344,6 +352,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } @@ -399,10 +408,13 @@ export function addRequestLog(entry: RequestLogEntry) { // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a // sanitization bug because the safe surface is the one you check. const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); - const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); + const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && entry.claudeCompatibility === undefined ? entry : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom; + if (claudeCompatibility) retained.claudeCompatibility = claudeCompatibility; + else if (retained !== entry) delete retained.claudeCompatibility; entry = retained; retainRequestLogEntry(entry); try { @@ -469,6 +481,7 @@ export function addRequestLog(entry: RequestLogEntry) { ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...failureDiagnostics, ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), + ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), }); } catch { /* request logging must never fail a user request */ @@ -891,7 +904,7 @@ function captureTerminalHttpStatus( last_error?: { type?: unknown; code?: unknown; message?: unknown }; response?: { error?: { type?: unknown; code?: unknown; message?: unknown }; - incomplete_details?: { code?: unknown; message?: unknown }; + incomplete_details?: { code?: unknown; message?: unknown; reason?: unknown }; }; }, ): void { @@ -900,7 +913,9 @@ function captureTerminalHttpStatus( if (type !== "response.failed" && type !== "response.incomplete" && type !== "error") return; const responseError = json.response?.error; const responseDetails = json.response?.incomplete_details; - const candidates = [json.error, json.last_error, responseError, responseDetails, json]; + const candidates: Array<{ type?: unknown; code?: unknown; message?: unknown } | undefined> = [ + json.error, json.last_error, responseError, responseDetails, json, + ]; const policy = candidates.some(candidate => ( candidate?.code === null || typeof candidate?.code === "string" ) && isCyberPolicyCode(candidate.code as string | null | undefined)) @@ -914,6 +929,29 @@ function captureTerminalHttpStatus( logCtx.terminalHttpStatus = 400; return; } + // A quota terminal can carry only a structured reason, without an error message. + // Keep this separate from normal output limits and from the policy precedence above. + const quotaTag = (value: unknown): boolean => value === "usage_limit_reached" + || value === "rate_limit_exceeded" || value === "insufficient_quota"; + const structuredRefusal = candidates.some(candidate => [400, 401, 403, 499].includes( + httpStatusFromTerminalError({ + type: typeof candidate?.type === "string" ? candidate.type : undefined, + code: typeof candidate?.code === "string" ? candidate.code : undefined, + }), + )); + const ordinaryIncompleteReason = typeof responseDetails?.reason === "string" + && ["max_output_tokens", "content_filter", "steered", "upstream_stall_timeout", "adapter_eof"].includes(responseDetails.reason); + if (type === "response.incomplete" && !structuredRefusal && (quotaTag(responseDetails?.reason) || candidates.some(candidate => + quotaTag(candidate?.code) + || quotaTag(candidate?.type) || candidate?.type === "rate_limit_error" + || (!ordinaryIncompleteReason && typeof candidate?.message === "string" && isRateLimitOrQuotaFailureMessage(candidate.message)) + ))) { + // The shared quota classifier also accepts a numeric HTTP status as its message. + // Preserve explicit payment-required evidence rather than relabeling it as 429. + logCtx.terminalHttpStatus = candidates.some(candidate => typeof candidate?.message === "string" + && Number(candidate.message.trim()) === 402) ? 402 : 429; + return; + } if (type !== "response.failed" || !responseError || typeof responseError !== "object") return; const responseCode = responseError.code === null || typeof responseError.code === "string" ? responseError.code @@ -942,6 +980,9 @@ export function httpStatusForRequestLogTerminal( status: ResponsesTerminalStatus, logCtx?: RequestLogContext, ): number { + if (status === "incomplete" && (logCtx?.terminalHttpStatus === 429 || logCtx?.terminalHttpStatus === 402)) { + return logCtx.terminalHttpStatus; + } /** * [Decision Log] * - 목적과 의도: Keep request logs aligned with the successful HTTP/SSE contract. @@ -1029,6 +1070,7 @@ export function addFinalRequestLog( // means a future caller cannot reintroduce the hole by forgetting to sanitize first, and // the in-memory /api/logs row matches what usage.jsonl already stores. const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility); addLog({ requestId, timestamp: start, @@ -1085,6 +1127,7 @@ export function addFinalRequestLog( ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }); if (isUsageDebugEnabled()) { appendUsageDebug({ @@ -1273,11 +1316,39 @@ export function sealRequestAttemptIdentity( accountLogLabel?: string, ): void { if (!attempt) return; + if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource; attempt.provider = provider; attempt.adapter = adapter; if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel; } +/** Capture only the resolved upstream route; inbound auth and today's config cannot label old usage. */ +export function recordAttemptCredentialSource( + attempt: PersistedUsageAttempt | undefined, + providerName: string, + provider: Pick, + adapterName: string = provider.adapter, +): void { + if (!attempt) return; + // Rebinding an attempt to an unrecognized route must not retain its previous attribution. + delete attempt.credentialSource; + if (providerName !== "xai" + || !["openai-chat", "openai-responses"].includes(adapterName)) return; + try { + const url = new URL(provider.baseUrl ?? ""); + if (url.protocol !== "https:" || url.port || url.username || url.password + || url.search || url.hash || !["/v1", "/v1/"].includes(url.pathname)) return; + if (provider.authMode === "oauth" && url.hostname === "cli-chat-proxy.grok.com") { + attempt.credentialSource = "grok-oauth"; + } else if ((provider.authMode === "key" || provider.authMode === undefined) + && url.hostname === "api.x.ai") { + attempt.credentialSource = "xai-api-key"; + } + } catch { + // Invalid/custom destinations have no known subscription provenance. + } +} + export function noteAttemptSend( attempt: PersistedUsageAttempt | undefined, inputTokenEstimate: number | undefined, diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 4177e03426..c9d8527ebc 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -93,7 +93,8 @@ export function createRoutedCustomToolRestoreBlockRewrite( declaredNames?: ReadonlySet, ): SseBlockRewrite { const itemNames = new Map(); - const customAliasItemNames = new Map(); + // Native helper aliases and genuine bare code-mode exec calls share completion repair. + const customExecItemNames = new Map(); const repairItemNames = new Map(); const ordinaryItemIds = new Set(); const openCalls = new Map(); @@ -119,7 +120,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( } pendingArguments = []; itemNames.clear(); - customAliasItemNames.clear(); + customExecItemNames.clear(); repairItemNames.clear(); ordinaryItemIds.clear(); }; @@ -195,15 +196,17 @@ export function createRoutedCustomToolRestoreBlockRewrite( const wireName = routedCustomToolWireName(parsed.item); const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames); const aliased = targetName !== undefined && targetName !== wireName; - if (upstreamItemId && aliased) { - customAliasItemNames.set(upstreamItemId, parsed.item.name); + const codeModeExec = targetName === "exec" && parsed.item.name === "exec" + && parsed.item.namespace === undefined && declaresCodeModeExec(declaredNames); + if (upstreamItemId && (aliased || codeModeExec)) { + customExecItemNames.set(upstreamItemId, parsed.item.name); if (type === "response.output_item.added") { openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); } } const repairable = wireName !== undefined && repairNames.has(wireName); if (upstreamItemId && repairable) repairItemNames.set(upstreamItemId, parsed.item.name); - const restored = repairable || aliased + const restored = repairable || aliased || codeModeExec ? restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames) : { value: parsed, changed: false }; if (type === "response.output_item.done" && upstreamItemId) releaseCall(upstreamItemId); @@ -259,7 +262,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( if ( type === "response.custom_tool_call_input.delta" && upstreamItemId - && customAliasItemNames.has(upstreamItemId) + && customExecItemNames.has(upstreamItemId) ) { const open = openCalls.get(upstreamItemId) ?? { argumentsText: "", emittedInput: "", retainedBytes: 0 }; const delta = typeof parsed.delta === "string" ? parsed.delta : ""; @@ -268,19 +271,34 @@ export function createRoutedCustomToolRestoreBlockRewrite( open.argumentsText += delta; open.retainedBytes += deltaBytes; openCalls.set(upstreamItemId, open); - return []; + if (customExecItemNames.get(upstreamItemId) !== "exec" + || mayBecomePatchEnvelope(open.argumentsText) + // JSON.parse accepts whitespace, escaped keys and arbitrary property order. + // Any object prefix may still wrap a patch; keep it until authoritative completion. + || open.argumentsText.trimStart() === "" + || open.argumentsText.trimStart().startsWith("{")) return []; + // If a held prefix turns out to be ordinary JavaScript, release the entire + // un-emitted suffix. Native custom input remains byte-exact. + const inputDelta = open.argumentsText.slice(open.emittedInput.length); + open.emittedInput = open.argumentsText; + return inputDelta ? [replaceSseDataPayload(block, JSON.stringify({ ...parsed, delta: inputDelta }))] : []; } if ( type === "response.custom_tool_call_input.done" && upstreamItemId - && customAliasItemNames.has(upstreamItemId) + && customExecItemNames.has(upstreamItemId) ) { const source = typeof parsed.input === "string" ? parsed.input : openCalls.get(upstreamItemId)?.argumentsText ?? ""; + const name = customExecItemNames.get(upstreamItemId)!; + const helper = name === "exec" + ? resolveCodeModeHelperName(undefined, name, source, undefined, declaredNames) + : name; + releaseCall(upstreamItemId); return [replaceSseDataPayload(block, JSON.stringify({ ...parsed, - input: compileCodeModeHelperInput(source, customAliasItemNames.get(upstreamItemId)!), + input: helper ? compileCodeModeHelperInput(source, helper) : source, }))]; } if ( @@ -312,6 +330,8 @@ export function createRoutedCustomToolRestoreBlockRewrite( open.argumentsText += delta; open.retainedBytes += deltaBytes; openCalls.set(upstreamItemId, open); + // A helper alias will become JavaScript at completion, never raw patch/JSON. + if (itemNames.get(upstreamItemId)?.aliased) return []; // Still accumulating toward the compact wrapper, or an unrecognized shape: // suppress progressive emission and let the done event carry input. if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return []; diff --git a/src/server/responses-function-tool-repair.ts b/src/server/responses-function-tool-repair.ts new file mode 100644 index 0000000000..48bb7e51b2 --- /dev/null +++ b/src/server/responses-function-tool-repair.ts @@ -0,0 +1,183 @@ +import { + TRANSLATOR_MAX_TURN_BYTES, + TranslatorBudgetExceededError, + type TranslatorBudget, +} from "../lib/translator-budget"; +import { repairFunctionCalls, type FunctionCallRepairSchemas } from "../responses/function-call-compat"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; + +type Identity = { + itemId?: string; + outputIndex?: number; + item: Record; + bytes: number; +}; +type PendingCompletion = { + block: string; + itemId?: string; + outputIndex?: number; + bytes: number; +}; +const ENTRY_OVERHEAD_BYTES = 64; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function outputIndexOf(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +/** Ordinary deltas remain upstream previews; only authoritative completions are repaired. */ +export function createResponsesFunctionToolRepairBlockRewrite( + schemas: FunctionCallRepairSchemas, + budget?: TranslatorBudget, +): SseBlockRewrite { + if (schemas.size === 0) return block => [block]; + const byId = new Map(); + const byIndex = new Map(); + let pending: PendingCompletion[] = []; + let retainedBytes = 0; + let disposed = false; + + const retain = (bytes: number): void => { + if (retainedBytes + bytes > TRANSLATOR_MAX_TURN_BYTES) { + throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES); + } + budget?.chargeRetained(bytes, { kind: "retained_collectors" }); + retainedBytes += bytes; + }; + const release = (bytes: number): void => { + budget?.releaseRetained(bytes, { kind: "retained_collectors" }); + retainedBytes -= bytes; + }; + const releaseIdentity = (identity: Identity): void => { + if (identity.itemId !== undefined) byId.delete(identity.itemId); + if (identity.outputIndex !== undefined) byIndex.delete(identity.outputIndex); + release(identity.bytes); + }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + release(retainedBytes); + byId.clear(); + byIndex.clear(); + pending = []; + }; + const lookup = (itemId: string | undefined, index: number | undefined): Identity | undefined => { + const identity = itemId === undefined ? undefined : byId.get(itemId); + if (identity) return index === undefined || identity.outputIndex === undefined || identity.outputIndex === index ? identity : undefined; + const indexed = index === undefined ? undefined : byIndex.get(index); + return indexed && (itemId === undefined || indexed.itemId === undefined || indexed.itemId === itemId) ? indexed : undefined; + }; + const register = (item: Record, index: number | undefined): Identity | undefined => { + const itemId = typeof item.id === "string" && item.id ? item.id : undefined; + if (itemId === undefined && index === undefined) return undefined; + // Repeated snapshots replace metadata; never retain provider argument bodies here. + const previous = new Set(); + if (itemId !== undefined && byId.has(itemId)) previous.add(byId.get(itemId)!); + if (index !== undefined && byIndex.has(index)) previous.add(byIndex.get(index)!); + for (const identity of previous) releaseIdentity(identity); + const metadata = { + type: item.type, + name: item.name, + ...("namespace" in item ? { namespace: item.namespace } : {}), + ...(item.status !== undefined && item.status !== "in_progress" && item.status !== "completed" ? { status: item.status } : {}), + }; + const bytes = ENTRY_OVERHEAD_BYTES + Buffer.byteLength(JSON.stringify([itemId, index, metadata]), "utf8"); + retain(bytes); + const identity = { itemId, outputIndex: index, item: metadata, bytes }; + if (itemId !== undefined) byId.set(itemId, identity); + if (index !== undefined) byIndex.set(index, identity); + return identity; + }; + const repairCompletion = (block: string, event: Record, identity: Identity): string => { + if (typeof event.arguments !== "string") return block; + const resolved = (typeof event.item_id !== "string" || event.item_id === "") && identity.itemId !== undefined + ? { ...event, item_id: identity.itemId } + : event; + const repaired = repairFunctionCalls({ ...identity.item, status: identity.item.status ?? "completed", arguments: event.arguments }, schemas); + if (repaired.changed && isObject(repaired.value)) { + return replaceSseDataPayload(block, JSON.stringify({ ...resolved, arguments: repaired.value.arguments })); + } + return resolved === event ? block : replaceSseDataPayload(block, JSON.stringify(resolved)); + }; + const flushPending = (identity: Identity): string[] => { + const output: string[] = []; + const remaining: PendingCompletion[] = []; + for (const completion of pending) { + const matches = completion.itemId !== undefined + ? completion.itemId === identity.itemId + && (completion.outputIndex === undefined || identity.outputIndex === undefined || completion.outputIndex === identity.outputIndex) + : completion.outputIndex !== undefined && completion.outputIndex === identity.outputIndex; + if (!matches) { remaining.push(completion); continue; } + release(completion.bytes); + const payload = sseDataPayload(completion.block); + const event: unknown = payload === null ? undefined : JSON.parse(payload); + output.push(isObject(event) ? repairCompletion(completion.block, event, identity) : completion.block); + } + pending = remaining; + return output; + }; + + const rewrite: SseBlockRewrite = block => { + if (disposed) return [block]; + const payload = sseDataPayload(block); + if (payload === null) return [block]; + if (payload === "[DONE]") { + const unfinished = pending.map(entry => entry.block); + dispose(); + return [...unfinished, block]; + } + let event: unknown; + try { event = JSON.parse(payload); } catch { return [block]; } + if (!isObject(event)) return [block]; + const index = outputIndexOf(event.output_index); + const itemId = typeof event.item_id === "string" && event.item_id ? event.item_id : undefined; + try { + if ((event.type === "response.output_item.added" || event.type === "response.output_item.done") && isObject(event.item)) { + const identity = register(event.item, index); + const replayed = identity ? flushPending(identity) : []; + const repaired = repairFunctionCalls(event, schemas); + const output = repaired.changed ? replaceSseDataPayload(block, JSON.stringify(repaired.value)) : block; + if (identity && (event.type === "response.output_item.done" || replayed.length > 0)) releaseIdentity(identity); + return event.type === "response.output_item.added" ? [output, ...replayed] : [...replayed, output]; + } + if (event.type === "response.function_call_arguments.done" && typeof event.arguments === "string") { + const identity = lookup(itemId, index); + if (identity) { + const output = repairCompletion(block, event, identity); + releaseIdentity(identity); + return [output]; + } + if (itemId !== undefined || index !== undefined) { + const bytes = ENTRY_OVERHEAD_BYTES + Buffer.byteLength(block, "utf8"); + retain(bytes); + pending.push({ block, itemId, outputIndex: index, bytes }); + return []; + } + } + if (typeof event.type === "string" && ["response.completed", "response.failed", "response.incomplete", "response.cancelled"].includes(event.type)) { + const replayed: string[] = []; + if (event.type === "response.completed" && isObject(event.response) && Array.isArray(event.response.output) + && (event.response.status === undefined || event.response.status === "completed")) { + for (const [slot, item] of event.response.output.entries()) { + if (!isObject(item)) continue; + const identity = register(item, slot); + if (identity) replayed.push(...flushPending(identity)); + } + } + replayed.push(...pending.map(entry => entry.block)); + const repaired = repairFunctionCalls(event, schemas); + dispose(); + return [...replayed, repaired.changed ? replaceSseDataPayload(block, JSON.stringify(repaired.value)) : block]; + } + return [block]; + } catch (error) { + dispose(); + throw error; + } + }; + rewrite.dispose = dispose; + return rewrite; +} diff --git a/src/server/responses-snapshot-codec.ts b/src/server/responses-snapshot-codec.ts new file mode 100644 index 0000000000..2de390fe35 --- /dev/null +++ b/src/server/responses-snapshot-codec.ts @@ -0,0 +1,14 @@ +/** Shared snapshot wire primitives; no retention state or policy. */ + +export function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export type RetainedOutputItem = { + item: Record; + sourceBytes: number; +}; + +export function jsonBlock(event: Record): string { + return `data: ${JSON.stringify(event)}`; +} diff --git a/src/server/responses-snapshot-repair.ts b/src/server/responses-snapshot-repair.ts index 9ae137888e..389b837490 100644 --- a/src/server/responses-snapshot-repair.ts +++ b/src/server/responses-snapshot-repair.ts @@ -26,6 +26,7 @@ import { MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES, } from "./relay"; import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec"; const RESPONSE_EVENT_STATUSES: Readonly> = { "response.created": "in_progress", @@ -42,10 +43,6 @@ type RequestDefaults = { tools: unknown[]; }; -function isPlainObject(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function isStructurallyValidToolChoice(value: unknown): boolean { return (typeof value === "string" && value.trim().length > 0) || (isPlainObject(value) && typeof value.type === "string" && value.type.trim().length > 0); @@ -179,15 +176,6 @@ type OpenItem = { const MAX_OPEN_ITEMS = MAX_COMPLETED_OUTPUT_ITEMS; const MAX_OPEN_ITEM_AGGREGATE_TEXT_BYTES = MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES; -type RetainedOutputItem = { - item: Record; - sourceBytes: number; -}; - -function jsonBlock(event: Record): string { - return `data: ${JSON.stringify(event)}`; -} - /** * Stateful block rewrite: field backfills + lifecycle completion injection. * `budget` bounds retained completed items (reconstruction only). diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 4d01ef0d3e..5b22c521a3 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,3 +1,4 @@ +import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, wireToolInnerName } from "../responses/tool-name-aliases"; import { CODE_MODE_EXEC_TOOL_NAME, dottedToolName, @@ -75,32 +76,6 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -/** - * A dotted spelling is a safe alias only when it cannot ALSO be read as some other identity's - * canonical `ns__name`. - * - * `{namespace: "x__y", name: "z"}` produces the dotted spelling "x__y.z", which is exactly the - * canonical wire name of `{namespace: "x", name: "y.z"}`. If only the latter is declared, an - * echoed call for the former would still find "x__y.z" in the declared set and be authorized as - * a tool the caller never granted. Requiring both halves to be free of the `__` separator keeps - * a dotted alias from ever impersonating a canonical name. - */ -function dottedAliasIsUnambiguous(namespace: string, name: string): boolean { - return !namespace.includes("__") && !name.includes("__"); -} - -function wireToolInnerName(tool: unknown): string | undefined { - if (!isPlainObject(tool)) return undefined; - const nestedFunction = tool.type === "function" && isPlainObject(tool.function) - ? tool.function - : undefined; - return typeof tool.name === "string" && tool.name.length > 0 - ? tool.name - : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0 - ? nestedFunction.name - : undefined; -} - function addWireToolName( names: Set, tool: unknown, @@ -171,48 +146,6 @@ function addWireToolSpecs( } } -/** - * Dotted aliases that more than one declared identity would claim, plus dotted aliases that - * collide with a canonical or bare declared name. - * - * Resolved over the WHOLE catalog before any name is registered, so which identity "wins" can - * never depend on declaration order -- an order the caller controls. - */ -function collectAmbiguousDottedAliases(specGroups: readonly unknown[]): Set { - const owners = new Map(); - const claim = (alias: string, identity: string): void => { - const owner = owners.get(alias); - if (owner === undefined) owners.set(alias, identity); - else if (owner !== identity) owners.set(alias, null); - }; - for (const specs of specGroups) { - if (!Array.isArray(specs)) continue; - for (const spec of specs) { - if (!isPlainObject(spec)) continue; - if (spec.type === "namespace" && Array.isArray(spec.tools)) { - const namespace = typeof spec.name === "string" ? spec.name : undefined; - if (!namespace || namespace === BUILTIN_FUNCTIONS_NAMESPACE) continue; - for (const inner of spec.tools) { - const name = wireToolInnerName(inner); - if (!name) continue; - const identity = JSON.stringify([namespace, name]); - claim(dottedToolName(namespace, name), identity); - // A canonical or bare name already owned by a different identity poisons the dotted - // alias that would shadow it. - claim(namespacedToolName(namespace, name), identity); - claim(name, identity); - } - continue; - } - const name = wireToolInnerName(spec); - if (name) claim(name, JSON.stringify([undefined, name])); - } - } - const ambiguous = new Set(); - for (const [alias, owner] of owners) if (owner === null) ambiguous.add(alias); - return ambiguous; -} - /** * Tool names the OUTBOUND Responses body actually declared. * diff --git a/src/server/responses/agent-task-recovery-cache.ts b/src/server/responses/agent-task-recovery-cache.ts index 44e32c62bb..93d0c1778b 100644 --- a/src/server/responses/agent-task-recovery-cache.ts +++ b/src/server/responses/agent-task-recovery-cache.ts @@ -149,3 +149,14 @@ export function agentTaskRecoveryWaiterCountForTests(): number { export function agentTaskRecoveryCacheSnapshotForTests(): { entries: number; bytes: number } { return { entries: RECOVERY_CACHE.size, bytes: recoveryCacheBytes }; } + +/** Read an existing recovery without starting a request or extending its lifetime. */ +export function cachedAgentTaskRecovery(key: string): string | null { + const entry = RECOVERY_CACHE.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + deleteRecoveryCacheEntry(key, entry); + return null; + } + return entry.assignment; +} diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index 61c373023a..64720ab8f9 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -5,6 +5,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body"; import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors"; import { structurallyValidFernetTokens } from "./encrypted-payload"; import { + cachedAgentTaskRecovery, discardCachedAgentTaskRecovery, resetAgentTaskRecoveryCache, resolveCachedAgentTaskRecovery, @@ -40,6 +41,18 @@ export interface AgentTaskRecoveryOptions { cacheEntries?: number; } +export type AgentTaskRecoveryFailureReason = + | "unsupported_envelope" + | "admission_denied" + // Includes cache capacity rejection; does not imply an upstream request was attempted. + | "recovery_unavailable" + | "caller_cancelled" + | "input_changed"; + +export type AgentTaskRecoveryResult = + | { readonly recovered: true } + | { readonly recovered: false; readonly reason: AgentTaskRecoveryFailureReason }; + export function agentTaskRecoveryConfig(config: OcxConfig): AgentTaskRecoveryOptions | null { const raw = config.agentTaskRecovery; if (!raw || raw.enabled !== true) return null; @@ -61,7 +74,7 @@ interface AgentEnvelope { itemIndex: number; encryptedIndex: number; headerText: string; - messageType: "NEW_TASK"; + messageType: "NEW_TASK" | "MESSAGE"; taskName: string; sender: string; ciphertext: string; @@ -69,7 +82,7 @@ interface AgentEnvelope { recipient: string; } -const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; +const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(input)) return null; @@ -90,7 +103,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(content)) return null; let headerText: string | null = null; - let messageType: "NEW_TASK" | null = null; + let messageType: "NEW_TASK" | "MESSAGE" | null = null; let taskName: string | null = null; let sender: string | null = null; let encryptedIndex = -1; @@ -113,7 +126,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { || part.text.slice(match.index + match[0].length).trim().length > 0 ) return null; headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0]; - messageType = "NEW_TASK"; + messageType = match[1] as "NEW_TASK" | "MESSAGE"; taskName = match[2]!; sender = match[3]!; } @@ -274,16 +287,20 @@ interface AdmittedRecovery { cacheKey: string; } +type RecoveryAdmissionResult = + | { admitted: true; recovery: AdmittedRecovery } + | { admitted: false; reason: "unsupported_envelope" | "admission_denied" }; + function admittedRecovery( req: Request, input: unknown, config: OcxConfig, parentThreadId?: string | null, -): AdmittedRecovery | null { +): RecoveryAdmissionResult { const envelope = findEnvelope(input); - if (!envelope) return null; + if (!envelope) return { admitted: false, reason: "unsupported_envelope" }; const admission = recoveryAdmission(req, config); - if (!admission) return null; + if (!admission) return { admitted: false, reason: "admission_denied" }; const cacheKey = createHash("sha256") .update(admission.cacheScope) .update("\0") @@ -297,7 +314,7 @@ function admittedRecovery( .update("\0") .update(envelope.ciphertext) .digest("hex"); - return { envelope, admission, cacheKey }; + return { admitted: true, recovery: { envelope, admission, cacheKey } }; } function recoveryPayload(envelope: AgentEnvelope, model: string): string { @@ -464,23 +481,43 @@ export async function recoverEncryptedAgentTask( config: OcxConfig, context: { parentThreadId?: string | null; abortSignal?: AbortSignal } = {}, ): Promise { + return (await recoverEncryptedAgentTaskWithResult(req, input, options, config, context)).recovered; +} + +/** Returns only bounded, caller-local diagnostics; no native error or payload content. */ +export async function recoverEncryptedAgentTaskWithResult( + req: Request, + input: unknown, + options: AgentTaskRecoveryOptions, + config: OcxConfig, + context: { parentThreadId?: string | null; abortSignal?: AbortSignal } = {}, +): Promise { // Admission is deliberately checked before cache access. A cache hit must not // turn this process into a plaintext oracle for an unauthenticated caller. const admitted = admittedRecovery(req, input, config, context.parentThreadId); - if (!admitted) return false; - const { admission, cacheKey, envelope } = admitted; + if (!admitted.admitted) return { recovered: false, reason: admitted.reason }; + const { admission, cacheKey, envelope } = admitted.recovery; const assignment = await resolveCachedAgentTaskRecovery( cacheKey, options.cacheEntries ?? 200, signal => requestRecovery(admission, envelope, options, signal), context.abortSignal, ); - if (!assignment) return false; - if (context.abortSignal?.aborted || !injectAssignment(input, envelope, assignment)) { + if (!assignment) { + return { + recovered: false, + reason: context.abortSignal?.aborted ? "caller_cancelled" : "recovery_unavailable", + }; + } + if (context.abortSignal?.aborted) { discardCachedAgentTaskRecovery(cacheKey); - return false; + return { recovered: false, reason: "caller_cancelled" }; } - return true; + if (!injectAssignment(input, envelope, assignment)) { + discardCachedAgentTaskRecovery(cacheKey); + return { recovered: false, reason: "input_changed" }; + } + return { recovered: true }; } export function discardEncryptedAgentTaskRecovery( @@ -490,9 +527,28 @@ export function discardEncryptedAgentTaskRecovery( context: { parentThreadId?: string | null } = {}, ): void { const admitted = admittedRecovery(req, input, config, context.parentThreadId); - if (admitted) discardCachedAgentTaskRecovery(admitted.cacheKey); + if (admitted.admitted) discardCachedAgentTaskRecovery(admitted.recovery.cacheKey); } export function resetAgentTaskRecoveryState(): void { resetAgentTaskRecoveryCache(); } + +/** Codex replays the original encrypted agent messages after tool calls. Reuse only an admitted cache hit. */ +export function restoreCachedEncryptedAgentTasks( + req: Request, input: unknown, config: OcxConfig, + context: { parentThreadId?: string | null } = {}, +): number { + if (!Array.isArray(input)) return 0; + let restored = 0; + for (const item of input) { + if (!item || typeof item !== "object" || item.type !== "agent_message") continue; + const single = [item]; + // Revalidates caller credentials and the exact supported agent envelope before cache access. + const admitted = admittedRecovery(req, single, config, context.parentThreadId); + if (!admitted.admitted) continue; + const assignment = cachedAgentTaskRecovery(admitted.recovery.cacheKey); + if (assignment && injectAssignment(single, admitted.recovery.envelope, assignment)) restored += 1; + } + return restored; +} diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 2f41be02fa..31813756ea 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -1,4 +1,5 @@ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; +import { isSafeResponseHeader } from "../safe-response-headers"; import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata"; import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request"; import { CodexWsCorrelation } from "./codex-ws-correlation"; @@ -16,6 +17,69 @@ interface ExchangeOptions { beforeDispatch?: (headers: Headers) => void; } +const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Rebuild only permitted metadata: upstream framing describes a different body. */ +function rejectionHeaders(source: Record, prelude: Headers): Headers { + const connectionHeaders = new Set(); + for (const [name, value] of Object.entries(source)) { + if (name.toLowerCase() !== "connection" || typeof value !== "string") continue; + for (const token of value.split(",")) { + const lower = token.trim().toLowerCase(); + if (HTTP_HEADER_TOKEN.test(lower)) connectionHeaders.add(lower); + } + } + // Reuse the metadata owner's count/value/family budgets and window freshness + // rules, without publishing quota twice. The unmarked HTTP response owns it. + const projected = new CodexWsMetadata(); + try { + for (const values of [Object.fromEntries(prelude), source]) { + const headers = Object.fromEntries(Object.entries(values).filter(([name, value]) => { + if (!HTTP_HEADER_TOKEN.test(name) || !isSafeResponseHeader(name) + || connectionHeaders.has(name.toLowerCase())) return false; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return false; + return !(typeof value === "number" && !Number.isFinite(value)) && !/[\r\n\0]/.test(String(value)); + })); + if (Object.keys(headers).length === 0) continue; + const event = { type: "codex.response.metadata", headers }; + // Bound the combined serialized seed and updates, even for replacements. + projected.consume(event, Buffer.byteLength(JSON.stringify(event))); + } + const headers = projected.snapshot(); + headers.set("content-type", "application/json"); + headers.set("cache-control", "no-store"); + return headers; + } finally { + projected.finish(); + } +} + +/** + * Carry #3740's refused-create status back to the HTTP recovery path. Codex's + * responses_websocket.rs accepts status/status_code and scalar header values; + * unlike its native client, this relay converts only precommit 4xx. Returning a + * post-send 5xx or fetch rejection could cause the outer retry wrapper to resend. + */ +function wrappedRejectionResponse(payload: Record, prelude: Headers): Response | null { + if (payload.type !== "error" || payload.stream_id !== undefined) return null; + // The native typed wrapper has one aliased field, not two competing statuses. + if (Object.hasOwn(payload, "status_code") && Object.hasOwn(payload, "status")) return null; + const status = Object.hasOwn(payload, "status_code") ? payload.status_code : payload.status; + if (typeof status !== "number" || !Number.isInteger(status) || status < 400 || status > 499) return null; + const error = payload.error; + if (error != null && (!record(error) + || [error.code, error.message].some(value => value != null && typeof value !== "string"))) return null; + if (payload.headers != null && !record(payload.headers)) return null; + const headers = rejectionHeaders(record(payload.headers) ? payload.headers : {}, prelude); + return new Response(JSON.stringify({ + error: error ?? { type: "upstream_error", message: "Upstream rejected the request" }, + }), { status, headers }); +} + /** The sole SSE exchange state machine for both one-shot and retained sockets. */ export function codexWsExchange(options: ExchangeOptions): Promise { const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch } = options; @@ -193,6 +257,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise { if (!controlFrame && !type.startsWith("response.") && type !== "error") return; if (!controlFrame) { try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; } + // Correlation must run first: a reused socket's foreign-stream error + // must not become an HTTP refusal that could authorize account replay. + if (metadata && sent && !responseCommitted && type === "error") { + let rejection: Response | null; + try { rejection = wrappedRejectionResponse(normalized.payload, metadata.snapshot()); } + catch (error) { failStream(error); return; } + if (rejection) { + terminal = true; + cleanup(); + try { controller.close(); } catch { /* unused stream already closed */ } + session.dispose(); + resolve(rejection); + return; + } + } commitResponse(); } const prefix = encoder.encode(`event: ${type}\ndata: `); diff --git a/src/server/responses/codex-ws-pool.ts b/src/server/responses/codex-ws-pool.ts index 378cf2d4a3..5d406bee4f 100644 --- a/src/server/responses/codex-ws-pool.ts +++ b/src/server/responses/codex-ws-pool.ts @@ -25,7 +25,7 @@ function digest(input: unknown): string { } /** Identity comes from the selected outgoing request, never a model label or caller hint. */ -export function codexWsReuseIdentity(url: string, headers: Record, frameText: string): CodexWsReuseIdentity | null { +export function codexWsReuseIdentity(url: string, headers: Record, frameText: string, proxy?: string): CodexWsReuseIdentity | null { if (url !== CODEX_RESPONSES_HTTP_URL) return null; let body: unknown; try { body = JSON.parse(frameText); } catch { return null; } @@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record): CodexWsSession | null { + acquire(identity: CodexWsReuseIdentity, url: string, headers: Record, proxy?: string): CodexWsSession | null { this.sweep(); for (const entry of this.entries.values()) { if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue; @@ -94,7 +94,7 @@ export class CodexWsPool { this.remove(oldest); } const createdAt = this.now(); - const session = new CodexWsSession(url, headers, true, () => this.changed(entry)); + const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy); const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false }; session.reserve(); this.entries.set(identity.key, entry); diff --git a/src/server/responses/codex-ws-session.ts b/src/server/responses/codex-ws-session.ts index bbf62f8137..32716a5297 100644 --- a/src/server/responses/codex-ws-session.ts +++ b/src/server/responses/codex-ws-session.ts @@ -10,8 +10,8 @@ export class CodexWsSession { private readonly completedIds = new Set(); constructor(url: string, headers: Record, readonly retainable = false, - private readonly changed: () => void = () => {}) { - this.socket = new WebSocket(url, { headers } as unknown as string[]); + private readonly changed: () => void = () => {}, proxy?: string) { + this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]); this.socket.addEventListener("open", this.onOpen); this.socket.addEventListener("message", this.onIdleMessage); this.socket.addEventListener("close", this.onClose); diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 46f1c3b8f2..7b71bea477 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -7,6 +7,7 @@ import { resolveEnvValue, } from "../../config"; import { parseRequest } from "../../responses/parser"; +import { externalTaskInputContent } from "../../responses/task-input"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; @@ -605,7 +606,7 @@ function leadingDeveloperPrefixLength(items: readonly unknown[]): number { function isConversationalItem(item: unknown): boolean { if (!isRecord(item)) return false; - if (item.type === "agent_message") return true; + if (item.type === "agent_message" || externalTaskInputContent(item) !== undefined) return true; const type = item.type ?? (typeof item.role === "string" ? "message" : undefined); return type === "message" && (item.role === "user" || item.role === "assistant"); } diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 3856c32b98..bbc90d0ca1 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -135,9 +135,13 @@ export type ComboStreamPreflightResult = export async function preflightComboStreamResponse( response: Response, logCtx: RequestLogContext, + retryableTerminal: (payload: unknown) => boolean = retryableZeroOutputTerminal, + options?: { allowMissingContentType?: boolean; replayReadErrors?: boolean }, ): Promise { const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; - if (!response.ok || !response.body || !contentType.includes("text/event-stream")) { + const isEventStream = contentType.includes("text/event-stream") + || (!contentType && options?.allowMissingContentType === true); + if (!response.ok || !response.body || !isEventStream) { return { kind: "accepted", response }; } @@ -150,18 +154,30 @@ export async function preflightComboStreamResponse( const inspector = createSseInspector({ logCtx, onParsedPayload: payload => { - if (comboStreamPayloadCommitsOutput(payload)) outputCommitted = true; + const retryable = retryableTerminal(payload); + const matchedBareError = retryable && payload !== null && typeof payload === "object" + && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error"; + // Only an explicit caller predicate may opt a known bare error into replay. + // Default combo classification still commits unknown/error events. + if (comboStreamPayloadCommitsOutput(payload) && !matchedBareError) outputCommitted = true; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return; - if (retryableZeroOutputTerminal(payload)) { - retryableTerminalPayload = payload as Record; - } + if (retryable) retryableTerminalPayload = payload as Record; }, onTerminal: status => { terminalStatus = status; }, }); try { for (;;) { - const next = await reader.read(); + let next: Awaited>; + try { + next = await reader.read(); + } catch (error) { + if (!options?.replayReadErrors) throw error; + // The native relay still owns post-header transport failures. Preserve + // the bounded prefix and the errored reader; cancelling it here would + // erase the failure before either client relay or inspection sees it. + return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) }; + } if (next.done) { inspector.finish(); } else { @@ -180,7 +196,10 @@ export async function preflightComboStreamResponse( inspector.feed(retained); } - if ((terminalStatus === "failed" || terminalStatus === "incomplete") + // A bare error event is not a protocol terminal (terminalStatus stays undefined), + // so its exact-message retryable match doubles as the terminal evidence. + if ((terminalStatus === "failed" || terminalStatus === "incomplete" + || retryableTerminalPayload?.type === "error") && !outputCommitted && retryableTerminalPayload) { await reader.cancel("retrying zero-output combo stream terminal").catch(() => undefined); return { kind: "failed", response: failedTerminalResponse(response, retryableTerminalPayload, logCtx) }; diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index abc1ae162c..43464d616f 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -107,7 +107,8 @@ import type { WsData } from "../ws-bridge"; import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle"; import type { AdmissionLease } from "../../lib/admission"; import { redactSecretString } from "../../lib/redact"; -import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { readBoundedResponseBytes } from "../../lib/bounded-body"; +import { resolveStallTimeoutSec } from "../../stall-timeout"; import { isRateLimitOrQuotaFailureMessage } from "../../lib/errors"; import { supportedLadderFor } from "../effort-policy"; import { @@ -203,6 +204,8 @@ function compactHandoffRoute(req: Request, previousModel: string, now = Date.now export interface HandleResponsesCompactOptions { nativeMainRefreshDependencies?: NativeMainRefreshDependencies; + /** Release the listener's idle guard only after the complete request body is accepted. */ + onRequestBodyRead?: () => void; } export function compactResponseTooLargeError(): Response { @@ -442,43 +445,45 @@ function compactResponseHeaders(upstream: Response): Headers { return headers; } -export async function bufferCompactResponse(upstream: Response, signal: AbortSignal): Promise { - const reader = upstream.body?.getReader(); +export async function bufferCompactResponse( + upstream: Response, + signal: AbortSignal, + stallTimeoutSec?: number, +): Promise { const headers = compactResponseHeaders(upstream); - if (!reader) return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers }); - const declaredLength = Number(upstream.headers.get("content-length")); - if (Number.isFinite(declaredLength) && declaredLength > COMPACT_RESPONSE_MAX_BYTES) { - await reader.cancel("compact_response_too_large").catch(() => undefined); - return compactResponseTooLargeError(); - } - const chunks: Uint8Array[] = []; - let total = 0; try { - while (true) { - if (signal.aborted) { - await reader.cancel(signal.reason).catch(() => undefined); - return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); - } - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > COMPACT_RESPONSE_MAX_BYTES) { - await reader.cancel("compact_response_too_large").catch(() => undefined); - return compactResponseTooLargeError(); - } - chunks.push(value); + if (signal.aborted) { + // No reader is attached yet. Cancellation must not wait for a broken source's cleanup. + void upstream.body?.cancel(signal.reason).catch(() => undefined); + return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } - } catch { + if (!upstream.body) return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers }); + const declaredLength = Number(upstream.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > COMPACT_RESPONSE_MAX_BYTES) { + void upstream.body.cancel("compact_response_too_large").catch(() => undefined); + return compactResponseTooLargeError(); + } + // Header admission has finished; only non-empty body chunks re-arm this deadline. + // The raw reader preserves bytes and cancels/releases without awaiting source cleanup. + const result = await readBoundedResponseBytes(upstream, { + signal, + maxBytes: COMPACT_RESPONSE_MAX_BYTES, + inactivityTimeoutMs: resolveStallTimeoutSec(stallTimeoutSec) * 1_000, + }); + if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); + if (result.oversized) return compactResponseTooLargeError(); + return new Response(result.bytes, { status: upstream.status, statusText: upstream.statusText, headers }); + } catch (error) { if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); + if (error instanceof DOMException && error.name === "TimeoutError") { + return Response.json({ error: { + message: "Compact response body stalled", + type: "upstream_stall_timeout", + code: "upstream_stall_timeout", + } }, { status: 504 }); + } return formatErrorResponse(502, "upstream_error", "Failed to read compact response"); } - const body = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers }); } @@ -504,6 +509,7 @@ export async function handleResponsesCompact( if (typeof raw.model !== "string" || raw.model.length === 0) { return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model"); } + options.onRequestBodyRead?.(); // Correct the IDENTITY before routing, or the synthetic id does not route at all. Held in // a local rather than written back to `raw.model`: assigning to the property widens it out // of the `string` narrowing the guard above just established. @@ -1038,7 +1044,7 @@ export async function handleResponsesCompact( upstream.headers.get("x-codex-secondary-reset-at"), upstream.headers.get("x-codex-tertiary-reset-at"), ].filter(Boolean); - const buffered = await bufferCompactResponse(upstream, req.signal); + const buffered = await bufferCompactResponse(upstream, req.signal, config.stallTimeoutSec); const bufferedErrorText = buffered.ok ? "" : await buffered.clone().text().catch(() => ""); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a45b95d4cd..08486d45b7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,6 +1,6 @@ import type { Server } from "bun"; import { randomUUID } from "node:crypto"; -import { adapterEventDiagnosticDetails, bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type BridgeDiagnosticContext, type BridgeDiagnosticSequence, type ResponsesTerminalStatus } from "../../bridge"; +import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { formatPassthroughUpstreamError } from "./passthrough-error"; import { createResponsesFieldBackfillBlockRewrite, @@ -21,9 +21,6 @@ import { resolveEnvValue, } from "../../config"; import { parseRequest } from "../../responses/parser"; -import { googleProviderOptionsRouteError } from "../../responses/google-provider-options"; -import { interceptRuntimeFailure } from "../../telemetry/hook"; -import type { ProviderAdapter } from "../../adapters/base"; import { bindReasoningReplayScope, commitReasoningReplayServingIdentity, @@ -45,14 +42,10 @@ import { copyPreviousResponseReplayProvenance, expandPreviousResponseInput, markBodyNonPersistable, - prepareResponseStateReplay, - prepareSensitiveResponsePersistence, previousResponseProviderState, - previousResponseReplayPrefixLength, previousResponseReplayFailure, previousResponseScopeMismatch, rememberResponseState, - type ResponseStateDurability, } from "../../responses/state"; import { bindTurnTerminationScope, @@ -93,8 +86,7 @@ import { pickComboTargetWithWait, targetKey, } from "../../combos"; -import { isDebugEnabled, isInjectionDebugEnabled } from "../../lib/debug-settings"; -import { debugStreamDiagnostic } from "../../lib/debug"; +import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { CYBER_POLICY_ERROR_CODE, CYBER_POLICY_FALLBACK_MESSAGE, @@ -103,16 +95,14 @@ import { isCyberPolicyMessage, } from "../../lib/errors"; import { injectionDebugLog } from "../../lib/injection-debug-log"; -import { OcxRequestValidationError } from "../../lib/errors"; -import { resolveClientRetryAfter } from "../../lib/retry-after"; import { antigravityOAuthDestinationConfigError, isAntigravityOAuthProvider } from "../../lib/provider-tls-profile"; +import { resolveClientRetryAfter } from "../../lib/retry-after"; import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit"; import { CODE_MODE_EXEC_TOOL_NAME, modelInList, namespacedToolName } from "../../types"; import type { AdapterEvent, OcxConfig, OcxParsedRequest, - ClaudeSourceEnvelope, OcxProviderConfig, OcxProviderContinuationOwner, OcxProviderContinuationState, @@ -122,29 +112,27 @@ import type { } from "../../types"; import { forceRefreshOAuthAccessSnapshot, - getValidAccessSnapshotForAccount, getValidAccessTokenForAccount, - getValidAccessTokenSnapshotForAccount, + getValidAccessSnapshotForAccount, getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage, type OAuthAccessSnapshot, UnsupportedOAuthProviderError, } from "../../oauth"; -import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot"; +import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountCredentialWithStatus } from "../../oauth/store"; import { ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST, anthropicSessionKeyFromParts, - bindAnthropicSessionAffinity, + commitAnthropicSelectionRouting, formatAnthropicProviderForLog, - getAnthropicPoolAccessToken, + getAnthropicPoolAccessSnapshot, getAnthropicPoolRetryAfterSeconds, isAnthropicAccountPoolEnabled, hasAnthropicFailoverQuorum, - promoteAnthropicActiveAccount, resolveAnthropicAccountForSession, rotateAnthropicAccountOn429, + type AnthropicAccountSelectionReason, } from "../../oauth/anthropic-routing"; -import { stampOAuthAccountLabel } from "../../providers/label"; import { antigravitySessionKeyFromParts, bindAntigravitySessionAffinity, @@ -164,7 +152,7 @@ import { rotateCursorAccountOn429, rotateCursorAccountOnAuth, } from "../../oauth/cursor-routing"; -import { getAccountCredential } from "../../oauth/store"; +import { stampOAuthAccountLabel } from "../../providers/label"; import { failoverAccountSnapshot, forgetGenericFailoverRoster, @@ -174,7 +162,8 @@ import { preferredInitialAccount, rotateGenericOAuthAccountOn429, } from "../../oauth/generic-account-failover"; -import { buildWebSearchTool, mediaBridgeWillRun, planWebSearch, resolveCcaInTurnGrounding, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; +import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot"; +import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; @@ -189,7 +178,6 @@ import { CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, headersForCodexAuthContext, - materializeCodexUpstreamAuth, materializeCodexUpstreamAuthAsync, isCodexAuthContextUsable, resolveCodexAuthContext, @@ -202,7 +190,6 @@ import { } from "../../codex/auth-context"; import { entitledCodexAccountIdsForModel, - isDirectCallerEntitledToCodexModel, invalidateCodexModelEntitlementsForAccount, resolveCodexModelEntitlements, } from "../../codex/model-entitlements"; @@ -222,10 +209,15 @@ import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome, } from "../../codex/routing"; -import { TokenRefreshError, forceRefreshCodexPoolToken } from "../../codex/account-store"; +import { + TokenRefreshError, + forceRefreshCodexPoolToken, + readCodexAccountRecord, +} from "../../codex/account-store"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { applyUpstreamRecoveryInit, + fetchWithResetRetry, fetchWithTransientRetry, prepareSameTarget429Wait, sleepWithAbort, @@ -239,7 +231,6 @@ import type { DataPlaneAdmission } from "../auth-cors"; import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; -import { decideV2RoutedDelegationBridge } from "./v2-routed-delegation-policy"; import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../../codex/loopback-target"; import { providerContextCap } from "../../providers/context-cap"; import { @@ -271,7 +262,8 @@ import { providerModelResponsesUpstreamStreaming, type InboundWire, } from "../../providers/registry"; -import type { AdapterRequest } from "../../adapters/base"; +import type { AdapterRequest, ProviderAdapter } from "../../adapters/base"; +import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../../providers/api-key-selection"; import { hasKeyPoolFailover, rateLimitRetryDelayMs, @@ -294,13 +286,13 @@ import { import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { + ENCRYPTED_FUNCTION_OUTPUT_REJECTION, isRateLimitOrQuotaFailureMessage, upstreamErrorMessageFromPayload, } from "../../lib/errors"; import type { AdmissionLease } from "../../lib/admission"; import { supportedLadderFor } from "../effort-policy"; -import { classifyAgentKind, isThreadSpawnRequest } from "../effort-policy"; -import { isMultiAgentV2Enabled } from "../../codex/features"; +import { isThreadSpawnRequest } from "../effort-policy"; import { applySubagentModelFallback, maybePrimeSubagentQuota, @@ -323,23 +315,18 @@ import { recordAttemptRequestedEffort, requestLogSpeedLabel, sealRequestAttemptIdentity, + recordAttemptCredentialSource, usageFromResponsesPayload, type RequestLogContext, } from "../request-log"; import { conversationIdFromResponsesRequest, - inboundClientThreadIdFromRequest, normalizeLogConversationId, reasoningReplayConversationIdFromResponsesRequest, sessionLaneIdFromRequest, sessionIdHeaderFromRequest, } from "../request-log-conversation"; import type { AttemptRecoveryKind } from "../../usage/log"; -import { - beginConversationTurn, - guardRepeatedPreToolText, - observeTurnProgress, -} from "../conversation-progress"; import { consumeForInspection, consumeForResponseLogMetadata, @@ -355,7 +342,9 @@ import { import { agentTaskRecoveryConfig, discardEncryptedAgentTaskRecovery, - recoverEncryptedAgentTask, + recoverEncryptedAgentTaskWithResult, + restoreCachedEncryptedAgentTasks, + type AgentTaskRecoveryFailureReason, } from "./agent-task-recovery"; import { relaySseEagerBounded } from "../relay-eager"; import { @@ -391,16 +380,9 @@ import { import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; -import { decideV2NativeParentOverride } from "./v2-native-parent-override"; -import { - createV2RoutedDelegationSseRewrite, - injectV2RoutedDelegationBridge, - rewriteV2RoutedDelegationCallsInJson, - type V2RoutedDelegationBridgeContext, -} from "./v2-routed-delegation-bridge"; import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload"; -import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, UpstreamRedirectError } from "./fetch-helpers"; +import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, type ProviderFetchOptions } from "./fetch-helpers"; import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability"; import { acquireUpstreamHostAdmission, @@ -412,6 +394,7 @@ import { upstreamHostHealthKey, type UpstreamHostAdmissionLease, } from "../../codex/upstream-host-health"; +import { createGrokResponsesSparseTerminalBlockRewrite } from "../grok-responses-snapshot-repair"; import { createResponsesSnapshotBlockRewrite, hasResponsesSnapshotRepair, @@ -425,6 +408,8 @@ import { } from "../sse-payload-rewrite"; import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; +import { collectFunctionCallRepairSchemas, repairFunctionCallsInJson } from "../../responses/function-call-compat"; +import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; import { @@ -451,7 +436,8 @@ import { responsesJsonToSseStream } from "../responses-json-events"; import { streamingContextOverflowResponse } from "./context-overflow"; import { guardTerminalEventStream } from "./terminal-guard"; import { - emptyCompletionPolicy, + emptyCompletionRetryEnabled, + emptyCompletionNotice, observeEmptyCompletion, guardEmptyCompletionEventStream, } from "./empty-completion-guard"; @@ -462,65 +448,6 @@ import { preflightComboStreamResponse } from "./combo-stream-preflight"; // already-committed event boundary and can replay custom adapter work. const runTurnAdapterSseResponses = new WeakSet(); -function diagnoseAdapterEvents( - events: AsyncIterable, - adapterName: string, - requestId: string | undefined, - logCtx: RequestLogContext, - state: BridgeDiagnosticSequence, -): AsyncIterable { - if (!requestId) return events; - return (async function* () { - for await (const event of events) { - const attempt = logCtx.activeAttempt; - debugStreamDiagnostic( - { - requestId, - adapterName, - ...(attempt?.ordinal !== undefined ? { attempt: attempt.ordinal } : {}), - ...(attempt?.recoveryKinds.at(-1) !== undefined ? { recovery: attempt.recoveryKinds.at(-1) } : {}), - }, - "adapter", - ++state.value, - event.type, - adapterEventDiagnosticDetails(event), - ); - yield event; - } - })(); -} - -function observeProgressEvents( - events: AsyncIterable, - logCtx: RequestLogContext, -): AsyncIterable { - if (!logCtx.turnProgress) return events; - const observed = (async function* () { - for await (const event of events) { - observeTurnProgress(logCtx.turnProgress!, event); - yield event; - } - })(); - const guarded = logCtx.turnProgressTrackerKey - ? guardRepeatedPreToolText(observed, logCtx.turnProgressTrackerKey, logCtx.turnProgress) - : observed; - return (async function* () { - yield* guarded; - })(); -} - -async function collectProgressEvents( - events: AdapterEvent[], - logCtx: RequestLogContext, -): Promise { - const collected: AdapterEvent[] = []; - for await (const event of observeProgressEvents( - (async function* () { yield* events; })(), - logCtx, - )) collected.push(event); - return collected; -} - /** * Adapters whose continuation state must survive Codex's store:false requests. */ @@ -539,8 +466,10 @@ export function sidecarOutcomeRecorder( probeLeaseId: authCtx.probeLeaseId, probeQuotaScope: authCtx.probeQuotaScope, writerGeneration: authCtx.writerGeneration, - // Sidecar auth failures describe the exact stored credential that was used. Preserve that - // generation fence so a later replacement does not inherit the old credential's quarantine. + // A vision or web-search sidecar can return 401/403, and that is evidence about the exact + // stored credential it used. Without the generation it becomes an account-wide quarantine + // that a replacement inherits (#2892 gap 4). `main-pool` has no stored-record generation, so + // it keeps the unfenced account-wide semantics. ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), }) : undefined; @@ -704,7 +633,7 @@ function bindRouteReasoningReplayScope(args: { // seed assigned before route binding. A Cursor conversation must be scoped to the exact // provider/destination/adapter/model/credential that serves it. if (continuationOwner) parsed._cursorIdentityScope = providerContinuationRouteScope(continuationOwner); - else if (!parsed._cursorIdentityScope?.trim()) { + else if (!parsed._cursorIdentityScope?.startsWith("cursor-unowned:")) { // Prevent the adapter's token-only fallback from recreating a provider-private id after the // route owner failed closed. The sentinel is per parsed request and contains no credential. parsed._cursorIdentityScope = `cursor-unowned:${randomUUID()}`; @@ -739,28 +668,96 @@ const OPAQUE_RESPONSES_INPUT_TYPES = new Set([ "compaction_summary", "context_compaction", ]); +const FUNCTION_OUTPUT_TYPES = new Set(["function_call_output", "custom_tool_call_output"]); +// codex-app subagent results replay as agent_message items whose content parts may carry +// backend-minted encrypted_content; the ChatGPT backend decrypts them in its function-output +// path, so a cross-identity replay of those parts produces ENCRYPTED_FUNCTION_OUTPUT_REJECTION. +const AGENT_MESSAGE_TYPE = "agent_message"; + +function encryptedFunctionOutputParts(output: unknown): boolean { + return Array.isArray(output) && output.some(part => ( + part !== null + && typeof part === "object" + && !Array.isArray(part) + && (part as { type?: unknown }).type === "encrypted_content" + && typeof (part as { encrypted_content?: unknown }).encrypted_content === "string" + && (part as { encrypted_content: string }).encrypted_content.length > 0 + )); +} -function outboundResponsesBodyCarriesOpaqueBlob(bodyText: string | undefined): boolean { - if (!bodyText) return false; +function outboundResponsesInput(bodyText: string | undefined): unknown[] | undefined { + if (!bodyText) return undefined; try { const body = JSON.parse(bodyText) as unknown; - if (!body || typeof body !== "object" || Array.isArray(body)) return false; + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; const input = (body as { input?: unknown }).input; - if (!Array.isArray(input)) return false; - return input.some(item => { - if (!item || typeof item !== "object" || Array.isArray(item)) return false; - const candidate = item as { type?: unknown; encrypted_content?: unknown }; - return typeof candidate.type === "string" - && OPAQUE_RESPONSES_INPUT_TYPES.has(candidate.type) - && typeof candidate.encrypted_content === "string" - && candidate.encrypted_content.length > 0; - }); + return Array.isArray(input) ? input : undefined; + } catch { + return undefined; + } +} + +function outboundResponsesBodyCarriesEncryptedFunctionOutput(bodyText: string | undefined): boolean { + const input = outboundResponsesInput(bodyText); + if (!input) return false; + return input.some(item => { + if (item === null || typeof item !== "object" || Array.isArray(item)) return false; + const candidate = item as { type?: unknown; output?: unknown; content?: unknown }; + const type = String(candidate.type ?? ""); + if (FUNCTION_OUTPUT_TYPES.has(type) && encryptedFunctionOutputParts(candidate.output)) return true; + return type === AGENT_MESSAGE_TYPE && encryptedFunctionOutputParts(candidate.content); + }); +} + +function outboundResponsesBodyCarriesOpaqueBlob(bodyText: string | undefined): boolean { + const input = outboundResponsesInput(bodyText); + if (!input) return false; + return input.some(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const candidate = item as { type?: unknown; encrypted_content?: unknown; output?: unknown }; + if ( + typeof candidate.type === "string" + && OPAQUE_RESPONSES_INPUT_TYPES.has(candidate.type) + && typeof candidate.encrypted_content === "string" + && candidate.encrypted_content.length > 0 + ) return true; + if ( + typeof candidate.type === "string" + && FUNCTION_OUTPUT_TYPES.has(candidate.type) + && encryptedFunctionOutputParts(candidate.output) + ) return true; + return candidate.type === AGENT_MESSAGE_TYPE + && encryptedFunctionOutputParts((candidate as { content?: unknown }).content); + }); +} + +function isEncryptedFunctionOutputRejection(bodyText: string): boolean { + if (bodyText.trim() === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + try { + const payload = JSON.parse(bodyText) as unknown; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as { detail?: unknown; message?: unknown; error?: unknown }; + if (record.detail === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + if (record.message === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + if (record.error === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + return record.error !== null + && typeof record.error === "object" + && !Array.isArray(record.error) + && (record.error as { message?: unknown }).message === ENCRYPTED_FUNCTION_OUTPUT_REJECTION; } catch { return false; } } function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { + if (isEncryptedFunctionOutputRejection(bodyText)) return true; + try { + if (upstreamErrorMessageFromPayload(JSON.parse(bodyText) as unknown) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) { + return true; + } + } catch { + /* invalid JSON bodies fall through to the exact nested envelope checks */ + } try { const payload = JSON.parse(bodyText) as unknown; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; @@ -805,8 +802,13 @@ export function shouldAttemptOpaqueBlobRecovery(args: { errorBody: string; alreadyAttempted: boolean; }): boolean { - return args.status >= 400 - && args.status < 500 + const acceptedStatus = (args.status >= 400 && args.status < 500) + || ( + args.status === 502 + && outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody) + && isEncryptedFunctionOutputRejection(args.errorBody) + ); + return acceptedStatus && args.adapterName === "openai-responses" && !args.alreadyAttempted && outboundResponsesBodyCarriesOpaqueBlob(args.outboundBody) @@ -822,7 +824,7 @@ async function opaqueBlobRejectionBodyForRecovery( ): Promise { if ( response.status < 400 - || response.status >= 500 + || (response.status >= 500 && response.status !== 502) || adapterName !== "openai-responses" || alreadyAttempted || !outboundResponsesBodyCarriesOpaqueBlob(outboundBody) @@ -899,6 +901,50 @@ function normalizeUpstreamErrorText(text: string, fallback: string): NormalizedU function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void { parsed._stripReasoningEncryptedContent = true; + const rawBody = parsed._rawBody; + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return; + const input = (rawBody as { input?: unknown }).input; + if (!Array.isArray(input)) return; + const stripEncryptedParts = (parts: unknown[]): unknown[] => { + let changed = false; + const stripped = parts.map(part => { + if ( + part !== null + && typeof part === "object" + && !Array.isArray(part) + && (part as { type?: unknown }).type === "encrypted_content" + && typeof (part as { encrypted_content?: unknown }).encrypted_content === "string" + && (part as { encrypted_content: string }).encrypted_content.length > 0 + ) { + changed = true; + return { type: "input_text", text: "[encrypted content omitted]" }; + } + return part; + }); + return changed ? stripped : parts; + }; + const strippedInput = input.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const record = item as Record; + const type = String(record.type ?? ""); + if (FUNCTION_OUTPUT_TYPES.has(type) && Array.isArray(record.output)) { + const output = stripEncryptedParts(record.output); + return output !== record.output ? { ...record, output } : item; + } + if (type === AGENT_MESSAGE_TYPE && Array.isArray(record.content)) { + const content = stripEncryptedParts(record.content); + return content !== record.content ? { ...record, content } : item; + } + return item; + }); + Object.assign(rawBody, { input: strippedInput }); +} + +function resetStreamedOpaqueBlobLogContext(logCtx: RequestLogContext): void { + delete logCtx.upstreamError; + delete logCtx.terminalHttpStatus; + delete logCtx.terminalErrorCode; + delete logCtx.terminalIncompleteReason; } type OpaqueBlobRecoveryGuard = { attempted: boolean }; @@ -1107,7 +1153,14 @@ interface CodexPoolAccountRetryArgs { firstAuthCtx: Extract; firstResponse: Response; outcomeStatus: number; - /** Refuse a different account after a stored-credential refresh replay spent the request budget. */ + /** + * Forbid resolving a DIFFERENT account for this retry. + * + * Set when a stored Pool 401 already spent this logical request's account budget on its own + * refresh and replay. The same-account gated-model retry above stays available, because it + * sends to the account that was already paying; only the alternate-account resolution below is + * out of budget. + */ sameAccountOnly?: boolean; upstream: AbortController; connectMs: number; @@ -1135,12 +1188,15 @@ type CodexPoolAccountRetryResult = authCtx: CodexAuthContext; }; -/** Keep every retry-stage entitlement snapshot inside the native-main selection fence. */ +/** Keep retry-stage entitlement snapshots inside the native-main selection fence. */ async function resolveCodexRetryModelEntitlements( config: OcxConfig, resolver: typeof resolveCodexModelEntitlements, turnAdmissionLease?: AdmissionLease, ): Promise>> { + // The initial auth selection has already released its admission before the first + // response arrives. Re-enter for every refresh so profile switching cannot overlap + // credential discovery, and omit main entirely when a drain or recovery owns it. const selectionAdmission = codexAccountSelectionForTurn(turnAdmissionLease)?.(); const nativeMainReadsForbidden = isNativeMainTrafficBlocked() || selectionAdmission?.mainProfileDraining === true; @@ -1391,6 +1447,7 @@ async function retryCodexPoolOnAlternateAccount( retryAdapter.name, logCtx.accountLogLabel, ); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name); const retrySameConfirmedAccount = outcomeStatus === 400 && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId) @@ -1429,6 +1486,8 @@ async function retryCodexPoolOnAlternateAccount( route.provider.authMode === "forward", ); } catch (error) { + // Only the forward send is a transport boundary. Entitlement resolver throws below are + // deliberately outside this catch so programming errors retain their original path. return { kind: "transport", error, authCtx: retryAuthCtx }; } retrySendCount += 1; @@ -1497,7 +1556,9 @@ export function codexForwardTerminalOutcomeRecorder( ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined { if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined; return (status, httpStatusOverride) => { - if (status === "incomplete") { + const quotaStatus = [httpStatusOverride, logCtx?.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (status === "incomplete" && quotaStatus === undefined) { // Normal limit/content-filter/stall terminal — the account served the // request. Don't penalize account health; record success to clear any // prior soft-avoid so a healthy account isn't stuck avoided. @@ -1522,7 +1583,7 @@ export function codexForwardTerminalOutcomeRecorder( // the parent's terminalHttpStatus so the semantic status is not lost. const outcome = status === "completed" ? 200 - : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); + : (quotaStatus ?? httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId: authCtx.affinityKey, fixedAccount: authCtx.fixedAccount, @@ -1530,38 +1591,14 @@ export function codexForwardTerminalOutcomeRecorder( probeLeaseId: codexProbeLeaseId(authCtx), probeQuotaScope: codexProbeQuotaScope(authCtx), writerGeneration: authCtx.writerGeneration, + // A mid-stream terminal can carry a semantic 401 long after the credential was + // replaced. It is never replayed — the client already saw output — but it must + // not retire the replacement either (#2887). + ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), }); }; } -function subagentSpawnFailureMessageForTerminal( - status: ResponsesTerminalStatus, - httpStatusOverride?: number, - logCtx?: RequestLogContext, -): string | number | undefined { - if (status !== "failed") return undefined; - const statusCode = httpStatusOverride ?? logCtx?.terminalHttpStatus; - if (statusCode === 429 || statusCode === 402) return statusCode; - if (typeof statusCode === "number" && statusCode >= 500 && statusCode < 600) return statusCode; - const terminalMessage = logCtx?.upstreamError?.trim(); - if (terminalMessage) return terminalMessage; - return undefined; -} - -function recordSubagentSpawnFailureForTerminal( - headers: Headers, - model: string, - status: ResponsesTerminalStatus, - config: OcxConfig, - accountId: string | null, - httpStatusOverride?: number, - logCtx?: RequestLogContext, -): void { - const failureMessage = subagentSpawnFailureMessageForTerminal(status, httpStatusOverride, logCtx); - if (failureMessage === undefined) return; - recordSubagentQuotaFailureForThreadSpawn(headers, model, failureMessage, config, accountId); -} - export function decodeRequestErrorResponse(err: unknown, label: string): Response { @@ -1622,70 +1659,6 @@ export interface ConsumedComboFailure { -export type ResolvedRouteInfo = { - parsed: OcxParsedRequest; - route: RouteResult; - provider: OcxProviderConfig; - modelId: string; - adapterName: string; - headers: Headers; -}; - -/** Benchmark-only observation passed to the optional raw-usage observer (phase 4 plan 04-02). */ -export interface ClaudeBenchmarkRawUsage { - adapterKind: string; - modelId: string; - usage: OcxUsage | undefined; -} - -/** Invoke the benchmark observer at most once per attempt context (per registration). */ -function observeBenchmarkUsage( - observer: HandleResponsesOptions["claudeBenchmarkObserver"], - gate: { done: boolean }, - adapterKind: string, - modelId: string, - usage: OcxUsage | undefined, -): void { - if (!observer || gate.done) return; - gate.done = true; - const safeUsage = usage ? { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - ...(usage.contextTotalTokens !== undefined ? { contextTotalTokens: usage.contextTotalTokens } : {}), - ...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}), - ...(usage.cachedInputTokens !== undefined ? { cachedInputTokens: usage.cachedInputTokens } : {}), - ...(usage.cacheReadInputTokens !== undefined ? { cacheReadInputTokens: usage.cacheReadInputTokens } : {}), - ...(usage.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: usage.cacheCreationInputTokens } : {}), - ...(usage.reasoningOutputTokens !== undefined ? { reasoningOutputTokens: usage.reasoningOutputTokens } : {}), - ...(usage.estimated !== undefined ? { estimated: usage.estimated } : {}), - } : undefined; - try { - observer({ adapterKind, modelId, usage: safeUsage }); - } catch { - // Benchmark-only seam: ordinary requests must be unaffected even when the - // observer misbehaves. - } -} - -function maybeInvokeResolvedRoute( - options: HandleResponsesOptions, - parsed: OcxParsedRequest, - route: RouteResult, - provider: OcxProviderConfig, - adapterName: string, - headers: Headers, -) { - const cb = options.onResolvedRoute; - if (!cb) return; - try { - cb({ parsed, route, provider, modelId: route.modelId, adapterName, headers }); - } catch (err) { - throw err instanceof OcxRequestValidationError - ? err - : new OcxRequestValidationError(redactSecretString(err instanceof Error ? err.message : String(err))); - } -} - export interface HandleResponsesOptions { /** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */ codexAuthPolicy?: CodexAuthPolicyConfig; @@ -1701,19 +1674,13 @@ export interface HandleResponsesOptions { admission?: DataPlaneAdmission; /** Called at most once after the complete client body is read and accepted for dispatch. */ onRequestBodyRead?: () => void; - /** Internal combo handoff invoked after the final adapter accepts the parsed request. */ - onRequestValidated?: () => void; forceEmptyResponseId?: boolean; abortSignal?: AbortSignal; - /** Internal deterministic seam for native-main refresh tests. */ - nativeMainRefreshDependencies?: NativeMainRefreshDependencies; /** One-shot TTFT callback: first non-empty model output observed (WP4). */ onFirstOutput?: () => void; onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void; /** Internal deterministic seam for account-gated native fallback tests. */ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; - /** Internal deterministic seam for Direct account-gated native auth tests. */ - isDirectCallerEntitledToCodexModel?: typeof isDirectCallerEntitledToCodexModel; recordTerminalOutcomes?: boolean; setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void; onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void; @@ -1722,6 +1689,8 @@ export interface HandleResponsesOptions { responsesTerminalRepairScheduler?: ResponsesTerminalRepairScheduler; /** Internal deterministic runtime-identity seam for Codex upstream WS selection tests. */ codexWsRuntimeIdentity?: BunRuntimeGateInput; + /** Test seam for native main refresh without live OAuth traffic. */ + nativeMainRefreshDependencies?: NativeMainRefreshDependencies; /** * When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort * (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity. @@ -1754,10 +1723,10 @@ export interface HandleResponsesOptions { }; /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */ deferCodexResetDerivedCooldown?: boolean; - /** A stored Pool credential was refreshed and its one allowed same-account replay was sent. */ - onStoredPool401ReplayDispatched?: () => void; /** 030-owned handoff when a child consumed the original failure under bounds. */ onConsumedComboFailure?: (failure: ConsumedComboFailure) => void; + /** A stored Pool credential was refreshed and its one allowed same-account replay was sent. */ + onStoredPool401ReplayDispatched?: () => void; /** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */ translatorBudget?: TranslatorBudget; /** @@ -1765,24 +1734,10 @@ export interface HandleResponsesOptions { * request IS the vision sidecar's own loopback describe call. The plan site * then STRIPS images instead of planning another describe — a depth cap of 1 * that holds under predicate drift and combo re-resolution. The Chat surface - * detects the raw `x-opencodex-vision-describe` header before its bridge - * rebuilds headers and carries the fact through this flag. - */ - visionDescribeTerminal?: boolean; - /** Request-local Anthropic source envelope; enables fidelity-preserving transport. */ - claudeSourceEnvelope?: ClaudeSourceEnvelope; - /** Synchronous callback invoked after each newly resolved route; must be pure and fast. */ - onResolvedRoute?: (info: ResolvedRouteInfo) => void; - /** - * Phase 4 plan 04-02: benchmark-only raw-usage observer. Optional and absent - * for ordinary requests. Receives a structural record only: final adapter - * kind, resolved model id, and the raw OcxUsage reported before any - * Anthropic wire normalization. Never receives request bodies, headers, - * provider names/aliases, endpoint, account identity, raw provider response, - * or error text. Invoked at most once per attempt context per onUsage - * registration; observer exceptions never affect ordinary request behavior. + * detects the raw `x-opencodex-vision-describe` header before its bridge + * rebuilds headers and carries the fact through this flag. */ - claudeBenchmarkObserver?: (observation: ClaudeBenchmarkRawUsage) => void; + visionDescribeTerminal?: boolean; } @@ -2000,13 +1955,14 @@ export const UPSTREAM_JSON_BODY_READ_OPTIONS = { firstByteTimeoutMs: UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS, }; -function unreadableEncryptedAgentTaskResponse(): Response { +function unreadableEncryptedAgentTaskResponse(reason?: AgentTaskRecoveryFailureReason): Response { return new Response( JSON.stringify({ error: { message: UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE, type: "invalid_request_error", code: "unreadable_encrypted_agent_task", + ...(reason === undefined ? {} : { recovery_reason: reason }), }, }), { status: 400, headers: { "Content-Type": "application/json" } }, @@ -2093,15 +2049,15 @@ async function resolveResponsesCodexAuth( requestScopedMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), resolveCodexModelEntitlements: options.resolveCodexModelEntitlements, - isDirectCallerEntitledToCodexModel: options.isDirectCallerEntitledToCodexModel, signal: options.abortSignal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, }); options.onCodexAuthContextResolved?.(authCtx); } else { - // A custom-named canonical-forward route still substitutes the physical native-main - // credential. Claim that profile before materializing auth so a switch drain cannot be - // bypassed merely because the provider row has a noncanonical name. + // A custom-named canonical-forward provider has no Codex account mode, but an + // admission bearer still substitutes the stored main credential below. Claim the + // same physical profile before synthesizing the main context so transport-based + // substitution cannot bypass a switch drain. if ( substituteMainCredential && ( @@ -2167,10 +2123,22 @@ async function resolveResponsesCodexAuth( } } +/** + * Terminal means the grant itself is dead and no retry can help. Everything else — + * an untyped network failure, a token-endpoint 5xx surfacing as `unknown`, an abort, + * refresh capacity, lock contention, a superseded flight — is transient, and treating + * it as terminal would quarantine a healthy account on an upstream blip, which is the + * defect this path exists to fix (#2887). + */ function isTerminalPoolRefreshFailure(error: unknown): boolean { return error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired"); } +/** + * One forced refresh and one same-account rebuild for a stored pool credential that + * upstream rejected with a pre-stream 401. `quarantine` distinguishes a dead grant, + * which must retire the account, from a transient failure, which must not. + */ async function refreshPoolForwardAuth(args: { req: Request; config: OcxConfig; @@ -2190,6 +2158,11 @@ async function refreshPoolForwardAuth(args: { signal: options.abortSignal, }); if (!refreshed.rotated) { + // The store resolved to the same bearer upstream just rejected. Replaying it + // would spend another upstream call to earn the identical 401. Upstream can do + // this on a SUCCESSFUL response by rotating only the refresh grant, so the + // credential generation may already have moved — quarantine has to be fenced on + // where the credential actually is, not on the generation we started from. return { ok: false, quarantine: true, @@ -2197,6 +2170,9 @@ async function refreshPoolForwardAuth(args: { response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), }; } + // Only a CAS this request performed itself proves the new credential descends from + // the rejected one. Somebody else's replacement may be a different identity, and + // its affinity must be retired rather than inherited. if (refreshed.selfRefreshed) { handOffThreadAffinityGeneration(authCtx.accountId, authCtx.generation, refreshed.generation); } @@ -2364,20 +2340,6 @@ async function applyFinalRouteRequestNormalization(args: { } } - // The private ChatGPT Codex Responses endpoint accepts only streaming - // requests, even when the downstream HTTP client asked for a unary JSON - // response. The caller preference was captured before this normalization; - // the passthrough response path drains this SSE back into bounded JSON. - if ( - isCanonicalOpenAiForwardProvider(route.provider) - && route.provider.adapter === "openai-responses" - ) { - parsed.stream = true; - if (parsed._rawBody && typeof parsed._rawBody === "object") { - (parsed._rawBody as Record).stream = true; - } - } - // Generic Responses clients (e.g. AI-SDK apps) omit `store`, but the canonical // forward Codex backend rejects a native request without an explicit store:false. // Default it only there — every other Responses upstream (key-auth providers and @@ -2449,8 +2411,6 @@ async function applyFinalRouteRequestNormalization(args: { subagentModels: config.subagentModels, subagentModelFallback: config.subagentModelFallback, injectionPrompt: config.injectionPrompt, - subagentRoles: config.subagentRoles, - syncCodexSubagentDefaults: config.syncCodexSubagentDefaults === true, }); if (guidance) { injectDeveloperMessage(parsed, guidance); @@ -2465,8 +2425,8 @@ async function applyFinalRouteRequestNormalization(args: { { const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); const surface = collabSurface(parsed); - if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true, logCtx.agentKind)) { - const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route), logCtx.agentKind); + if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) { + const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route)); if (capped) { logCtx.requestedEffort = `${capped.from}->${capped.to}`; if (isInjectionDebugEnabled()) { @@ -2478,19 +2438,6 @@ async function applyFinalRouteRequestNormalization(args: { } } - { - const { sanitizeEffortForModel, supportedLadderFor } = await import("../effort-policy"); - const previousEffort = parsed.options.reasoning; - const ladder = supportedLadderFor(route); - if (ladder !== undefined && ladder.length === 0 && previousEffort) { - sanitizeEffortForModel(parsed, ladder); - logCtx.requestedEffort = `${logCtx.requestedEffort ?? previousEffort}->none`; - if (isInjectionDebugEnabled()) { - injectionDebugLog(`[opencodex] ${route.modelId}: stripped reasoning effort for effortless model`); - } - } - } - { const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog"); const clamped = shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId) @@ -2535,8 +2482,7 @@ export async function handleComboResponses( // Expand previous_response_id before image policy and child dispatch so a // continuation that only references prior images still fails closed when // imageInput is disabled (and so targets see the full replayed input). - const inboundClientThreadId = inboundClientThreadIdFromRequest(req.headers); - await prepareResponseStateReplay(rawBody); + const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; const body = expandPreviousResponseInput(rawBody, inboundClientThreadId); const scopeMismatch = previousResponseScopeMismatch(body); if (scopeMismatch) { @@ -2608,19 +2554,12 @@ export async function handleComboResponses( let comboPayloadReadable = false; const payloadEligible = (target: (typeof combo.targets)[number]): boolean => comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); - const initialNow = Date.now(); - let pick: ReturnType = null; - const pickWithWait = (pickOptions: { - exclude?: Iterable; - eligible?: (target: NonNullable["targets"][number]) => boolean; - now?: number; - }) => pickComboTargetWithWait(config, comboId, { - ...pickOptions, - waitForCooldownMs: combo.waitForCooldownMs, - abortSignal: options.abortSignal, - }); - - if (unreadableEncryptedAgentTask && !combo.targets.some(canDecryptUnreadableAgentTask)) { + let encryptedTaskRecoveryAttempted = false; + let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined; + let storedPool401ReplayDispatched = false; + const recoverUnreadableEncryptedTask = async (): Promise => { + if (encryptedTaskRecoveryAttempted) return false; + encryptedTaskRecoveryAttempted = true; const recovery = agentTaskRecoveryConfig(config); if ( (options.inboundWire ?? "responses") !== "responses" @@ -2634,32 +2573,24 @@ export async function handleComboResponses( config, { parentThreadId: inboundClientThreadId }, ); - return unreadableEncryptedAgentTaskResponse(); - } - pick = await pickWithWait({ now: initialNow }); - if (!pick) { - discardEncryptedAgentTaskRecovery( - req, - (body as { input?: unknown } | undefined)?.input, - config, - { parentThreadId: inboundClientThreadId }, - ); - return options.abortSignal?.aborted - ? clientCancelledResponse() - : comboUnavailable(comboId); + return false; } let recovered = false; try { - recovered = await recoverEncryptedAgentTask( + const result = await recoverEncryptedAgentTaskWithResult( req, (body as { input?: unknown } | undefined)?.input, recovery, config, { parentThreadId: inboundClientThreadId, abortSignal: options.abortSignal }, ); + recovered = result.recovered; + recoveryFailureReason = result.recovered ? undefined : result.reason; } catch { recovered = false; + recoveryFailureReason = undefined; } + // Recovery has the same in-place input mutation contract as the direct routed path. if ( !recovered || hasUnreadableEncryptedAgentTask((body as { input?: unknown } | undefined)?.input) @@ -2670,16 +2601,47 @@ export async function handleComboResponses( config, { parentThreadId: inboundClientThreadId }, ); - return unreadableEncryptedAgentTaskResponse(); + return false; } comboPayloadReadable = true; comboReplaySnapshot.recoveredPlaintext = true; - } else { - pick = await pickWithWait({ - eligible: payloadEligible, - now: initialNow, - }); + return true; + }; + const initialNow = Date.now(); + const pickWithWait = (pickOptions: { + exclude?: Iterable; + eligible?: (target: NonNullable["targets"][number]) => boolean; + now?: number; + }) => pickComboTargetWithWait(config, comboId, { + ...pickOptions, + waitForCooldownMs: combo.waitForCooldownMs, + abortSignal: options.abortSignal, + }); + let pick = await pickWithWait({ + eligible: payloadEligible, + now: initialNow, + }); + + if (unreadableEncryptedAgentTask && !pick) { + pick = await pickWithWait({ now: initialNow }); + if (!pick) { + discardEncryptedAgentTaskRecovery( + req, + (body as { input?: unknown } | undefined)?.input, + config, + { parentThreadId: inboundClientThreadId }, + ); + return options.abortSignal?.aborted + ? clientCancelledResponse() + : comboUnavailable(comboId); + } + if (!(await recoverUnreadableEncryptedTask())) { + return options.abortSignal?.aborted + ? clientCancelledResponse() + : unreadableEncryptedAgentTaskResponse(recoveryFailureReason); + } } + if (!pick) { return options.abortSignal?.aborted ? clientCancelledResponse() @@ -2692,11 +2654,9 @@ export async function handleComboResponses( let lastFailure: Response | null = null; while (pick) { if (options.abortSignal?.aborted) return clientCancelledResponse(); - const selectedPick = pick; const childLog: RequestLogContext = { model: pick.target.model, provider: pick.target.provider, - ...(logCtx.agentKind ? { agentKind: logCtx.agentKind } : {}), ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}), ...(logCtx.surface ? { surface: logCtx.surface } : {}), }; @@ -2715,37 +2675,42 @@ export async function handleComboResponses( }); let resolvedAuth: CodexAuthContext | undefined; let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; - let started: number | undefined; - let attempt: ReturnType | undefined; - const beginAttempt = (): void => { - if (attempt) return; - started = Date.now(); - attempt = beginRequestAttempt( - (logCtx.attempts?.length ?? 0) + 1, - selectedPick.target.provider, - selectedPick.target.model, - config.providers[selectedPick.target.provider]!.adapter, - ); - childLog.activeAttempt = attempt; - childLog.activeAttemptStartedAt = started; - recordAttemptRequestedEffort(childLog); - }; + const started = Date.now(); + const attempt = beginRequestAttempt( + (logCtx.attempts?.length ?? 0) + 1, + pick.target.provider, + pick.target.model, + config.providers[pick.target.provider]!.adapter, + ); + childLog.activeAttempt = attempt; let attemptRetained = false; const retainCancelledAttempt = (): void => { - if (attemptRetained || !attempt) return; + if (attemptRetained) return; sealRequestAttemptIdentity( attempt, childLog.provider, childLog.providerAdapter ?? attempt.adapter, childLog.accountLogLabel, ); - finishRequestAttempt(attempt, 499, Date.now() - (started ?? Date.now()), childLog.usage); + finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage); (logCtx.attempts ??= []).push(attempt); attemptRetained = true; }; let consumedChildFailure: ConsumedComboFailure | undefined; - let storedPool401ReplayDispatched = false; - const callbackGate = createChildPassthroughCallbackGate(options); + const callbackGate = createChildPassthroughCallbackGate({ + ...options, + onNativePassthroughTerminal: status => { + // A committed stream can acquire terminal metadata after preflight copied + // the child log. Publish it before the outer logger finalizes, but only + // through the gate: discarded attempts must never affect the parent. + // Undefined child fields must preserve metadata already inspected by WS. + if (childLog.terminalHttpStatus !== undefined) logCtx.terminalHttpStatus = childLog.terminalHttpStatus; + if (childLog.terminalIncompleteReason !== undefined) logCtx.terminalIncompleteReason = childLog.terminalIncompleteReason; + if (childLog.terminalErrorCode !== undefined) logCtx.terminalErrorCode = childLog.terminalErrorCode; + if (childLog.upstreamError !== undefined) logCtx.upstreamError = childLog.upstreamError; + options.onNativePassthroughTerminal?.(status); + }, + }); let response: Response; try { const currentTargetProvider = pick.target.provider; @@ -2760,11 +2725,10 @@ export async function handleComboResponses( comboAttempt: true, comboReplaySnapshot, deferCodexResetDerivedCooldown, - onRequestValidated: beginAttempt, // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later // Object.assign(logCtx, childLog) would overwrite the request-relative value). onFirstOutput: () => { - if (attempt && started !== undefined && attempt.firstOutputMs === undefined) { + if (attempt.firstOutputMs === undefined) { attempt.firstOutputMs = Math.max(0, Date.now() - started); } options.onFirstOutput?.(); @@ -2817,7 +2781,6 @@ export async function handleComboResponses( } if (response.ok) { - if (!attempt) return response; sealRequestAttemptIdentity( attempt, childLog.provider, @@ -2864,30 +2827,51 @@ export async function handleComboResponses( retainCancelledAttempt(); return clientCancelledResponse(); } - if (attempt) { - sealRequestAttemptIdentity( - attempt, - childLog.provider, - childLog.providerAdapter ?? attempt.adapter, - childLog.accountLogLabel, - ); - finishRequestAttempt( - attempt, - failure.response.status, - Date.now() - (started ?? Date.now()), - failure.usage, - ); - (logCtx.attempts ??= []).push(attempt); - attemptRetained = true; - } + sealRequestAttemptIdentity( + attempt, + childLog.provider, + childLog.providerAdapter ?? attempt.adapter, + childLog.accountLogLabel, + ); + finishRequestAttempt( + attempt, + failure.response.status, + Date.now() - started, + failure.usage, + ); + (logCtx.attempts ??= []).push(attempt); + attemptRetained = true; lastFailure = failure.response; + const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + }); if (storedPool401ReplayDispatched) { + if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) { + const recoveredTarget = await pickWithWait({ + exclude: pick.attempted, + eligible: target => { + try { + const route = routeConcreteModel(config, `${target.provider}/${target.model}`); + return route.codexAccountMode === undefined + && !isCanonicalOpenAiForwardProvider(route.provider); + } catch { + return false; + } + }, + }); + if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (recoveredTarget && await recoverUnreadableEncryptedTask()) { + pick = recoveredTarget; + continue; + } + if (options.abortSignal?.aborted) return clientCancelledResponse(); + } + // Keep the spent Pool budget sticky even after a recovered routed child: + // no later failure may reopen ordinary combo/native account hopping. adoptFailedChildLog(childLog); return lastFailure; } - if (comboFailureDecision(failure.response.status, failure.classificationText, { - code: failure.upstreamCode, - }) === "stop") { + if (failureDecision === "stop") { adoptFailedChildLog(childLog); if ( failure.response.status === 413 @@ -2898,9 +2882,10 @@ export async function handleComboResponses( return lastFailure; } console.warn( - `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${started === undefined ? 0 : Date.now() - started}ms`, + `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`, ); const failureNow = Date.now(); + const attemptedTargets = pick.attempted; const nextPick = advanceComboAfterFailure(config, pick, { retryAfter: failure.retryAfter, resetAt: failure.resetAt, @@ -2924,6 +2909,18 @@ export async function handleComboResponses( }); } if (!pick) { + if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (unreadableEncryptedAgentTask && !comboPayloadReadable) { + const recoveredTarget = await pickWithWait({ + exclude: attemptedTargets, + now: failureNow, + }); + if (recoveredTarget && await recoverUnreadableEncryptedTask()) { + pick = recoveredTarget; + continue; + } + } + // Waiting or recovery may have observed cancellation after the check above. if (options.abortSignal?.aborted) return clientCancelledResponse(); adoptFailedChildLog(childLog); } @@ -3053,10 +3050,6 @@ export async function handleResponses( }); return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response; } catch (error) { - if (error instanceof OcxRequestValidationError) { - const response = formatErrorResponse(error.status, "invalid_request_error", redactSecretString(error.message)); - return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response; - } if (ownsBudget) translatorBudget.dispose(); throw error; } @@ -3128,11 +3121,10 @@ async function handleResponsesInner( onRequestBodyRead: undefined, }); } - await prepareResponseStateReplay(body); let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( (body as { input?: unknown } | undefined)?.input, ); - const inboundClientThreadId = inboundClientThreadIdFromRequest(req.headers); + const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; const cursorClientThreadId = codexPoolAffinityKey(req.headers); const originalBody = body; if (options.comboReplaySnapshot) { @@ -3173,6 +3165,7 @@ async function handleResponsesInner( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // Captured before any parser mutates it, so both grammars see the client's id. const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config); if (fastRow) { @@ -3198,8 +3191,6 @@ async function handleResponsesInner( if (options.comboReplaySnapshot?.recoveredPlaintext) { markBodyNonPersistable(parsed._rawBody); } - parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort === true; - if (options.claudeSourceEnvelope) parsed._claudeSourceEnvelope = options.claudeSourceEnvelope; toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true; const providerContinuationCandidate = options.comboReplaySnapshot @@ -3240,16 +3231,9 @@ async function handleResponsesInner( return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err)); } options.onRequestBodyRead?.(); - let v2RoutedDelegationBridge: V2RoutedDelegationBridgeContext | undefined; - let v2BridgeStateDurability: ResponseStateDurability | undefined; - const responseStateOptions = (force = false): { - force?: boolean; - clientThreadId?: string; - durability?: ResponseStateDurability; - } => ({ + const responseStateOptions = (force = false): { force?: boolean; clientThreadId?: string } => ({ ...(force ? { force: true } : {}), ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}), - ...(v2BridgeStateDurability ? { durability: v2BridgeStateDurability } : {}), }); const resolvedConversationId = conversationIdFromResponsesRequest({ clientThreadId: parsed._clientThreadId, @@ -3289,7 +3273,6 @@ async function handleResponsesInner( logCtx.configuredServiceTier = readConfiguredCodexServiceTier(); logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier); - let shadowIntercepted = false; let route: RouteResult; try { // A `compaction_trigger` turn may name a bare native model the operator has @@ -3312,15 +3295,20 @@ async function handleResponsesInner( } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ } const targetRoute = resolveRoute(_sci.model); if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) { - shadowIntercepted = true; - const originalModel = parsed.modelId; + const _sciOriginal = parsed.modelId; parsed.modelId = _sci.model; if (parsed._rawBody && typeof parsed._rawBody === "object") { (parsed._rawBody as { model?: string }).model = _sci.model; } + // Record the operator-configured prefix that matched, NOT the caller's raw model string. + // Matching is by prefix, so a caller can append arbitrary text and still intercept; that + // raw value would then land in usage.jsonl and /api/logs behind a pattern-based redactor + // that does not recognize every credential family. The prefix is a value the operator + // configured, so no caller-controlled string is persisted. logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString( - shadowSourceModelPrefix(originalModel, _sci.sourceModels), + shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels), ); + // Helpers must not resume/append into the parent thread's Cursor conversation. parsed._cursorIsolateConversation = true; shadowRoute = targetRoute; } @@ -3340,34 +3328,12 @@ async function handleResponsesInner( return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err)); } - const parentOverride = decideV2NativeParentOverride({ - kind: "responses", - config, - headers: req.headers, - parsed, - sourceRoute: route, - comboAttempt: options.comboAttempt, - targetEvidence: evidenceFromBody(parsed._rawBody), - }); - if (parentOverride.kind === "reject") { - if (parentOverride.trace) logCtx.routeDecision = parentOverride.trace as typeof logCtx.routeDecision; - return formatErrorResponse(404, "invalid_request_error", parentOverride.message); - } - if (parentOverride.kind === "override") { - route = parentOverride.route; - parsed.modelId = route.modelId; - if (parsed._rawBody && typeof parsed._rawBody === "object") { - (parsed._rawBody as { model?: string }).model = route.modelId; - } - } - const hasUnexpandedPreviousResponse = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; // Exact account selectors are isolated from Pool-wide quota work. A canonical replay miss must // also fail closed without polling quota upstream. Cached fallback state can still select a // provider with native continuation support below. - const agentKind = logCtx.agentKind ?? classifyAgentKind(req.headers, "responses"); - const threadSpawn = isThreadSpawnRequest(req.headers, agentKind); + const threadSpawn = isThreadSpawnRequest(req.headers); const initialSubagentFallbackChain = threadSpawn && !options.comboAttempt ? resolveSubagentFallbackChain(parsed, config) : null; @@ -3468,9 +3434,6 @@ async function handleResponsesInner( if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; } - if (err instanceof OcxRequestValidationError) { - return formatErrorResponse(err.status, "invalid_request_error", redactSecretString(err.message)); - } return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err)); } } @@ -3479,29 +3442,37 @@ async function handleResponsesInner( previewSelectionAdmission?.release(); } + let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined; // Native fallback and explicitly trusted direct Responses routes can consume ciphertext, // so recover only after final route selection. if ( inboundWire === "responses" && threadSpawn - && unreadableEncryptedAgentTask && agentTaskRecovery && !isCanonicalOpenAiForwardProvider(route.provider) && !options.comboAttempt && !canPassThroughEncryptedV2AgentTask(route, inboundWire) ) { - let recovered = false; - try { - recovered = await recoverEncryptedAgentTask( + let recovered = restoreCachedEncryptedAgentTasks( + req, (body as { input?: unknown } | undefined)?.input, config, { parentThreadId }, + ) > 0; + unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( + (body as { input?: unknown } | undefined)?.input, + ); + if (unreadableEncryptedAgentTask) try { + const result = await recoverEncryptedAgentTaskWithResult( req, (body as { input?: unknown } | undefined)?.input, agentTaskRecovery, config, { parentThreadId, abortSignal: options.abortSignal }, ); + recovered = result.recovered; + recoveryFailureReason = result.recovered ? undefined : result.reason; } catch { recovered = false; + recoveryFailureReason = undefined; } if (recovered) { unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( @@ -3527,6 +3498,7 @@ async function handleResponsesInner( (reparsed as unknown as Record)[key] = parsed[key]; } } + bindTurnTerminationScope(reparsed, resolvedConversationId); parsed = reparsed; // The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task // text. Bar it from the continuation cache before any recording path can reach it — @@ -3598,9 +3570,6 @@ async function handleResponsesInner( if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; } - if (err instanceof OcxRequestValidationError) { - return formatErrorResponse(err.status, "invalid_request_error", redactSecretString(err.message)); - } return formatErrorResponse( 404, "invalid_request_error", @@ -3608,8 +3577,7 @@ async function handleResponsesInner( ); } } - } catch (err) { - if (err instanceof OcxRequestValidationError) throw err; + } catch { unreadableEncryptedAgentTask = true; } } @@ -3628,7 +3596,7 @@ async function handleResponsesInner( && !finalRouteCanPassThroughEncryptedTask && unreadableEncryptedAgentTask ) { - return unreadableEncryptedAgentTaskResponse(); + return unreadableEncryptedAgentTaskResponse(recoveryFailureReason); } // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no @@ -3645,54 +3613,9 @@ async function handleResponsesInner( ); } - // Child fallback and encrypted-task recovery can change both the parsed catalog and the - // destination. Arm the plaintext mirror only after that selection is final: eligible native - // children may then delegate to routed grandchildren without creating ChatGPT-only ciphertext, - // while routed fallbacks and non-spawn maintenance turns never see the private namespace. - const bridgeDecision = decideV2RoutedDelegationBridge({ - enabled: config.v2RoutedDelegationBridge === true, - inboundWire, - multiAgentMode: config.multiAgentMode, - upstreamV2Enabled: isMultiAgentV2Enabled(), - canonicalNativeRoute: isCanonicalOpenAiForwardProvider(route.provider), - // `classifyAgentKind` incorporates both x-openai-subagent and the JSON - // x-codex-turn-metadata marker. Undefined is malformed/conflicting and fails closed. - hasSubagentMarker: agentKind !== "main", - threadSpawn, - comboAttempt: options.comboAttempt === true, - compaction: parsed._compactionRequest === true, - shadowRoute: shadowIntercepted, - collaborationSurface: collabSurface(parsed), - body: parsed._rawBody, - replayPrefixLength: previousResponseReplayPrefixLength(parsed._rawBody), - }); - if (config.v2RoutedDelegationBridge === true) { - logCtx.v2BridgeDecision = bridgeDecision.decision; - if (bridgeDecision.active) logCtx.v2BridgeScope = bridgeDecision.scope; - } - if (bridgeDecision.active) { - try { - v2RoutedDelegationBridge = injectV2RoutedDelegationBridge(parsed); - if (v2RoutedDelegationBridge) { - copyPreviousResponseReplayProvenance( - parsed._rawBody, - v2RoutedDelegationBridge.requestStateBody, - ); - v2BridgeStateDurability = await prepareSensitiveResponsePersistence( - v2RoutedDelegationBridge.requestStateBody, - ); - logCtx.v2BridgeStateDurability = v2BridgeStateDurability; - toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); - } - } catch (error) { - return formatErrorResponse(400, "invalid_request_error", error instanceof Error ? error.message : String(error)); - } - } - - // Captured before normalization: whether the CLIENT asked for SSE. Transport - // policy may force bounded JSON upstream for reliability (#875), or force - // canonical ChatGPT upstream to SSE; the answer is reframed to the client's - // requested transport after the final route has been normalized. + // Captured before normalization: whether the CLIENT asked for SSE. The + // transport-neutral upstream-streaming policy below may force a bounded JSON + // upstream for reliability (#875); the answer must then be reframed to SSE // for streaming clients. const clientRequestedStream = parsed.stream; await applyFinalRouteRequestNormalization({ @@ -3812,8 +3735,84 @@ async function handleResponsesInner( // the request actually used, so a concurrent rotation cannot cool an innocent replacement. let genericFailoverAccountId: string | null = null; let genericFailovers = 0; - /** - * Config generation captured where the serving credential is RESOLVED, not where the + let oauthSelection = route.provider.authMode === "oauth" + ? captureOAuthAccountSelection(route.providerName) : null; + let servingOAuthSnapshot: OAuthAccessSnapshot | undefined; + // These owners also serve early passthrough and sidecar sends. A dispatch-time + // rebuild must update every later builder, without entering a later block's TDZ. + let adapter: ProviderAdapter; + let activeAdapter: ProviderAdapter; + let runTurnAdapter: ProviderAdapter; + let sameTargetRequest: AdapterRequest | undefined; + let sameTargetParsed: OcxParsedRequest | undefined; + let sameTargetToken = 0; + let transportToken = 0; + let imageTierBias = 0; + const invalidateSameTargetRequest = (): void => { transportToken += 1; }; + type DispatchBinding = + | { kind: "oauth"; selection: NonNullable; snapshot: OAuthAccessSnapshot } + | { kind: "api-key"; provider: OcxProviderConfig }; + const requestBindings = new WeakMap(); + const adapterBindings = new WeakMap(); + const rawRunTurns = new WeakMap>(); + const commitResolvedOAuthSelection = async ( + candidate: OAuthAccessSnapshot, + proactive = false, + anthropicReason?: AnthropicAccountSelectionReason, + ): Promise => { + const maxSelectionAttempts = 3; + for (let attempt = 0; attempt < maxSelectionAttempts; attempt++) { + if (!oauthSelection) return null; + const proactiveEnabled = route.providerName === "anthropic" + ? isAnthropicAccountPoolEnabled(config) + : (config.providers[route.providerName]?.oauthAccountFailover?.enabled + ?? config.oauthAccountFailover?.enabled) === true; + if (proactive && candidate.accountId !== oauthSelection.accountId && !proactiveEnabled) { + oauthSelection = captureOAuthAccountSelection(route.providerName); + if (!oauthSelection) return null; + candidate = route.providerName === "anthropic" + ? await getAnthropicPoolAccessSnapshot(oauthSelection.accountId) + : await getValidAccessSnapshotForAccount(route.providerName, oauthSelection.accountId, { requireUsableAccount: true }); + } + const committed = await commitOAuthAccountSelection(route.providerName, candidate.accountId, { + expectedSelection: oauthSelection, + expectedCredentialGeneration: candidate.generation, + requireUsableAccount: true, + }); + if (committed) { + if (route.providerName === "anthropic" && !commitAnthropicSelectionRouting( + candidate.accountId, oauthSelection, committed, + { config, sessionKey: anthropicSessionKey, reason: anthropicReason, expectedCredentialGeneration: candidate.generation }, + )) return null; + oauthSelection = committed; + servingOAuthSnapshot = candidate; + forgetGenericFailoverRoster(route.providerName); + return candidate; + } + // A newer manual choice wins over this request's old proposal, including A→B→A. + // Resolve that choice, not the rejected candidate, before trying admission again. + oauthSelection = captureOAuthAccountSelection(route.providerName); + if (!oauthSelection) return null; + candidate = route.providerName === "anthropic" + ? await getAnthropicPoolAccessSnapshot(oauthSelection.accountId) + : await getValidAccessSnapshotForAccount(route.providerName, oauthSelection.accountId, { requireUsableAccount: true }); + if (route.provider.googleMode === "cloud-code-assist" && !candidate.projectId) return null; + } + return null; + }; + const refreshResolvedOAuthSelection = async (sent: OAuthAccessSnapshot): Promise => { + const current = captureOAuthAccountSelection(route.providerName); + const unchanged = current?.accountId === oauthSelection?.accountId + && current?.revision === oauthSelection?.revision; + const candidate = unchanged ? await forceRefreshOAuthAccessSnapshot(sent) : sent; + const admitted = await commitResolvedOAuthSelection(candidate); + if (!admitted) throw new Error("OAuth selection changed during credential recovery"); + genericFailoverAccountId = admitted.accountId; + stampOAuthAccountLabel(logCtx, route.providerName, route.provider, admitted.accountId); + return admitted; + }; + /** + * Config generation captured where the serving credential is RESOLVED, not where the * quota is written. A streaming turn is a long await, so a generation captured at write * time cannot see a config or account change that happened earlier in the same turn — * the case the fence exists for. Stays 0 for every provider without a passive quota. @@ -3840,11 +3839,14 @@ async function handleResponsesInner( * tolerates project discovery failing, so a stored account can legitimately have no project; * sending that account's bearer with the FAILED account's project is worse than not rotating. */ - const applyFailoverSnapshot = ( + const applyFailoverSnapshot = async ( snapshot: OAuthAccessSnapshot, retryParsed: OcxParsedRequest = parsed, - ): boolean => { + ): Promise => { if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return false; + const committed = await commitResolvedOAuthSelection(snapshot); + if (!committed) return false; + snapshot = committed; let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken }; if (route.providerName === "github-copilot") { rotatedProvider = resolveProviderTransport( @@ -3864,21 +3866,157 @@ async function handleResponsesInner( parsed._kiroAuthContext = kiroContext; if (retryParsed !== parsed) retryParsed._kiroAuthContext = { ...kiroContext }; } - if (isAntigravityOAuth) { - antigravityAccountId = snapshot.accountId; - sentOAuthSnapshot = snapshot; - replayOAuthCredentialSnapshot = { - accountId: snapshot.accountId, - generation: snapshot.generation, - }; - bindAntigravitySessionAffinity(antigravitySessionKey, snapshot.accountId ?? genericFailoverAccountId); - } // Re-stamp: a request that rotated accounts must be attributed to the account that actually // served it. All three rotation sites funnel through here, so this is the only re-stamp // needed -- and putting it anywhere else would let one of the three drift. stampOAuthAccountLabel(logCtx, route.providerName, route.provider, snapshot.accountId); + if (route.providerName === "anthropic") { + anthropicPoolAccountId = snapshot.accountId; + logCtx.provider = formatAnthropicProviderForLog("anthropic", snapshot.accountId, config); + } else { + genericFailoverAccountId = snapshot.accountId; + } + if (isAntigravityOAuth) { + antigravityAccountId = snapshot.accountId; + bindAntigravitySessionAffinity(antigravitySessionKey, snapshot.accountId); + } + sentOAuthSnapshot = snapshot; + replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation }; return true; }; + const selectionIsCurrent = (binding: DispatchBinding | undefined): boolean => { + if (route.provider.authMode === "forward") return true; + if (!binding) return false; + if (binding.kind === "api-key") return providerApiKeySelectionIsCurrent(config, route.providerName, binding.provider); + const selected = captureOAuthAccountSelection(route.providerName); + const row = getAccountCredentialWithStatus(route.providerName, binding.snapshot.accountId); + return selected?.accountId === binding.selection.accountId && selected?.revision === binding.selection.revision + && !!row && !row.needsReauth && row.credential.expires > Date.now() + && credentialGeneration(row.credential) === binding.snapshot.generation; + }; + const resolveSelectionAdapter = (provider: OcxProviderConfig, retention = config.cacheRetention): ProviderAdapter => { + const resolved = resolveAdapter(provider, retention); + if (route.provider.authMode === "forward") return resolved; + const binding: DispatchBinding | undefined = route.provider.authMode === "oauth" + ? oauthSelection && servingOAuthSnapshot + ? { kind: "oauth", selection: { ...oauthSelection }, snapshot: servingOAuthSnapshot } + : undefined + : { kind: "api-key", provider: { ...route.provider } }; + if (binding) adapterBindings.set(resolved, binding); + const build = resolved.buildRequest.bind(resolved); + resolved.buildRequest = async (requestParsed, incoming) => { + const request = await build(requestParsed, incoming); + // Capture at adapter creation, never from mutable serving state after an await. + if (binding) requestBindings.set(request, binding); + return request; + }; + if (resolved.runTurn) { + rawRunTurns.set(resolved, resolved.runTurn.bind(resolved)); + resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit); + } + return resolved; + }; + const refreshDispatchAdapter = async (requestParsed: OcxParsedRequest): Promise => { + if (route.provider.authMode === "oauth") { + if (!servingOAuthSnapshot || !await applyFailoverSnapshot(servingOAuthSnapshot, requestParsed)) { + throw new Error("OAuth account selection changed before dispatch"); + } + } else { + const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, route.provider); + if (!current) throw new Error("API key selection is unavailable before dispatch"); + route.provider = current; + } + adapter = activeAdapter = runTurnAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + ); + invalidateSameTargetRequest(); + return adapter; + }; + const refreshRunTurnAdapter = async (requestParsed: OcxParsedRequest): Promise => { + requestParsed._cursorIdentityScope = undefined; + requestParsed._cursorConversationId = undefined; + if (requestParsed._providerContinuation?.cursor) { + const { cursor: _oldCursor, ...rest } = requestParsed._providerContinuation; + requestParsed._providerContinuation = rest; + } + return refreshDispatchAdapter(requestParsed); + }; + const runSelectedTurn = async ( + selectedAdapter: ProviderAdapter, + ...[requestParsed, incoming, emit]: Parameters> + ): Promise => { + for (let attempt = 0; attempt < 3; attempt++) { + if (!selectionIsCurrent(adapterBindings.get(selectedAdapter))) selectedAdapter = await refreshRunTurnAdapter(requestParsed); + const binding = adapterBindings.get(selectedAdapter); + const run = rawRunTurns.get(selectedAdapter); + if (!run) throw new Error("Selected provider no longer supports this turn transport"); + let sent = false; + let refused = false; + // Both main and image-loop callers already acquired the initial pacing slot. + // Subsequent physical messages retain this adapter/credential and are paced normally. + const fetch = providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, modelId: route.modelId, pacingSlotAcquired: true, + beforeDispatch: () => { + if (sent) return; + if (!selectionIsCurrent(binding)) { + refused = true; + throw new Error("Account selection changed before the first turn dispatch"); + } + sent = true; + }, + }); + try { + await run(requestParsed, { ...incoming, providerFetch: fetch }, event => { if (!refused) emit(event); }); + } catch (error) { + if (!refused) throw error; + } + if (!refused) return; + // The adapter may map the guard's exception to an error event. Neither that + // event nor a refused send may escape before retrying the newly selected account. + selectedAdapter = await refreshRunTurnAdapter(requestParsed); + } + throw new Error("Account selection changed repeatedly before turn dispatch"); + }; + const oauthDispatch = (wireRequest: AdapterRequest, requestParsed = parsed): ProviderFetchOptions["dispatchOverride"] => { + if (route.provider.authMode === "forward") return undefined; + return async (input, init, execute) => { + let destination = input; + let dispatchInit = init; + for (let attempt = 0; attempt < 3; attempt++) { + if (selectionIsCurrent(requestBindings.get(wireRequest))) { + const fetchImpl = (route.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? execute; + return fetchImpl(destination, dispatchInit); + } + const nextAdapter = await refreshDispatchAdapter(requestParsed); + const rebuilt = await nextAdapter.buildRequest(requestParsed, { + headers: selectedForwardHeaders, translatorBudget, + ...(imageTierBias > 0 ? { imageTierBias } : {}), + }); + const bodySize = checkOutboundBodySize(rebuilt.body, config.maxUpstreamBodyBytes); + if (!bodySize.admitted) { + rebuilt.releaseBodyObservation?.(); + return formatErrorResponse(413, "outbound_body_too_large", describeOutboundBodyRefusal(bodySize)); + } + const headers = new Headers(dispatchInit.headers); + for (const name of Object.keys(wireRequest.headers)) headers.delete(name); + for (const [name, value] of Object.entries(rebuilt.headers)) headers.set(name, value); + wireRequest.releaseBodyObservation?.(); + Object.assign(wireRequest, rebuilt); + const binding = requestBindings.get(rebuilt); + if (binding) requestBindings.set(wireRequest, binding); + else requestBindings.delete(wireRequest); + sameTargetRequest = wireRequest; + sameTargetParsed = requestParsed; + sameTargetToken = transportToken; + destination = rebuilt.url; + dispatchInit = { ...dispatchInit, method: rebuilt.method, headers, body: rebuilt.body }; + bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider, + adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); + // The next iteration validates synchronously and calls fetch in that same turn. + } + throw new Error("OAuth account selection changed repeatedly before dispatch"); + }; + }; const oauthSessionKeyParts = { sessionIdHeader: sessionIdHeaderFromRequest(req.headers), threadIdHeader: req.headers.get("thread-id"), @@ -3914,15 +4052,11 @@ async function handleResponsesInner( } return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available"); } - const accessToken = await getAnthropicPoolAccessToken(selection.accountId); - anthropicPoolAccountId = selection.accountId; - bindAnthropicSessionAffinity(anthropicSessionKey, selection.accountId); - promoteAnthropicActiveAccount(selection.accountId); - route.provider = { ...route.provider, apiKey: accessToken }; - logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config); - if (selection.accountId) { - logCtx.accountLogLabel = selection.accountId; - } + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(selection.accountId), true, selection.reason); + if (!admitted) return formatErrorResponse(409, "conflict_error", "OAuth account selection changed; retry the request"); + anthropicPoolAccountId = admitted.accountId; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); } else if ( route.providerName === "cursor" && route.provider.authMode === "oauth" @@ -3944,6 +4078,9 @@ async function handleResponsesInner( const accessToken = await getValidAccessTokenForAccount("cursor", selection.accountId); resolved = { ...resolved, accountId: selection.accountId, accessToken }; } + const admitted = await commitResolvedOAuthSelection(resolved, true); + if (!admitted) return formatErrorResponse(409, "conflict_error", "OAuth account selection changed; retry the request"); + resolved = admitted; cursorPoolAccountId = selection.accountId; parsed._cursorIdentityScope = selection.accountId; bindCursorSessionAffinity(cursorSessionKey, selection.accountId); @@ -3951,12 +4088,26 @@ async function handleResponsesInner( accountId: resolved.accountId, generation: resolved.generation, }; + if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; route.provider = { ...route.provider, apiKey: resolved.accessToken }; logCtx.provider = formatCursorProviderForLog("cursor", selection.accountId); if (selection.accountId) { logCtx.accountLogLabel = selection.accountId; } } else { + // Prefer the account with known headroom BEFORE the first attempt. Rotation alone + // only reacts to a 429, so a turn could open on an account a previous probe already + // measured as spent. A null answer means "use the active account", so every provider + // without quota evidence keeps the resolution it has today. + const preferredAccountId = isGenericFailoverProvider(route.providerName, route.provider) + ? preferredInitialAccount(config, route.providerName) + : null; + // Resolved account-scoped, NOT through failoverAccountSnapshot: that helper marks a + // rotation site, and rotation sites must apply their credential through + // applyFailoverSnapshot's pairing rules. This is initial resolution — the code below + // already pairs the snapshot's Kiro metadata, Copilot origin and Antigravity project + // with this same bearer, exactly as it does for the active account. + let usedPreferredAccount = preferredAccountId !== null; let resolved: OAuthAccessSnapshot; if (isAntigravityOAuth) { const selection = resolveAntigravityAccountForSession(antigravitySessionKey); @@ -3974,42 +4125,45 @@ async function handleResponsesInner( return formatErrorResponse(401, "authentication_error", "Selected Antigravity OAuth account needs reauthentication"); } antigravityAccountId = selection.accountId; - resolved = await getValidAccessTokenSnapshotForAccount(route.providerName, selection.accountId); + resolved = await getValidAccessSnapshotForAccount(route.providerName, selection.accountId, { requireUsableAccount: true }); bindAntigravitySessionAffinity(antigravitySessionKey, selection.accountId); - } else { - // Prefer an account with known headroom before dispatch. The preference is advisory: - // stale, reauth-marked, or project-less candidates fall back to the active account. - const preferredAccountId = isGenericFailoverProvider(route.providerName, route.provider) - ? preferredInitialAccount(config, route.providerName) - : null; - let usedPreferredAccount = preferredAccountId !== null; - if (preferredAccountId) { - try { - resolved = await getValidAccessSnapshotForAccount( - route.providerName, - preferredAccountId, - { requireUsableAccount: true }, - ); - } catch { - forgetGenericFailoverRoster(route.providerName); - usedPreferredAccount = false; - resolved = await getValidAccessTokenSnapshot(route.providerName); - } - } else { - resolved = await getValidAccessTokenSnapshot(route.providerName); - } - if (usedPreferredAccount && route.provider.googleMode === "cloud-code-assist" && !resolved.projectId) { - resolved = await getValidAccessTokenSnapshot(route.providerName); + } else if (preferredAccountId) { + try { + // `requireUsableAccount` makes a removed OR reauth-flagged account throw from + // inside the resolver's own store read. Without it a revoked account resolves + // successfully — its credential is still readable — and the request would + // dispatch on an account already known to need a fresh login. + resolved = await getValidAccessSnapshotForAccount( + route.providerName, + preferredAccountId, + { requireUsableAccount: true }, + ); + } catch { + // The roster is read behind a short TTL, so a preferred account can be removed + // or flagged for reauth in the window after it was cached. Resolving it then + // throws, and a PREFERENCE that turns a healthy request into a 401 is worse + // than no preference at all — the active account is still perfectly usable. + // Drop the stale roster so the next request re-reads it, and carry on. + forgetGenericFailoverRoster(route.providerName); usedPreferredAccount = false; + resolved = await getValidAccessTokenSnapshot(route.providerName); } - if (route.provider.googleMode === "cloud-code-assist") { - if (usedPreferredAccount && resolved.projectId) { - route.provider = { ...route.provider, project: resolved.projectId }; - } else if (!route.provider.project && resolved.projectId) { - route.provider = { ...route.provider, project: resolved.projectId }; - } - } + } else { + resolved = await getValidAccessTokenSnapshot(route.providerName); + } + // A Cloud Code Assist account needs its own project. Antigravity's refresh path + // tolerates project discovery failing, so a stored account can legitimately have + // none — and a PREFERENCE must never turn a working request into an error. Fall + // back to the ordinary active-account resolution instead, which is exactly what + // would have happened had the preference never existed. + if (usedPreferredAccount && route.provider.googleMode === "cloud-code-assist" && !resolved.projectId) { + resolved = await getValidAccessTokenSnapshot(route.providerName); + usedPreferredAccount = false; } + const admitted = await commitResolvedOAuthSelection(resolved, true); + if (!admitted) return formatErrorResponse(409, "conflict_error", "OAuth account selection changed; retry the request"); + if (admitted.accountId !== resolved.accountId) usedPreferredAccount = true; + resolved = admitted; replayOAuthCredentialSnapshot = { accountId: resolved.accountId, generation: resolved.generation, @@ -4042,11 +4196,15 @@ async function handleResponsesInner( // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) }; } - // Antigravity (cloud-code-assist) must use the project paired with this exact token - // snapshot. A configured project may belong to another account and is never a fallback. - if (isAntigravityOAuth) { - if (!resolved.projectId) { - return formatErrorResponse(400, "invalid_request_error", "Antigravity project unavailable — re-run `ocx login google-antigravity`"); + // Project identity belongs to the admitted account on EVERY request, including + // the request after a pool transition made that account the persisted active one. + if (route.provider.googleMode === "cloud-code-assist") { + if (isAntigravityOAuth) { + if (!resolved.projectId) { + return formatErrorResponse(400, "invalid_request_error", "Antigravity project unavailable — re-run `ocx login google-antigravity`"); + } + } else if (!resolved.projectId) { + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist account project is unavailable"))); } route.provider = { ...route.provider, project: resolved.projectId }; } @@ -4090,29 +4248,7 @@ async function handleResponsesInner( logCtx.provider = route.providerName; delete logCtx.accountLogLabel; } - const adapter = resolveAdapter(adapterProvider, config.cacheRetention); - maybeInvokeResolvedRoute(options, parsed, route, adapterProvider, adapter.name, selectedForwardHeaders); - const googleOptionsError = googleProviderOptionsRouteError(parsed, { - providerName: route.providerName, - provider: adapterProvider, - adapterName: adapter.name, - }); - if (googleOptionsError) { - return formatErrorResponse(400, "invalid_request_error", googleOptionsError); - } - const assertGoogleOptionsRoute = ( - candidate: Pick, - provider: OcxProviderConfig, - requestParsed: OcxParsedRequest = parsed, - ): void => { - const message = googleProviderOptionsRouteError(requestParsed, { - providerName: route.providerName, - provider, - adapterName: candidate.name, - }); - if (message) throw new OcxRequestValidationError(message); - if (requestParsed.options.providerOptions?.google) candidate.validateRequest?.(requestParsed); - }; + adapter = resolveSelectionAdapter(adapterProvider, config.cacheRetention); bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, @@ -4126,42 +4262,6 @@ async function handleResponsesInner( logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); } logCtx.providerAdapter = adapter.name; - try { - assertGoogleOptionsRoute(adapter, adapterProvider); - adapter.validateRequest?.(parsed); - } catch (err) { - return formatErrorResponse( - 400, - "invalid_request_error", - redactSecretString(err instanceof Error ? err.message : String(err)), - ); - } - options.onRequestValidated?.(); - if (adapter.name === "cursor" && logCtx.conversationId && !options.comboAttempt) { - const turn = beginConversationTurn(logCtx.conversationId, route.providerName, route.modelId); - logCtx.turnProgressTrackerKey = turn.key; - logCtx.turnProgress = turn.telemetry; - if (turn.telemetry.repetitionCircuitOpen === true) { - logCtx.localTerminalReason = "cursor-repetition-circuit"; - logCtx.errorCode = "cursor_repetition_circuit_open"; - return formatErrorResponse( - 502, - "upstream_error", - "Cursor repeated the same tool-bearing output across multiple turns; this request was stopped to break the loop", - ); - } - if (turn.retryAfterSeconds !== undefined) { - logCtx.turnProgressCircuitBlocked = true; - logCtx.localTerminalReason = "cursor-rate-limit-circuit"; - logCtx.errorCode = "cursor_rate_limit_circuit_open"; - return formatErrorResponse( - 429, - "rate_limit_error", - "Cursor request paused after consecutive rate limits; retry after the indicated cooldown", - { retryAfter: String(turn.retryAfterSeconds) }, - ); - } - } // Ordinary requests receive one durable attempt only after their final initial // adapter is resolved. Combo children own their attempt and retries keep it. if (!options.comboAttempt && !logCtx.activeAttempt) { @@ -4176,8 +4276,8 @@ async function handleResponsesInner( (logCtx.attempts ??= []).push(attempt); } sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel); - let runTurnAdapter = adapter; - let runTurnOAuth401ReplayAttempted = false; + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, adapterProvider, adapter.name); + runTurnAdapter = adapter; if (adapter.runTurn) { recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed)); } @@ -4195,23 +4295,6 @@ async function handleResponsesInner( ); if (passiveSubjectId) logCtx.activeAttempt.labRouteSubjectId = passiveSubjectId; } - const diagnosticRequestId = isDebugEnabled() ? randomUUID() : undefined; - const adapterDiagnosticState: BridgeDiagnosticSequence = { value: 0 }; - const diagnosticContext: BridgeDiagnosticContext | undefined = diagnosticRequestId - ? { requestId: diagnosticRequestId, adapterName: adapter.name, sequence: adapterDiagnosticState } - : undefined; - const noteDiagnosticAttempt = ( - attempt: RequestLogContext["activeAttempt"], - inputEstimate: number | undefined, - recovery?: AttemptRecoveryKind, - adapterName?: string, - ): void => { - noteAttemptSend(attempt, inputEstimate, recovery); - if (!diagnosticContext) return; - diagnosticContext.attempt = attempt?.ordinal; - diagnosticContext.recovery = recovery; - if (adapterName) diagnosticContext.adapterName = adapterName; - }; const isPassthrough = "passthrough" in adapter && !!adapter.passthrough; const rawInput = (parsed._rawBody as { input?: unknown }).input; @@ -4376,12 +4459,7 @@ async function handleResponsesInner( && (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true); const rememberPassthroughResponse = passthroughRecordEligible ? (response: { id?: unknown; output?: unknown; status?: unknown }) => - rememberResponseState( - v2RoutedDelegationBridge?.requestStateBody ?? parsed._rawBody, - response, - undefined, - responseStateOptions(true), - ) + rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true)) : undefined; if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) { console.warn( @@ -4408,13 +4486,17 @@ async function handleResponsesInner( ); // Hosted calls the PROVIDER runs itself. Gated on the destination actually being xAI, so a // declaration alone cannot buy the exemption on some other upstream that never serves it. - const providerExecutedCallTypes = isXaiResponsesDestination(route.provider) - ? collectProviderExecutedCallTypes(clientToolAuthorizationBody) - : new Set(); + // Provider-executed declarations are authorized from the actual outbound body, after the + // adapter has applied destination-specific injection and normalization. Client-executed tool + // authority remains bounded to the caller-owned catalog above. + const providerExecutedCallTypes = new Set(); let request: Awaited>; try { - assertGoogleOptionsRoute(adapter, adapterProvider); - request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); + request = await adapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + ...(observeAntigravityProviderError ? { onProviderError: observeAntigravityProviderError } : {}), + }); } catch (error) { releaseCodexAuthContextProbeLease(authCtx); // A tool catalog this proxy cannot lower onto one wire namespace is a client input error, and @@ -4428,17 +4510,22 @@ async function handleResponsesInner( } throw error; } - for (const name of request.convertedRoutedCustomToolNames ?? []) { - if ( - toolBridgeMaps.freeformToolNames.has(name) - || toolBridgeMaps.toolNsMap.get(name)?.freeform === true - ) routedCustomToolNames.add(name); - } - for (const name of request.routedCustomToolRepairNames ?? []) { - if ( - toolBridgeMaps.freeformToolNames.has(name) - || toolBridgeMaps.toolNsMap.get(name)?.freeform === true - ) routedCustomToolRepairNames.add(name); + const functionRepairSchemas = isCanonicalOpenAiForwardProvider(route.provider) + ? new Map() + : collectFunctionCallRepairSchemas(clientToolAuthorizationBody); + if (!isCanonicalOpenAiForwardProvider(route.provider)) { + for (const name of request.convertedRoutedCustomToolNames ?? []) { + if ( + toolBridgeMaps.freeformToolNames.has(name) + || toolBridgeMaps.toolNsMap.get(name)?.freeform === true + ) routedCustomToolNames.add(name); + } + for (const name of request.routedCustomToolRepairNames ?? []) { + if ( + toolBridgeMaps.freeformToolNames.has(name) + || toolBridgeMaps.toolNsMap.get(name)?.freeform === true + ) routedCustomToolRepairNames.add(name); + } } for (const name of request.convertedRoutedToolSearchNames ?? []) { // The adapter already keeps this set empty when tool_choice forbids the private search. @@ -4474,9 +4561,10 @@ async function handleResponsesInner( let outboundRequestBody: Record | undefined; const declaredWireToolNames = new Set(); const declaredNamelessClientCallTypes = new Set(); - // A bare tool_choice can select one namespaced tool unambiguously. Restore that exact, - // request-bounded identity before authorization checks; adding the bare name to the declared - // set would let a namespaced `exec` accidentally authorize code-mode helper calls too. + // `buildToolBridgeMaps` creates a bare alias only when the caller selected exactly one + // namespaced tool through a bare tool_choice. Restore that request-bounded identity before + // authorization checks instead of admitting the bare name into the declared set: for `exec`, + // the latter would also authorize the unrelated code-mode helper names. const authorizedBareNamespaceToolAliases: RoutedNamespaceToolAliases = new Map( [...toolBridgeMaps.toolNsMap].flatMap(([alias, identity]) => alias === identity.name @@ -4490,11 +4578,21 @@ async function handleResponsesInner( ); const restoreAuthorizedBareNamespaceToolCalls = (value: unknown): unknown => restoreRoutedNamespaceCalls(value, authorizedBareNamespaceToolAliases).value; + const normalizeFunctionCompletionJson = (text: string): string => { + const snapshot = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) + ? repairResponsesSnapshotJson(text, outboundRequestBody) + : text; + // Sparse gateways need completion status inferred before schema repair can + // distinguish completed arguments from in-progress placeholders. + return repairFunctionCallsInJson(backfillResponsesFieldsJson(snapshot), functionRepairSchemas); + }; let undeclaredToolGuardActive = false; const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => { outboundRequestBody = parseOutboundRequestBody(builtRequest.body); providerExecutedCallTypes.clear(); if (isXaiResponsesDestination(route.provider)) { + // Preserve the caller-declared authorization recognized by the original classifier, then + // add adapter-injected declarations from the actual current-turn outbound catalog. for (const callType of collectProviderExecutedCallTypes(clientToolAuthorizationBody)) { providerExecutedCallTypes.add(callType); } @@ -4532,6 +4630,12 @@ async function handleResponsesInner( // current-turn wire snapshot above may authorize a call. if (replayedInputPrefixLength === 0) { for (const name of toolBridgeMaps.declaredToolNames) { + // `buildToolBridgeMaps` also aliases a namespaced tool under its bare name when the + // caller's `tool_choice` selected it unambiguously, which the bridge needs to route the + // call back. For `exec` alone that alias would also switch on nested-helper + // normalization and re-authorize `exec_command`/`shell_command`/`apply_patch`, so it is + // admitted here only when the caller's own catalog declared a bare `exec`. Selecting an + // MCP `exec` is not a declaration of the code-mode shell tool. if ( name === CODE_MODE_EXEC_TOOL_NAME && !clientDeclaredWireToolNames.has(CODE_MODE_EXEC_TOOL_NAME) @@ -4597,12 +4701,15 @@ async function handleResponsesInner( const rememberPassthroughResponseChecked = rememberPassthroughResponse ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { if (inspectionSawUndeclaredTool) return; - const restoredResponse = restoreRoutedCustomCalls( - restoreAuthorizedBareNamespaceToolCalls(response), + const restored = restoreRoutedCustomCalls( + restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), routedCustomToolNames, routedCustomToolRepairNames, declaredWireToolNames, - ).value as { id?: unknown; output?: unknown; status?: unknown }; + ).value; + const restoredResponse = (functionRepairSchemas.size > 0 + ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) + : restored) as { id?: unknown; output?: unknown; status?: unknown }; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -4670,7 +4777,6 @@ async function handleResponsesInner( linkAbortSignal(upstream, options.abortSignal); const connectMs = config.connectTimeoutMs ?? 200_000; let upstreamResponse: Response; - const replayBudget = route.provider.replayTransientFailures ? { remaining: 2 } : undefined; /** * Refuse a built body that exceeds the operator's configured ceiling, before it is sent. * @@ -4772,13 +4878,14 @@ async function handleResponsesInner( // Body is a replayable string; nothing has streamed to the client yet. upstreamResponse = await fetchWithTransientRetry( recovery => { - noteDiagnosticAttempt(logCtx.activeAttempt, passthroughEstimate, recovery, route.provider.adapter); + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery); return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ method: request.method, headers: request.headers, body: request.body, }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), @@ -4793,7 +4900,7 @@ async function handleResponsesInner( return res; }); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); } catch (err) { return transportFailureResponse(err); @@ -4809,7 +4916,7 @@ async function handleResponsesInner( const rebuildAndRefetch = async ( recovery: AttemptRecoveryKind, ): Promise => { - const retryAdapter = resolveAdapter( + const retryAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -4817,12 +4924,6 @@ async function handleResponsesInner( upstream.abort(); return { failed: formatErrorResponse(502, "upstream_error", "Recovery changed the provider wire unexpectedly") }; } - try { - assertGoogleOptionsRoute(retryAdapter, route.provider); - } catch (err) { - upstream.abort(); - return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(err instanceof Error ? err.message : String(err))) }; - } try { request = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, @@ -4850,18 +4951,20 @@ async function handleResponsesInner( retryAdapter.name, logCtx.accountLogLabel, ); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name); const rebuiltBodyRefusal = refuseOversizedOutboundBody(request); if (rebuiltBodyRefusal) return { failed: rebuiltBodyRefusal }; try { return await fetchWithTransientRetry( innerRecovery => { - noteDiagnosticAttempt(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery, retryAdapter.name); + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery); return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ method: request.method, headers: request.headers, body: request.body, }, innerRecovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), @@ -4874,7 +4977,7 @@ async function handleResponsesInner( return response; }); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); } catch (err) { return { failed: transportFailureResponse(err) }; @@ -4901,6 +5004,9 @@ async function handleResponsesInner( const replay = poolReplay ?? await refreshNativeMainForwardAuth({ req, config, route, authCtx, substituteMainCredential, options }); if (!replay.ok) { + // Compact already records this; core historically returned without recording, + // so a dead grant stayed selectable and every request repeated the same doomed + // refresh. Fenced by the generation the 401 belongs to (#2887). if (poolAuthCtx && poolReplay && !poolReplay.ok && poolReplay.quarantine) { recordCodexUpstreamOutcome(config, poolAuthCtx.accountId, 401, { threadId: poolAuthCtx.affinityKey, @@ -4917,7 +5023,7 @@ async function handleResponsesInner( authCtx = replay.authCtx; route.provider = replay.provider; selectedForwardHeaders = replay.headers; - const replayAdapter = resolveAdapter( + const replayAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire), config.cacheRetention, ); @@ -4935,6 +5041,7 @@ async function handleResponsesInner( }); logCtx.providerAdapter = replayAdapter.name; sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, replayAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, replayAdapter.name); try { request = await replayAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, @@ -4945,17 +5052,26 @@ async function handleResponsesInner( recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); refreshUndeclaredToolGuard(request); + // The 401 replay rebuilds the body before sending, so it needs the same ceiling as + // every other build site; a replay is exactly when a grown payload reappears. const replayBodyRefusal = refuseOversizedOutboundBody(request); if (replayBodyRefusal) return replayBodyRefusal; - noteDiagnosticAttempt(logCtx.activeAttempt, passthroughEstimate, "oauth-401", replayAdapter.name); + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, "oauth-401"); upstreamResponse = await fetchWithHeaderTimeout( request.url, { method: request.method, headers: request.headers, body: request.body }, upstream.signal, connectMs, parsed.stream, + // The replay-dispatched signal is what bounds the rest of this logical request, so it + // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing + // admission BEFORE calling the executor, so signalling at the call site would spend the + // budget even when a rejected pacing wait means nothing reaches the network. Wrapping + // the executor moves the signal to the last moment before the send, where a throw from + // here on is a genuine transport attempt. storedPoolReplayDispatchNotifier( providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), @@ -4993,7 +5109,7 @@ async function handleResponsesInner( try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } let refreshed: OAuthAccessSnapshot; try { - refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot); + refreshed = await refreshResolvedOAuthSelection(sentOAuthSnapshot); } catch (err) { upstream.abort(); releaseCodexAuthContextProbeLease(authCtx); @@ -5001,11 +5117,19 @@ async function handleResponsesInner( } if (isAntigravityOAuth && refreshed.accountId !== antigravityAccountId) { upstream.abort(); + releaseCodexAuthContextProbeLease(authCtx); return formatErrorResponse(401, "authentication_error", "Antigravity OAuth account changed during refresh"); } - if (isAntigravityOAuth && !refreshed.projectId) { + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { upstream.abort(); - return formatErrorResponse(400, "invalid_request_error", "Antigravity project unavailable — re-run `ocx login google-antigravity`"); + releaseCodexAuthContextProbeLease(authCtx); + return formatErrorResponse( + 401, + "authentication_error", + isAntigravityOAuth + ? "Antigravity project unavailable — re-run `ocx login google-antigravity`" + : publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required")), + ); } sentOAuthSnapshot = refreshed; replayOAuthCredentialSnapshot = { @@ -5015,19 +5139,20 @@ async function handleResponsesInner( if (route.providerName === "kiro") { parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; } - if (isAntigravityOAuth) { - route.provider = { ...route.provider, project: refreshed.projectId }; - } const refreshedProvider = resolveProviderTransport( route.providerName, - { ...route.provider, apiKey: refreshed.accessToken }, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), + }, parsed.options.promptCacheKey, route.providerName === "github-copilot" ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) : undefined, ); route.provider = refreshedProvider; - const refreshedAdapter = resolveAdapter( + const refreshedAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), config.cacheRetention, ); @@ -5049,8 +5174,8 @@ async function handleResponsesInner( refreshedAdapter.name, logCtx.accountLogLabel, ); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, refreshedAdapter.name); try { - assertGoogleOptionsRoute(refreshedAdapter, refreshedProvider); request = await refreshedAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget, @@ -5071,13 +5196,14 @@ async function handleResponsesInner( try { upstreamResponse = await fetchWithTransientRetry( recovery => { - noteDiagnosticAttempt(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401", refreshedAdapter.name); + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401"); return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ method: request.method, headers: request.headers, body: request.body, }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), @@ -5090,7 +5216,7 @@ async function handleResponsesInner( return res; }); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); } catch (err) { return transportFailureResponse(err); @@ -5116,13 +5242,10 @@ async function handleResponsesInner( try { snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); } catch { /* Keep the original 429 body readable when the next credential is unavailable. */ } } - if (snapshot && applyFailoverSnapshot(snapshot)) { - genericFailoverAccountId = snapshot.accountId; + if (snapshot && await applyFailoverSnapshot(snapshot)) { genericFailovers += 1; - sentOAuthSnapshot = snapshot; - replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation }; route.provider = resolveProviderTransport( - route.providerName, route.provider, parsed.options.promptCacheKey, snapshot.apiBaseUrl, + route.providerName, route.provider, parsed.options.promptCacheKey, sentOAuthSnapshot?.apiBaseUrl, ); bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: route.provider, @@ -5173,13 +5296,14 @@ async function handleResponsesInner( recovery => { // The first send of every replay is itself a rate-limit retry; inner transient-5xx // recoveries keep their own label (recovery is provided for those). - noteDiagnosticAttempt(logCtx.activeAttempt, passthroughEstimate, recovery ?? "rate-limit-429", route.provider.adapter); + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "rate-limit-429"); return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ method: request.method, headers: request.headers, body: request.body, }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), @@ -5192,7 +5316,7 @@ async function handleResponsesInner( return res; }); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); } catch (err) { return transportFailureResponse(err); @@ -5240,55 +5364,14 @@ async function handleResponsesInner( poolRetryOutcome = upstreamResponse.status >= 500 ? 429 : upstreamResponse.status; } - // Wrapped quota in 5xx: mirror the transient-5xx immediate-retry budget on the - // exhausted account before pool rotation. The ordinary transient layer is opt-in - // (replayTransientFailures), so a plain quota-wrapped 502 would otherwise rotate - // after one send. The docs and tests require up to three sends on the same - // credential before the alternate attempt, and three sends + cooldown for sole - // accounts. - if (poolRetryOutcome === 429 && upstreamResponse.status >= 500) { - const maxSameAccountQuotaRetries = 2; - for (let wrappedRetry = 0; wrappedRetry < maxSameAccountQuotaRetries; wrappedRetry++) { - if (options.abortSignal?.aborted || upstream.signal.aborted) break; - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* ignore */ } - if (replayBudget) replayBudget.remaining = Math.max(0, replayBudget.remaining - 1); - try { - noteAttemptSend(logCtx.activeAttempt, passthroughEstimate); - noteDiagnosticAttempt(logCtx.activeAttempt, passthroughEstimate, "transient-5xx", route.provider.adapter); - upstreamResponse = await fetchWithHeaderTimeout( - request.url, - { - method: request.method, - headers: request.headers, - body: request.body, - }, - upstream.signal, - connectMs, - parsed.stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - providerName: route.providerName, - modelId: route.modelId, - onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), - beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) - ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, - }), - route.provider.authMode === "forward", - ); - settleObservedHostResponse(); - captureAffinityResponse(upstreamResponse); - } catch (err) { - upstream.abort(); - break; - } - const stillQuota = await shouldRetryCodexPoolAccountQuota(upstreamResponse, options.abortSignal); - if (!stillQuota) { - poolRetryOutcome = undefined; - break; - } - } - } - if (poolRetryOutcome !== undefined) { + // A stored Pool 401 spent this request's account budget on its own refresh and replay, so + // nothing afterwards may be paid for out of a DIFFERENT account. One flag carries that, + // rather than a status check here as well: a quota failure has no same-account move, so + // `sameAccountOnly` makes it terminal by refusing the alternate; the gated-model 400 + // ladder does have one — retrying the account the refreshed roster still grants — and + // keeps it. An earlier revision also broke here on a non-400 outcome, which no test could + // justify because this flag already produced the identical result. const storedReplaySpent = codex401ReplayKind === "stored"; const retry = await retryCodexPoolOnAlternateAccount({ req, @@ -5347,6 +5430,48 @@ async function handleResponsesInner( upstreamResponse = opaqueBlobRecovery.response; continue passthroughRecovery; } + + const recoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; + const streamedFunctionOutputCandidate = upstreamResponse.ok + && !!upstreamResponse.body + && (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && parsed.stream)) + && !opaqueBlobRecoveryGuard.attempted + && outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body); + if (streamedFunctionOutputCandidate) { + const preflightLog: RequestLogContext = { model: logCtx.model, provider: logCtx.provider }; + const preflight = await preflightComboStreamResponse(upstreamResponse, preflightLog, + payload => { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const type = (payload as { type?: unknown }).type; + return (type === "error" || type === "response.failed" || type === "response.incomplete") + && upstreamErrorMessageFromPayload(payload) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION; + }, { + allowMissingContentType: !recoveryContentType && parsed.stream, + replayReadErrors: true, + }); + if (options.abortSignal?.aborted) return transportFailureResponse(options.abortSignal.reason); + upstreamResponse = preflight.response; + if (preflight.kind === "failed") { + const streamedOpaqueRecovery = await attemptOpaqueBlobRecovery({ + response: upstreamResponse, + outboundBody: request.body, + adapterName: adapter.name, + parsed, + guard: opaqueBlobRecoveryGuard, + signal: upstream.signal, + }, rebuildAndRefetch); + if (streamedOpaqueRecovery.kind === "failed") return streamedOpaqueRecovery.response; + if (streamedOpaqueRecovery.kind === "recovered") { + resetStreamedOpaqueBlobLogContext(logCtx); + upstreamResponse = streamedOpaqueRecovery.response; + continue passthroughRecovery; + } + logCtx.upstreamError = preflightLog.upstreamError; + logCtx.terminalHttpStatus = preflightLog.terminalHttpStatus; + logCtx.terminalErrorCode = preflightLog.terminalErrorCode; + logCtx.terminalIncompleteReason = preflightLog.terminalIncompleteReason; + } + } break; } const headers = sanitizePassthroughHeaders(upstreamResponse.headers); @@ -5357,9 +5482,9 @@ async function handleResponsesInner( if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType; } // The chatgpt backend may omit Content-Type on SSE responses. Fall back to - // treating a successful body as SSE when upstream normalization requested it. + // treating a successful body as SSE when the caller requested streaming. const passthroughCt = headers.get("content-type")?.toLowerCase(); - let isEventStream = passthroughCt?.includes("text/event-stream") + const isEventStream = passthroughCt?.includes("text/event-stream") || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream); const recordTerminalOutcome = codexForwardTerminalOutcomeRecorder( config, @@ -5390,16 +5515,16 @@ async function handleResponsesInner( if (terminalBodyWillRecord) { options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { terminalRecorder(status, httpStatusOverride); - if (status === "failed") { - if (!isFixedCodexAccount(authCtx)) { - recordSubagentSpawnFailureForTerminal( + if (status === "failed" || status === "incomplete") { + const quotaFailureMessage = [httpStatusOverride, logCtx.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { + recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, - status, + quotaFailureMessage, config, subagentFallbackAccountId, - httpStatusOverride, - logCtx, ); } } @@ -5417,6 +5542,9 @@ async function handleResponsesInner( probeLeaseId: codexProbeLeaseId(authCtx), probeQuotaScope: codexProbeQuotaScope(authCtx), writerGeneration: authCtx.writerGeneration, + // Includes a replay's second 401, which is the case that actually retires the + // account — fence it on the credential the request was holding. + ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), }); } } @@ -5474,167 +5602,6 @@ async function handleResponsesInner( // (src/server/relay-eager.ts; policy: // devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md). // The bundled known-bad runtime remains on tee by default on both platforms. - if (isEventStream && upstreamResponse.body && clientRequestedStream !== true) { - commitReasoningReplayServingRoute(); - const bridgeSseRewrite = createV2RoutedDelegationSseRewrite(v2RoutedDelegationBridge); - const normalizedBody = bridgeSseRewrite - ? relaySseWithBlockRewrite( - upstreamResponse.body, - payloadRewriteAsBlockRewrite(bridgeSseRewrite), - translatorBudget, - ) - : upstreamResponse.body; - // Stop reading at the first protocol terminal even if the backend keeps - // the HTTP connection alive. This is the same terminal boundary used by - // the client-facing SSE path and prevents unary callers from hanging on - // a completed response. - const terminalBoundedBody = relaySseWithFailedTail( - normalizedBody, - upstream, - undefined, - { synthesizeMissingTerminal: false }, - ); - let completedResponse: { id?: unknown; output?: unknown; status?: unknown } | undefined; - let terminalResponse: { id?: unknown; output?: unknown; status?: unknown } | undefined; - let rawTerminalResponse: { id?: unknown; output?: unknown; status?: unknown } | undefined; - let terminalEventType: "response.completed" | "response.failed" | "response.incomplete" | undefined; - let observedTerminal: { - status: ResponsesTerminalStatus; - httpStatusOverride?: number; - } | undefined; - const reportNativeTerminal = recordTerminalOutcomes - ? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { - terminalRecorder?.(status, httpStatusOverride); - if (status === "failed") { - interceptRuntimeFailure( - new Error(logCtx.upstreamError ?? "upstream response stream failed"), - { provider: route.providerName, model: route.modelId, config: config.autonomousRemediation }, - ); - if (!isFixedCodexAccount(authCtx)) { - recordSubagentSpawnFailureForTerminal( - req.headers, - subagentQuotaFailureModel, - status, - config, - subagentFallbackAccountId, - httpStatusOverride, - logCtx, - ); - } - } - options.onNativePassthroughTerminal?.(status); - } - : undefined; - let aggregationFailureSettled = false; - const failUnarySseAggregation = (message: string): Response => { - if (!aggregationFailureSettled) { - aggregationFailureSettled = true; - logCtx.transportPhase = "mid_stream"; - logCtx.terminalSource = "synthetic"; - if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true; - reportNativeTerminal?.("failed", 502); - } - return formatErrorResponse(502, "upstream_error", message); - }; - const inspector = createSseInspector({ - onTerminal: (status, httpStatusOverride) => { - observedTerminal ??= { - status, - ...(httpStatusOverride !== undefined ? { httpStatusOverride } : {}), - }; - }, - logCtx, - onCompletedResponse: response => { completedResponse = response; }, - onParsedPayload: payload => { - noteInspectedPayload(payload); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return; - const event = payload as { type?: unknown; response?: unknown }; - if ( - event.type !== "response.completed" - && event.type !== "response.failed" - && event.type !== "response.incomplete" - ) return; - terminalEventType ??= event.type; - if (!event.response || typeof event.response !== "object" || Array.isArray(event.response)) return; - rawTerminalResponse ??= event.response as { id?: unknown; output?: unknown; status?: unknown }; - if (event.type !== "response.completed") terminalResponse = rawTerminalResponse; - }, - onFirstOutput: options.onFirstOutput, - pinCompletedResponseIdToFirstSeen: route.providerName === "github-copilot", - }); - let bounded; - try { - bounded = await readBoundedResponseBody( - new Response(terminalBoundedBody), - { ...UPSTREAM_JSON_BODY_READ_OPTIONS, signal: upstream.signal, fatalUtf8: true }, - ); - if (!bounded.oversized && !bounded.truncated) { - inspector.feed(new TextEncoder().encode(bounded.text)); - inspector.finish(); - } - } catch (error) { - inspector.dispose(); - if (options.abortSignal?.aborted || req.signal.aborted) { - releaseCodexAuthContextProbeLease(authCtx); - options.onNativePassthroughCancel?.(); - return clientCancelledResponse(); - } - if (isTranslatorBudgetExceededError(error)) { - return failUnarySseAggregation("upstream translation buffer exceeded the safe limit"); - } - return failUnarySseAggregation("upstream response stream could not be decoded"); - } finally { - inspector.dispose(); - } - if (bounded.oversized) { - return failUnarySseAggregation("upstream response stream exceeded the safe body limit"); - } - if (bounded.truncated) { - return failUnarySseAggregation("upstream response stream stalled before completing"); - } - if (inspectionSawUndeclaredTool) { - return failUnarySseAggregation("upstream emitted a tool call outside the request catalog"); - } - const collected = completedResponse ?? terminalResponse; - const expectedStatus = terminalEventType === "response.completed" - ? "completed" - : terminalEventType === "response.failed" - ? "failed" - : terminalEventType === "response.incomplete" - ? "incomplete" - : undefined; - if ( - !collected - || !rawTerminalResponse - || !observedTerminal - || !expectedStatus - || observedTerminal.status !== expectedStatus - || typeof rawTerminalResponse.id !== "string" - || rawTerminalResponse.id.length === 0 - || rawTerminalResponse.status !== expectedStatus - || !Array.isArray(rawTerminalResponse.output) - || typeof collected.id !== "string" - || collected.id.length === 0 - || collected.status !== expectedStatus - || !Array.isArray(collected.output) - ) { - return failUnarySseAggregation( - terminalEventType - ? "upstream response stream carried an invalid terminal response" - : "upstream response stream closed before a terminal response", - ); - } - reportNativeTerminal?.(observedTerminal.status, observedTerminal.httpStatusOverride); - headers.set("content-type", "application/json"); - headers.delete("cache-control"); - upstreamResponse = new Response(JSON.stringify(collected), { - status: upstreamResponse.status, - statusText: upstreamResponse.statusText, - headers, - }); - isEventStream = false; - } - if (isEventStream && upstreamResponse.body) { // For streamed passthrough, a successful terminal response means non-error upstream status // before relay starts. Waiting for SSE completion would retain request state across the whole @@ -5654,18 +5621,13 @@ async function handleResponsesInner( options.responsesTerminalRepairScheduler, ) : upstreamResponse.body; - // The bridge owns request-scoped item-id admission. Apply it before the - // stream is split so the client, inspector, and continuation cache see - // the same authorized event history. - const bridgeSseRewrite = createV2RoutedDelegationSseRewrite(v2RoutedDelegationBridge); - const normalizedPassthroughSseBody = bridgeSseRewrite - ? relaySseWithBlockRewrite( - passthroughSseBody, - payloadRewriteAsBlockRewrite(bridgeSseRewrite), - translatorBudget, - ) - : passthroughSseBody; const repairConfig = route.provider.responsesItemIdRepair; + // Grok Build renders deltas live but reconstructs its durable assistant + // turn from the completed response snapshot. Native Responses streams + // may instead carry the complete items in output_item.done, so the + // explicit Grok compatibility marker enables strict terminal-only repair. + // The provider's broader snapshot/lifecycle repair remains opt-in. + const grokClientSnapshotRepairEnabled = logCtx.surface === "grok"; const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair); const githubCopilotRepairEnabled = route.providerName === "github-copilot"; const responseModelRewrite = parsed._responseModelId !== undefined @@ -5714,10 +5676,16 @@ async function handleResponsesInner( githubCopilotRepairEnabled ? createGithubCopilotResponsesBlockRewrite(translatorBudget) : undefined, + grokClientSnapshotRepairEnabled + ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget) + : undefined, snapshotRepairEnabled ? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget) : undefined, createResponsesFieldBackfillBlockRewrite(), + functionRepairSchemas.size > 0 + ? createResponsesFunctionToolRepairBlockRewrite(functionRepairSchemas, translatorBudget) + : undefined, // Last: every rewrite above can still rename or reshape a call item, so the guard must // compare the names the client will actually receive against the declared catalog. undeclaredToolGuardActive @@ -5731,7 +5699,7 @@ async function handleResponsesInner( const clientBlockRewrite = blockRewrites.length > 0 ? composeSseBlockRewrites(...blockRewrites) : undefined; - const needsClientRewrite = bridgeSseRewrite !== undefined || clientBlockRewrite !== undefined; + const needsClientRewrite = clientBlockRewrite !== undefined; // #864: win32 rewrite traffic must never enter the tee()+JS-pull chain // (Bun#32111 JS-sink segfault — text frames pass, the terminal block is // lost). The eager single reader applies the same rewrites inline. @@ -5755,17 +5723,16 @@ async function handleResponsesInner( const reportNativeTerminal = recordTerminalOutcomes ? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { terminalRecorder?.(status, httpStatusOverride); - if (status === "failed") interceptRuntimeFailure(new Error(logCtx.upstreamError ?? "upstream response stream failed"), { provider: route.providerName, model: route.modelId, config: config.autonomousRemediation }); - if (status === "failed") { - if (!isFixedCodexAccount(authCtx)) { - recordSubagentSpawnFailureForTerminal( + if (status === "failed" || status === "incomplete") { + const quotaFailureMessage = [httpStatusOverride, logCtx.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { + recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, - status, + quotaFailureMessage, config, subagentFallbackAccountId, - httpStatusOverride, - logCtx, ); } } @@ -5780,7 +5747,7 @@ async function handleResponsesInner( onFirstOutput: options.onFirstOutput, pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, }); - const eagerBody = relaySseEagerBounded(normalizedPassthroughSseBody, turnAc, { + const eagerBody = relaySseEagerBounded(passthroughSseBody, turnAc, { inspectChunk: chunk => inspector.feed(chunk), finishInspection: () => inspector.finish(), disposeInspection: () => inspector.dispose(), @@ -5790,11 +5757,14 @@ async function handleResponsesInner( ...(clientBlockRewrite ? { rewriteBlocks: clientBlockRewrite } : {}), - onSynthetic: kind => { + onSynthetic: (kind, reason) => { if (!reportNativeTerminal) return; if (kind === "incomplete") { logCtx.terminalSource = "synthetic"; reportNativeTerminal("incomplete"); + } else if (reason === "upstream_error") { + logCtx.terminalSource = "synthetic"; + reportNativeTerminal("failed", logCtx.terminalHttpStatus ?? 502); } else { logCtx.transportPhase = "mid_stream"; logCtx.terminalSource = "synthetic"; @@ -5807,6 +5777,7 @@ async function handleResponsesInner( }, { clientGoneSignal: options.abortSignal, ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), + ...(logCtx.upstreamError === undefined ? {} : { upstreamError: logCtx.upstreamError }), }); // When selected, this relay closes response.completed even if upstream // keeps the connection alive. Marked Codex WS traffic, Windows @@ -5820,7 +5791,7 @@ async function handleResponsesInner( })), ); } - const [nativeBody, inspectBody] = normalizedPassthroughSseBody.tee(); + const [nativeBody, inspectBody] = passthroughSseBody.tee(); const turnAc = new AbortController(); const clientGone = new AbortController(); linkAbortSignal(upstream, turnAc.signal); @@ -5842,17 +5813,16 @@ async function handleResponsesInner( // client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel. const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { terminalRecorder?.(status, httpStatusOverride); - if (status === "failed") interceptRuntimeFailure(new Error(logCtx.upstreamError ?? "upstream response stream failed"), { provider: route.providerName, model: route.modelId, config: config.autonomousRemediation }); - if (status === "failed") { - if (!isFixedCodexAccount(authCtx)) { - recordSubagentSpawnFailureForTerminal( + if (status === "failed" || status === "incomplete") { + const quotaFailureMessage = [httpStatusOverride, logCtx.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { + recordSubagentQuotaFailureForThreadSpawn( req.headers, subagentQuotaFailureModel, - status, + quotaFailureMessage, config, subagentFallbackAccountId, - httpStatusOverride, - logCtx, ); } } @@ -5887,7 +5857,12 @@ async function handleResponsesInner( const rewrittenBody = clientBlockRewrite !== undefined ? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget) : nativeBody; - const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason)); + const clientBody = relaySseWithFailedTail( + rewrittenBody, + upstream, + reason => clientGone.abort(reason), + { upstreamError: logCtx.upstreamError }, + ); return markNativePassthroughSseResponse(new Response(clientBody, { status: upstreamResponse.status, headers, @@ -5921,12 +5896,8 @@ async function handleResponsesInner( restoredNamespace, authorizedBareNamespaceToolAliases, ); - const bridgeNormalized = rewriteV2RoutedDelegationCallsInJson( - restoredAuthorizedBareNamespace, - v2RoutedDelegationBridge, - ); const restored = restoreRoutedCustomCallsInJson( - bridgeNormalized, + restoredAuthorizedBareNamespace, routedCustomToolNames, routedCustomToolRepairNames, declaredWireToolNames, @@ -5935,12 +5906,10 @@ async function handleResponsesInner( restored, routedToolSearchNames, ); - const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) - ? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody) - : restoredToolSearch; + const repaired = normalizeFunctionCompletionJson(restoredToolSearch); const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId - ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId) - : backfillResponsesFieldsJson(repaired); + ? rewriteResponsesModelJson(repaired, parsed._responseModelId) + : repaired; // The bounded-JSON answer bypasses the SSE payload rewrite, so content- // channel reasoning needs the same normalization here for the plain // JSON answer and every reframed-SSE variant built from clientJson. @@ -5974,7 +5943,7 @@ async function handleResponsesInner( if (rememberPassthroughResponseChecked) { try { rememberPassthroughResponseChecked( - JSON.parse(clientJson) as { id?: unknown; output?: unknown; status?: unknown }, + JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown }, ); } catch { /* non-JSON despite content-type; recording is best-effort */ } } @@ -6110,26 +6079,14 @@ async function handleResponsesInner( // - non-runTurn: web-search wins over image when both eligible (documented priority) // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn // can proceed for web-search-only turns - const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; - const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; - const hasMediaPlan = !!(imgPlan || vidPlan); - // A media plan is not enough to suppress Gemini 2.x grounding: both bridges inject tools only - // on streaming turns. This is the potential-injection value used to resolve sidecar precedence. - const mediaMayInject = mediaBridgeWillRun(hasMediaPlan, false, !!adapter.runTurn, parsed.stream); const wsPlan = !routedCompaction ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, { - admission: options.admission, - codexAuthPolicy: options.codexAuthPolicy, - hasMediaBridge: mediaMayInject, + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, }) : undefined; - const webSearchWinsMedia = !!wsPlan && !adapter.runTurn; - const mediaWillRun = mediaBridgeWillRun(hasMediaPlan, !!wsPlan, !!adapter.runTurn, parsed.stream); - const ccaInTurnGrounding = !routedCompaction - ? resolveCcaInTurnGrounding(config, parsed, false, route.provider, route.modelId, mediaWillRun) - : undefined; - if (ccaInTurnGrounding) parsed._ccaInTurnGrounding = ccaInTurnGrounding; - const canRunWebSearch = webSearchWinsMedia && !ccaInTurnGrounding; + const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; + const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; + const canRunWebSearch = !!wsPlan && !adapter.runTurn; const rotateSidecarProviderOn429 = async (retryAfter: string | null): Promise => { const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { retryAfter, @@ -6157,9 +6114,8 @@ async function handleResponsesInner( if (!nextAccountId) return null; try { const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); - genericFailoverAccountId = nextAccountId; genericFailovers += 1; - if (!applyFailoverSnapshot(snapshot)) return null; + if (!await applyFailoverSnapshot(snapshot)) return null; } catch { return null; } @@ -6183,12 +6139,12 @@ async function handleResponsesInner( // carries none, and getAnthropicPoolAccessToken is what enforces its fail-closed // local-cli credential rule. Both existing Anthropic rotation sites apply the token the // same way. - const accessToken = await getAnthropicPoolAccessToken(nextAccountId); - anthropicPoolAccountId = nextAccountId; + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId)); + if (!admitted) throw new Error("OAuth selection changed during recovery"); + anthropicPoolAccountId = admitted.accountId; anthropicPoolFailovers += 1; - route.provider = { ...route.provider, apiKey: accessToken }; - promoteAnthropicActiveAccount(nextAccountId); - logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config); + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); } catch { return null; } @@ -6197,7 +6153,7 @@ async function handleResponsesInner( // credential. The 429 is terminal for this sidecar turn. return null; } - const rotatedAdapter = resolveAdapter( + const rotatedAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -6209,7 +6165,7 @@ async function handleResponsesInner( }); return rotatedAdapter; }; - if (hasMediaPlan && webSearchWinsMedia) { + if ((imgPlan || vidPlan) && canRunWebSearch) { // Web search takes priority when both are active — the media bridge cannot run // alongside runWithWebSearch. Surface a runtime signal so the user knows their // configured video/image bridge was skipped for this turn, rather than silently @@ -6217,7 +6173,7 @@ async function handleResponsesInner( if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn"); if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn"); } - if (mediaWillRun || (imgPlan && !parsed.stream && !webSearchWinsMedia)) { + if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) { // The image bridge detects a hosted image_generation tool and requires streaming. // The video bridge activates from config and injects a tool — it also needs streaming // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip @@ -6263,17 +6219,14 @@ async function handleResponsesInner( options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId }, ); - const benchmarkUsageGate = { done: false }; const imgResponse = await runWithImageBridge({ parsed, adapter, incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, - ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), ...(imgPlan ? { plan: imgPlan } : {}), ...(vidPlan ? { videoPlan: vidPlan } : {}), forwardHeaders: selectedForwardHeaders, onAttemptSend: (recovery?: AttemptRecoveryKind) => - noteDiagnosticAttempt(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery, adapter.name), - ...(diagnosticContext ? { diagnostic: diagnosticContext } : {}), + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery), abortSignal: options.abortSignal, maxRounds: imgPlan && vidPlan ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2)) @@ -6284,14 +6237,19 @@ async function handleResponsesInner( stallTimeoutSec: config.stallTimeoutSec, waitForRequestSlot: imageProviderFetch.waitForPacing, fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch, + fetchForRequest: (request, iterParsed) => { + const fetch = providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request, iterParsed), + providerName: route.providerName, modelId: route.modelId, + }); + return fetch.unpacedFetch ?? fetch; + }, onRequestBuilt: request => { recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); }, - validateAdapter: (requestParsed, candidate) => assertGoogleOptionsRoute(candidate, route.provider, requestParsed), ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}), onUsage: usage => { - observeBenchmarkUsage(options.claudeBenchmarkObserver, benchmarkUsageGate, adapter.name, parsed._responseModelId ?? parsed.modelId, usage); // Cursor may assign _cursorConversationId inside the image loop's first runTurn; // backfill so Logs can filter/total that opening request (parity with the normal // runTurn branch). @@ -6337,21 +6295,25 @@ async function handleResponsesInner( // through web-search instead of being swallowed. runTurn adapters never enter this branch. if (canRunWebSearch && wsPlan) { parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()]; + // Resolve the mutable route at send time: a 429 rotation replaces route.provider, so retaining + // one pre-rotation providerFetch would keep the old credential and transport pin. const routedProviderFetch = ((input: Parameters[0], init?: RequestInit) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { providerName: route.providerName, modelId: route.modelId, })(input, init)) as typeof globalThis.fetch; - const benchmarkUsageGate = { done: false }; const wsResponse = await runWithWebSearch({ parsed, adapter, + fetchForRequest: (request, iterParsed) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request, iterParsed), + providerName: route.providerName, modelId: route.modelId, + }), incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget, providerFetch: routedProviderFetch, }, - ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), backend: wsPlan.backend, forwardProvider: wsPlan.forwardSidecar?.provider, anthropicSidecar: wsPlan.anthropicSidecar, @@ -6371,12 +6333,9 @@ async function handleResponsesInner( recordAdapterReasoning(logCtx, request); recordAdapterTier(logCtx, request); }, - validateAdapter: (requestParsed, candidate) => assertGoogleOptionsRoute(candidate, route.provider, requestParsed), onAttemptSend: (recovery?: AttemptRecoveryKind) => - noteDiagnosticAttempt(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery, adapter.name), - ...(diagnosticContext ? { diagnostic: diagnosticContext } : {}), + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery), onUsage: usage => { - observeBenchmarkUsage(options.claudeBenchmarkObserver, benchmarkUsageGate, adapter.name, parsed._responseModelId ?? parsed.modelId, usage); logCtx.usageFromBridge = true; if (usage) { logCtx.usage = usage; @@ -6384,7 +6343,7 @@ async function handleResponsesInner( } }, recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome, - connectTimeoutMs: config.connectTimeoutMs ?? Math.max(200_000, wsPlan.routedModelStallTimeoutMs), + connectTimeoutMs: config.connectTimeoutMs ?? 200_000, routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs, stallTimeoutSec: wsPlan.stallTimeoutSec, streamRoutedModelOutput: wsPlan.streamRoutedModelOutput, @@ -6407,18 +6366,16 @@ async function handleResponsesInner( // Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output // text and no tool call is a failure the client cannot see — it silently records the turn as // done. The guard holds pre-content adapter events, suppresses the terminal of an empty - // turn, and surfaces a stated error. An explicit top-level opt-in retries the IDENTICAL - // request once; known-broken Composer 2.5 empty turns fail statedly without an automatic - // replay. OCX_EMPTY_COMPLETION_RETRY=0 is a disable-only replay override. Compaction turns - // and combo attempts keep their own + // turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is + // empty or fails. This is a top-level config opt-in; OCX_EMPTY_COMPLETION_RETRY=0 is a + // disable-only emergency override. Compaction turns and combo attempts keep their own // machinery (the combo preflight already handles empty streams). Native Chat-to-Chat // requests return from handleChatCompletions before entering Responses core, so they are // intentionally outside this guard and retain their existing one-send wire behavior. - const selectedEmptyCompletionPolicy = emptyCompletionPolicy(config, adapter.name, route.modelId); - const emptyCompletionGuardEnabled = selectedEmptyCompletionPolicy !== "observe" + const emptyCompletionGuardEnabled = + emptyCompletionRetryEnabled(config) && !options.comboAttempt && !routedCompaction; - const emptyCompletionMaxRetries = selectedEmptyCompletionPolicy === "retry" ? 1 : 0; if (adapter.runTurn) { const runTurnAbort = new AbortController(); @@ -6426,6 +6383,13 @@ async function handleResponsesInner( const queue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); + const refreshRunTurnSelection = async (): Promise => { + if (selectionIsCurrent(adapterBindings.get(runTurnAdapter))) return; + await refreshRunTurnAdapter(parsed); + bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: route.provider, + adapterName: runTurnAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, runTurnAdapter.name, logCtx.accountLogLabel); + }; // Initial admission must settle before the streaming Response commits HTTP 200. // Let the outer Responses facade preserve the local retryable-429 contract. try { @@ -6435,7 +6399,6 @@ async function handleResponsesInner( queue.close(); throw error; } - const replayBudget = route.provider.replayTransientFailures ? { remaining: 2 } : undefined; // One attempt of the runTurn transport, against an explicit queue. The // empty-completion guard re-invokes the IDENTICAL turn (same parsed request, // same forwarded headers, same abort signal) through a fresh queue, so the @@ -6447,14 +6410,11 @@ async function handleResponsesInner( pacingSlotAcquired = false, ): Promise => { try { - if (diagnosticContext) { - diagnosticContext.recovery = recovery; - diagnosticContext.attempt = logCtx.activeAttempt?.ordinal; - } if (!pacingSlotAcquired) { await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); } - noteDiagnosticAttempt(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery, adapter.name); + await refreshRunTurnSelection(); + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery); const runTurnProviderFetch = providerFetch( route.provider, options.codexWsRuntimeIdentity, @@ -6474,11 +6434,6 @@ async function handleResponsesInner( abortSignal: runTurnAbort.signal, translatorBudget, providerFetch: runTurnProviderFetch, - replayBudget, - replayTransientFailures: route.provider.replayTransientFailures, - onAdapterRetry: (adapterRecovery: AttemptRecoveryKind) => { - noteDiagnosticAttempt(logCtx.activeAttempt, logCtx.usageLogInputTokens, adapterRecovery, adapter.name); - }, }, targetQueue.push, ); @@ -6505,36 +6460,6 @@ async function handleResponsesInner( } }; const runTurn = async (): Promise => runTurnAttempt(queue, undefined, true); - const refreshRunTurnAdapterOnPreflight401 = async ( - error: Extract, - ): Promise => { - const status = error.status ?? adapterFailureFromMessage(error.message).httpStatus; - if (status !== 401 || route.providerName !== "cursor" || route.provider.authMode !== "oauth" - || !sentOAuthSnapshot || runTurnOAuth401ReplayAttempted) return false; - runTurnOAuth401ReplayAttempted = true; - try { - const refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot); - sentOAuthSnapshot = refreshed; - replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, generation: refreshed.generation }; - route.provider = { ...route.provider, apiKey: refreshed.accessToken }; - const provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); - const refreshedAdapter = resolveAdapter(provider, config.cacheRetention); - if (!refreshedAdapter.runTurn) return false; - runTurnAdapter = refreshedAdapter; - bindRouteReasoningReplayScope({ - parsed, - providerName: route.providerName, - provider, - adapterName: refreshedAdapter.name, - oauthCredentialSnapshot: replayOAuthCredentialSnapshot, - codexAuthContext: authCtx, - forwardHeaders: selectedForwardHeaders, - }); - return true; - } catch { - return false; - } - }; const rotateRunTurnAdapterOnPreflight429 = async ( error: Extract, ): Promise => { @@ -6554,9 +6479,8 @@ async function handleResponsesInner( if (!nextAccountId) return false; try { const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); - genericFailoverAccountId = nextAccountId; genericFailovers += 1; - if (!applyFailoverSnapshot(snapshot)) return false; + if (!await applyFailoverSnapshot(snapshot)) return false; // A Cursor conversation/checkpoint is credential-scoped. The failed attempt emitted no // client-visible bytes, so replay is safe, but carrying its account identity into the next // account would not be. Let the rotated adapter derive a fresh identity and conversation. @@ -6572,7 +6496,7 @@ async function handleResponsesInner( route.provider, inboundWire, ); - const rotatedAdapter = resolveAdapter(rotatedProvider, config.cacheRetention); + const rotatedAdapter = resolveSelectionAdapter(rotatedProvider, config.cacheRetention); if (!rotatedAdapter.runTurn) return false; runTurnAdapter = rotatedAdapter; bindRouteReasoningReplayScope({ @@ -6585,6 +6509,7 @@ async function handleResponsesInner( forwardHeaders: selectedForwardHeaders, }); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, rotatedAdapter.name); return true; } catch { return false; @@ -6596,14 +6521,13 @@ async function handleResponsesInner( let source = firstSource; while (true) { const preflight = await preflightAdapterEvents(source); - if (!preflight.error || preflight.replayUnsafe) return preflight.stream; - const refreshed = await refreshRunTurnAdapterOnPreflight401(preflight.error); - const rotated = refreshed ? false : await rotateRunTurnAdapterOnPreflight429(preflight.error); - if (!refreshed && !rotated) return preflight.stream; + if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) { + return preflight.stream; + } const retryQueue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); - void runTurnAttempt(retryQueue, refreshed ? "oauth-401" : "oauth-account-429"); + void runTurnAttempt(retryQueue, "oauth-account-429"); source = retryQueue.stream(); } }; @@ -6615,17 +6539,14 @@ async function handleResponsesInner( onBacklogExceeded: () => runTurnAbort.abort(), }); void runTurnAttempt(retryQueue, "empty-completion"); - return diagnoseAdapterEvents(retryQueue.stream(), adapter.name, diagnosticRequestId, logCtx, adapterDiagnosticState); + return retryQueue.stream(); }; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; if (parsed.stream) { void runTurn(); - let eventSource: AsyncIterable = diagnoseAdapterEvents( - queue.stream(), adapter.name, diagnosticRequestId, logCtx, adapterDiagnosticState, - ); - if ((genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) - || (route.providerName === "cursor" && route.provider.authMode === "oauth" && sentOAuthSnapshot)) { + let eventSource: AsyncIterable = queue.stream(); + if (route.provider.authMode === "oauth" || (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName))) { // Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be // replayed transparently; after any output reaches the bridge, a later error stays terminal. eventSource = await preflightRunTurnFailover(eventSource); @@ -6643,7 +6564,6 @@ async function handleResponsesInner( const guardedSource = emptyCompletionGuardEnabled ? guardEmptyCompletionEventStream({ firstEvents: eventSource, - maxRetries: emptyCompletionMaxRetries, // Identical-turn retry: same parsed request, same headers, same // signal — run the adapter transport again against a fresh queue. continuation: runTurnRetrySource, @@ -6653,14 +6573,10 @@ async function handleResponsesInner( // result (#2472). Retrying by default would re-send a turn that may already have had // billable side effects, so the honest default is observability, not recovery. : observeEmptyCompletion(eventSource, () => { - console.warn( - `[opencodex] ${route.providerName}/${route.modelId} completed with no output text ` - + "and no tool call. Set \"emptyCompletionRetry\": true to retry such turns once.", - ); + console.warn(emptyCompletionNotice(route.providerName, route.modelId)); }); - const benchmarkUsageGate = { done: false }; const sseStream = bridgeToResponsesSSE( - observeProgressEvents(guardedSource, logCtx), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { runTurnAbort.abort(); queue.close(); @@ -6678,11 +6594,9 @@ async function handleResponsesInner( // grok-build's strict decoder dies on the typed response.heartbeat frame; its // eventsource layer tolerates comment keep-alives. Codex needs the opposite. ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}), - ...(diagnosticContext ? { diagnostic: diagnosticContext } : {}), onUsage: usage => { // Raw adapter usage, pre wire-normalization: the bridged SSE now always carries // zero-default detail objects, so provenance must come from here (cache_detail_missing). - observeBenchmarkUsage(options.claudeBenchmarkObserver, benchmarkUsageGate, runTurnAdapter.name, parsed._responseModelId ?? parsed.modelId, usage); logCtx.usageFromBridge = true; if (usage) { logCtx.usage = usage; @@ -6715,8 +6629,7 @@ async function handleResponsesInner( await runTurn(); const firstAttemptEvents = await queue.collect(); let runTurnEvents: AdapterEvent[] = firstAttemptEvents; - if ((genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) - || (route.providerName === "cursor" && route.provider.authMode === "oauth" && sentOAuthSnapshot)) { + if (route.provider.authMode === "oauth" || (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName))) { runTurnEvents = []; for await (const event of await preflightRunTurnFailover( (async function* () { yield* firstAttemptEvents; })(), @@ -6727,13 +6640,11 @@ async function handleResponsesInner( events = []; for await (const event of guardEmptyCompletionEventStream({ firstEvents: (async function* () { yield* runTurnEvents; })(), - maxRetries: emptyCompletionMaxRetries, continuation: runTurnRetrySource, })) events.push(event); } else { events = runTurnEvents; } - events = await collectProgressEvents(events, logCtx); if (options.comboAttempt) { const firstMeaningful = events.find(event => event.type !== "heartbeat"); if (!firstMeaningful || firstMeaningful.type === "error") { @@ -6744,7 +6655,6 @@ async function handleResponsesInner( } } let providerState: OcxProviderContinuationState | undefined; - const benchmarkUsageGate = { done: false }; const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._reasoningReplayScope, @@ -6757,7 +6667,6 @@ async function handleResponsesInner( ...(routedCompaction ? { compaction: true } : {}), onProviderState: state => { providerState = state; }, onUsage: usage => { - observeBenchmarkUsage(options.claudeBenchmarkObserver, benchmarkUsageGate, runTurnAdapter.name, parsed._responseModelId ?? parsed.modelId, usage); logCtx.usageFromBridge = true; if (usage) { logCtx.usage = usage; @@ -6792,7 +6701,7 @@ async function handleResponsesInner( const stallTimeoutMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0 ? Math.floor(config.stallTimeoutSec * 1000) : 300_000; - let activeAdapter = adapter; + activeAdapter = adapter; // One immutable, body-safe outbound request per same-target sequence (URL, serialized body, // auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the @@ -6859,14 +6768,14 @@ async function handleResponsesInner( { headers: { "Content-Type": "application/json" } }, ); } - // One request-scoped owner covers the initial send, recovery refetches, and continuation. - // Provider opt-in retry counts must not reset merely because the request changed legs. + // One request-scoped transient-retry budget owner, declared here so BOTH the initial send + // and the later recovery refetches (429, key/account rotation, OAuth replay) share it. A + // per-leg budget would let a request that recovers several times multiply upstream load. let transientSendsUsed = 0; const noteTransientSends = (used: number): void => { transientSendsUsed += Math.max(0, used); }; const remainingTransientSendBudget = (budget: number): number => Math.max(1, budget - transientSendsUsed); try { - assertGoogleOptionsRoute(activeAdapter, route.provider); initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget, @@ -6888,11 +6797,6 @@ async function handleResponsesInner( cleanupUpstreamAbort(); upstream.abort(); if (options.abortSignal?.aborted) return clientCancelledResponse(); - if (isTranslatorBudgetExceededError(err)) { - return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { - code: "translation_buffer_limit", - }); - } const msg = err instanceof Error ? err.message : String(err); return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)); } @@ -6900,46 +6804,52 @@ async function handleResponsesInner( // Capture it in a const so the fetch callbacks read a narrowed, immutable value // (TypeScript drops narrowing for a `let` captured by a nested function). const builtInitialRequest = initialRequest; - let sameTargetRequest: AdapterRequest | undefined = builtInitialRequest; - let sameTargetParsed: OcxParsedRequest | undefined = parsed; - let sameTargetToken = 0; - let transportToken = 0; + sameTargetRequest = builtInitialRequest; + sameTargetParsed = parsed; + sameTargetToken = transportToken; /** * Invalidate the same-target request cache. Every credential/adapter/parsed mutation MUST * go through here: the cache keys on `parsed` REFERENCE identity, so an in-place mutation * is invisible to it and a missed bump would replay a request built with a stale key. */ - const invalidateSameTargetRequest = (): void => { transportToken += 1; }; + let upstreamResponse: Response; - const replayBudget = route.provider.replayTransientFailures ? { remaining: 2 } : undefined; try { if (activeAdapter.fetchResponse) { - noteDiagnosticAttempt(logCtx.activeAttempt, inputTokenEstimate, undefined, activeAdapter.name); + noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate); await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream, - executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtInitialRequest), providerName: route.providerName, modelId: route.modelId, - pacingSlotAcquired: true, - }), - replayBudget, - ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), + }), }); } else { - // One coordinator owns reset and transient-5xx replay for every generic adapter. + // #1851 scope guard: transient-5xx retry on this generic adapter path is opt-in for + // direct Google AI Studio only (Vertex/Antigravity use fetchResponse above). Other + // adapters keep reset-only retry so combo failover still hops on the first 5xx + // instead of burning ~1.2s of same-target retries per hop. + // #2643: an opted-in key-auth openai-chat provider also gets transient-5xx retry. The + // legacy direct-Google exception is preserved exactly; every other adapter still keeps + // reset-only semantics so combo failover hops on the first 5xx. const transientPolicy = transientRetryPolicyFor(route.provider); - upstreamResponse = await fetchWithTransientRetry( + const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; + upstreamResponse = await fetchWithRetryPolicy( recovery => { - noteDiagnosticAttempt(logCtx.activeAttempt, inputTokenEstimate, recovery, activeAdapter.name); + noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery); return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({ method: builtInitialRequest.method, headers: builtInitialRequest.headers, body: builtInitialRequest.body, }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtInitialRequest), providerName: route.providerName, modelId: route.modelId, })); @@ -6947,8 +6857,6 @@ async function handleResponsesInner( { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url), - replayTransientFailures: route.provider.replayTransientFailures, - replayBudget, ...(transientPolicy ? { attempts: transientPolicy.attempts, onSendsConsumed: noteTransientSends } : {}), @@ -6959,9 +6867,7 @@ async function handleResponsesInner( cleanupUpstreamAbort(); upstream.abort(); if (options.abortSignal?.aborted) return clientCancelledResponse(); - const msg = route.provider.googleMode === "ai-studio-web" && err instanceof UpstreamRedirectError - ? "Google AI Studio session expired — re-authentication required" - : describeUpstreamConnectFailure(err, connectMs); + const msg = describeUpstreamConnectFailure(err, connectMs); return formatErrorResponse(502, "upstream_error", msg); } finally { builtInitialRequest.releaseBodyObservation?.(); @@ -6975,7 +6881,6 @@ async function handleResponsesInner( let rateLimitRetries = 0; // Shared with the terminal-guard continuation below: an image-tier reduction that let the // main request clear a 413 must not be forgotten on the very next continuation build. - let imageTierBias = 0; if (!upstreamResponse.ok) { // Recovery loop: multi-key 429 failover + at most ONE opaque-state rebuild and ONE // anthropic 413 tightened retry @@ -6994,13 +6899,6 @@ async function handleResponsesInner( const rebuildAndRefetch = async ( recovery: AttemptRecoveryKind, ): Promise => { - try { - assertGoogleOptionsRoute(activeAdapter, route.provider); - } catch (err) { - cleanupUpstreamAbort(); - upstream.abort(); - return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(err instanceof Error ? err.message : String(err))) }; - } let retryRequest: AdapterRequest; if (sameTargetRequest !== undefined && sameTargetParsed === parsed && sameTargetToken === transportToken) { // Same target (key/adapter/parsed/tier unchanged): replay the exact cached request. @@ -7022,9 +6920,6 @@ async function handleResponsesInner( cleanupUpstreamAbort(); upstream.abort(); if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; - if (isTranslatorBudgetExceededError(err)) { - return { failed: formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { code: "translation_buffer_limit" }) }; - } const msg = err instanceof Error ? err.message : String(err); return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) }; } @@ -7039,7 +6934,8 @@ async function handleResponsesInner( if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate; logCtx.providerAdapter = activeAdapter.name; sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); - noteDiagnosticAttempt(logCtx.activeAttempt, retryEstimate, recovery, activeAdapter.name); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); + noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery); try { try { if (activeAdapter.fetchResponse) { @@ -7049,29 +6945,36 @@ async function handleResponsesInner( timeoutMs: connectMs, stream: parsed.stream, executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(retryRequest), providerName: route.providerName, modelId: route.modelId, - pacingSlotAcquired: true, }), ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), }); } + // #2643 review: this leg used to call fetchWithHeaderTimeout directly, so an + // opted-in provider's transient-5xx policy applied to the initial send and to + // native chat but was silently bypassed here — a 429 that recovered into a + // retryable 503 got no retry on the Responses path. Route it through the same + // selection, and pass what is LEFT of the request-scoped budget rather than a + // fresh one, so a recovery loop cannot multiply total upstream sends. const refetchTransientPolicy = transientRetryPolicyFor(route.provider); - return await fetchWithTransientRetry( - recoveryKind => fetchWithHeaderTimeout(retryRequest.url, applyUpstreamRecoveryInit({ - method: retryRequest.method, - headers: retryRequest.headers, - body: retryRequest.body, - }, recoveryKind), upstream.signal, connectMs, parsed.stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - providerName: route.providerName, - modelId: route.modelId, - })), + const refetchWithPolicy = (route.provider.adapter === "google" || refetchTransientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; + return await refetchWithPolicy( + recoveryKind => fetchWithHeaderTimeout(retryRequest.url, + applyUpstreamRecoveryInit({ + method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, + }, recoveryKind), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(retryRequest), + providerName: route.providerName, + modelId: route.modelId, + })), { abortSignal: upstream.signal, label: safeHostLabel(retryRequest.url), - replayTransientFailures: route.provider.replayTransientFailures, - replayBudget, ...(refetchTransientPolicy ? { attempts: remainingTransientSendBudget(refetchTransientPolicy.attempts), @@ -7105,7 +7008,7 @@ async function handleResponsesInner( try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } let refreshed: OAuthAccessSnapshot; try { - refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot); + refreshed = await refreshResolvedOAuthSelection(sentOAuthSnapshot); } catch (err) { cleanupUpstreamAbort(); return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); @@ -7114,9 +7017,15 @@ async function handleResponsesInner( cleanupUpstreamAbort(); return formatErrorResponse(401, "authentication_error", "Antigravity OAuth account changed during refresh"); } - if (isAntigravityOAuth && !refreshed.projectId) { + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { cleanupUpstreamAbort(); - return formatErrorResponse(400, "invalid_request_error", "Antigravity project unavailable — re-run `ocx login google-antigravity`"); + return formatErrorResponse( + 401, + "authentication_error", + isAntigravityOAuth + ? "Antigravity project unavailable — re-run `ocx login google-antigravity`" + : publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required")), + ); } sentOAuthSnapshot = refreshed; replayOAuthCredentialSnapshot = { @@ -7126,12 +7035,13 @@ async function handleResponsesInner( if (route.providerName === "kiro") { parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; } - if (isAntigravityOAuth) { - route.provider = { ...route.provider, project: refreshed.projectId }; - } const refreshedProvider = resolveProviderTransport( route.providerName, - { ...route.provider, apiKey: refreshed.accessToken }, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), + }, parsed.options.promptCacheKey, route.providerName === "github-copilot" ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) @@ -7139,7 +7049,7 @@ async function handleResponsesInner( ); route.provider = refreshedProvider; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), config.cacheRetention, ); @@ -7150,7 +7060,6 @@ async function handleResponsesInner( adapterName: activeAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot, }); - maybeInvokeResolvedRoute(options, parsed, route, refreshedProvider, activeAdapter.name, selectedForwardHeaders); const result = await rebuildAndRefetch("oauth-401"); if ("failed" in result) return result.failed; upstreamResponse = result; @@ -7173,7 +7082,7 @@ async function handleResponsesInner( try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } route.provider = rotated; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -7243,7 +7152,7 @@ async function handleResponsesInner( try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } route.provider = rotated; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -7253,42 +7162,11 @@ async function handleResponsesInner( provider: route.provider, adapterName: activeAdapter.name, }); - maybeInvokeResolvedRoute(options, parsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); const result = await rebuildAndRefetch("key-429"); if ("failed" in result) return result.failed; upstreamResponse = result; } - // Antigravity-specific recovery allows only one short, abort-aware replay on the same - // credential. The generic OAuth failover below may still rotate when another account exists. - if (upstreamResponse.status === 403 && isAntigravityOAuth && antigravityAccountId) { - recordAntigravityCooldown(antigravityAccountId, upstreamResponse.headers.get("retry-after"), Date.now(), "geoblock"); - } - if (upstreamResponse.status === 429 && isAntigravityOAuth && antigravityAccountId) { - recordAntigravityCooldown(antigravityAccountId, upstreamResponse.headers.get("retry-after")); - const retryAfter = retryableAntigravity429DelayMs(upstreamResponse.headers.get("retry-after")); - if (!antigravity429RetryAttempted && retryAfter !== null) { - antigravity429RetryAttempted = true; - try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already closed */ } - try { - await sleepWithAbort(retryAfter, upstream.signal); - } catch { - cleanupUpstreamAbort(); - upstream.abort(); - return clientCancelledResponse(); - } - if (options.abortSignal?.aborted || upstream.signal.aborted) { - cleanupUpstreamAbort(); - upstream.abort(); - return clientCancelledResponse(); - } - const result = await rebuildAndRefetch("rate-limit-429"); - if ("failed" in result) return result.failed; - upstreamResponse = result; - continue recovery; - } - } - // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry // with another eligible OAuth account (bounded per request). Disabled by default. while ( @@ -7305,20 +7183,19 @@ async function handleResponsesInner( if (!nextAccountId) break; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } try { - const accessToken = await getAnthropicPoolAccessToken(nextAccountId); - anthropicPoolAccountId = nextAccountId; + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId)); + if (!admitted) throw new Error("OAuth selection changed during recovery"); + anthropicPoolAccountId = admitted.accountId; anthropicPoolFailovers += 1; - route.provider = { ...route.provider, apiKey: accessToken }; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; invalidateSameTargetRequest(); - promoteAnthropicActiveAccount(nextAccountId); - logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config); - logCtx.accountLogLabel = nextAccountId; - activeAdapter = resolveAdapter( + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); - maybeInvokeResolvedRoute(options, parsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); const result = await rebuildAndRefetch("anthropic-oauth-429"); if ("failed" in result) return result.failed; upstreamResponse = result; @@ -7326,6 +7203,36 @@ async function handleResponsesInner( break; } } + + // Antigravity-specific recovery allows only one short, abort-aware replay on the same + // credential. The generic OAuth failover below may still rotate when another account exists. + if (upstreamResponse.status === 403 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, upstreamResponse.headers.get("retry-after"), Date.now(), "geoblock"); + } + if (upstreamResponse.status === 429 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, upstreamResponse.headers.get("retry-after")); + const retryAfter = retryableAntigravity429DelayMs(upstreamResponse.headers.get("retry-after")); + if (!antigravity429RetryAttempted && retryAfter !== null) { + antigravity429RetryAttempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already closed */ } + try { + await sleepWithAbort(retryAfter, upstream.signal); + } catch { + cleanupUpstreamAbort(); + upstream.abort(); + return clientCancelledResponse(); + } + if (options.abortSignal?.aborted || upstream.signal.aborted) { + cleanupUpstreamAbort(); + upstream.abort(); + return clientCancelledResponse(); + } + const result = await rebuildAndRefetch("rate-limit-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } + } if ( upstreamResponse.status === 402 && cursorPoolAccountId @@ -7352,14 +7259,19 @@ async function handleResponsesInner( if (!nextAccountId) break; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } try { - const accessToken = await getValidAccessTokenForAccount("cursor", nextAccountId); + const snapshot = await getValidAccessSnapshotForAccount("cursor", nextAccountId, { requireUsableAccount: true }); + const admitted = await commitResolvedOAuthSelection(snapshot); + if (!admitted) break; cursorPoolAccountId = nextAccountId; cursorPoolFailovers += 1; parsed._cursorIdentityScope = nextAccountId; - route.provider = { ...route.provider, apiKey: accessToken }; - replayOAuthCredentialSnapshot = undefined; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + replayOAuthCredentialSnapshot = { + accountId: admitted.accountId, + generation: admitted.generation, + }; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -7368,13 +7280,14 @@ async function handleResponsesInner( providerName: route.providerName, provider: route.provider, adapterName: activeAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, codexAuthContext: authCtx, forwardHeaders: selectedForwardHeaders, }); - maybeInvokeResolvedRoute(options, parsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); logCtx.provider = formatCursorProviderForLog("cursor", nextAccountId); logCtx.accountLogLabel = nextAccountId; sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); const result = await rebuildAndRefetch("cursor-oauth-auth"); if ("failed" in result) return result.failed; upstreamResponse = result; @@ -7399,14 +7312,19 @@ async function handleResponsesInner( if (!nextAccountId) break; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } try { - const accessToken = await getValidAccessTokenForAccount("cursor", nextAccountId); + const snapshot = await getValidAccessSnapshotForAccount("cursor", nextAccountId, { requireUsableAccount: true }); + const admitted = await commitResolvedOAuthSelection(snapshot); + if (!admitted) break; cursorPoolAccountId = nextAccountId; cursorPoolFailovers += 1; parsed._cursorIdentityScope = nextAccountId; - route.provider = { ...route.provider, apiKey: accessToken }; - replayOAuthCredentialSnapshot = undefined; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + replayOAuthCredentialSnapshot = { + accountId: admitted.accountId, + generation: admitted.generation, + }; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -7415,13 +7333,14 @@ async function handleResponsesInner( providerName: route.providerName, provider: route.provider, adapterName: activeAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, codexAuthContext: authCtx, forwardHeaders: selectedForwardHeaders, }); - maybeInvokeResolvedRoute(options, parsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); logCtx.provider = formatCursorProviderForLog("cursor", nextAccountId); logCtx.accountLogLabel = nextAccountId; sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); const result = await rebuildAndRefetch("cursor-oauth-429"); if ("failed" in result) return result.failed; upstreamResponse = result; @@ -7456,16 +7375,15 @@ async function handleResponsesInner( // projectId with its token and Kiro carries routing metadata, so a token-only swap // would mix one account's credential with another's routing data. const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); - genericFailoverAccountId = nextAccountId; - genericFailovers += 1; - if (!applyFailoverSnapshot(snapshot)) break; + genericFailovers += 1; + if (!await applyFailoverSnapshot(snapshot)) break; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); - maybeInvokeResolvedRoute(options, parsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); const result = await rebuildAndRefetch("oauth-account-429"); if ("failed" in result) return result.failed; upstreamResponse = result; @@ -7611,16 +7529,6 @@ async function handleResponsesInner( nextParsed: OcxParsedRequest, initialRecoveryKind?: AttemptRecoveryKind, ): AsyncGenerator { - const requestValidationEvent = (error: unknown): AdapterEvent | undefined => { - if (!(error instanceof OcxRequestValidationError)) return undefined; - return { - type: "error", - status: error.status, - errorType: "invalid_request_error", - code: "invalid_request_error", - message: error.message, - }; - }; let response: Response | undefined; // One-shot recovery label for the next top-of-loop continuation send after a failover rotation. let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined = initialRecoveryKind; @@ -7633,7 +7541,6 @@ async function handleResponsesInner( */ const fetchContinuation = async (recoveryKind?: AttemptRecoveryKind): Promise => { let continuationRequest: AdapterRequest | undefined; - assertGoogleOptionsRoute(activeAdapter, route.provider, nextParsed); if (sameTargetRequest !== undefined && sameTargetParsed === nextParsed && sameTargetToken === transportToken) { // Same target (key/adapter/parsed/tier unchanged): replay the exact cached request. continuationRequest = sameTargetRequest; @@ -7643,7 +7550,6 @@ async function handleResponsesInner( headers: selectedForwardHeaders, translatorBudget, ...(imageTierBias > 0 ? { imageTierBias } : {}), - ...(observeAntigravityProviderError ? { onProviderError: observeAntigravityProviderError } : {}), }); recordAdapterReasoning(logCtx, continuationRequest); recordAdapterTier(logCtx, continuationRequest); @@ -7669,32 +7575,29 @@ async function handleResponsesInner( // Optional recovery label for same-target / failover continuation sends. const replayKind: AttemptRecoveryKind | undefined = recoveryKind; try { - if (diagnosticContext) { - diagnosticContext.recovery = replayKind; - diagnosticContext.attempt = logCtx.activeAttempt?.ordinal; - diagnosticContext.adapterName = activeAdapter.name; - } if (activeAdapter.fetchResponse) { - noteDiagnosticAttempt(logCtx.activeAttempt, continuationEstimate, replayKind, activeAdapter.name); + noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind); await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal); return await activeAdapter.fetchResponse(builtContinuationRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: nextParsed.stream, executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), providerName: route.providerName, modelId: nextParsed.modelId, - pacingSlotAcquired: true, }), - replayBudget, - ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}), }); } - // Continuations consume the same generation-scoped transient replay allowance. + // Same #1851 scope guard as the initial send: transient-5xx retry only for direct + // Google AI Studio; every other adapter keeps reset-only semantics here. const continuationTransientPolicy = transientRetryPolicyFor(route.provider); - return await fetchWithTransientRetry( + const fetchContinuationWithRetryPolicy = (route.provider.adapter === "google" || continuationTransientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; + return await fetchContinuationWithRetryPolicy( recovery => { - noteDiagnosticAttempt(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind, activeAdapter.name); + noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind); return fetchWithHeaderTimeout( builtContinuationRequest.url, applyUpstreamRecoveryInit({ @@ -7706,6 +7609,7 @@ async function handleResponsesInner( connectMs, nextParsed.stream, providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), providerName: route.providerName, modelId: nextParsed.modelId, }), @@ -7714,8 +7618,9 @@ async function handleResponsesInner( { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url), - replayTransientFailures: route.provider.replayTransientFailures, - replayBudget, + // Same request-scoped budget as the initial send and the 429/rotation refetches: + // a terminal-guard continuation is another leg of ONE request, so handing it a + // fresh `attempts` would let one request exceed the configured total-send ceiling. ...(continuationTransientPolicy ? { attempts: remainingTransientSendBudget(continuationTransientPolicy.attempts), @@ -7734,10 +7639,7 @@ async function handleResponsesInner( nextContinuationRecoveryKind = undefined; response = await fetchContinuation(recoveryKind); } catch (error) { - const validationEvent = requestValidationEvent(error); - if (validationEvent) { - yield validationEvent; - } else if (options.abortSignal?.aborted || upstream.signal.aborted) { + if (options.abortSignal?.aborted || upstream.signal.aborted) { yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; } else { yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; @@ -7783,10 +7685,7 @@ async function handleResponsesInner( try { response = await fetchContinuation("rate-limit-429"); } catch (error) { - const validationEvent = requestValidationEvent(error); - if (validationEvent) { - yield validationEvent; - } else if (options.abortSignal?.aborted || upstream.signal.aborted) { + if (options.abortSignal?.aborted || upstream.signal.aborted) { yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; } else { yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; @@ -7795,42 +7694,6 @@ async function handleResponsesInner( } } - if (response.status === 403 && isAntigravityOAuth && antigravityAccountId) { - recordAntigravityCooldown(antigravityAccountId, response.headers.get("retry-after"), Date.now(), "geoblock"); - } - if (response.status === 429 && isAntigravityOAuth && antigravityAccountId) { - recordAntigravityCooldown(antigravityAccountId, response.headers.get("retry-after")); - const retryAfter = retryableAntigravity429DelayMs(response.headers.get("retry-after")); - if (!antigravity429RetryAttempted && retryAfter !== null) { - antigravity429RetryAttempted = true; - try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } - try { - await sleepWithAbort(retryAfter, upstream.signal); - } catch { - if (options.abortSignal?.aborted || upstream.signal.aborted) { - yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; - } else { - yield { type: "error", message: "Provider continuation failed: retry wait interrupted" }; - } - return; - } - if (options.abortSignal?.aborted || upstream.signal.aborted) { - yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; - return; - } - try { - response = await fetchContinuation("rate-limit-429"); - } catch (error) { - if (options.abortSignal?.aborted || upstream.signal.aborted) { - yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; - } else { - yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; - } - return; - } - } - } - if (response.status === 429 && hasKeyPoolFailover(route.provider)) { const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { retryAfter: response.headers.get("retry-after"), @@ -7842,7 +7705,7 @@ async function handleResponsesInner( try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } route.provider = rotated; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -7860,7 +7723,6 @@ async function handleResponsesInner( provider: route.provider, adapterName: activeAdapter.name, }); - maybeInvokeResolvedRoute(options, nextParsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); nextContinuationRecoveryKind = "key-429"; continue; } @@ -7879,20 +7741,19 @@ async function handleResponsesInner( if (nextAccountId) { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } try { - const accessToken = await getAnthropicPoolAccessToken(nextAccountId); - anthropicPoolAccountId = nextAccountId; + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId)); + if (!admitted) throw new Error("OAuth selection changed during recovery"); + anthropicPoolAccountId = admitted.accountId; anthropicPoolFailovers += 1; - route.provider = { ...route.provider, apiKey: accessToken }; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; invalidateSameTargetRequest(); - promoteAnthropicActiveAccount(nextAccountId); - logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config); - logCtx.accountLogLabel = nextAccountId; - activeAdapter = resolveAdapter( + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); - maybeInvokeResolvedRoute(options, nextParsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); nextContinuationRecoveryKind = "anthropic-oauth-429"; continue; } catch { @@ -7900,6 +7761,29 @@ async function handleResponsesInner( } } } + if (response.status === 403 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, response.headers.get("retry-after"), Date.now(), "geoblock"); + } + if (response.status === 429 && isAntigravityOAuth && antigravityAccountId) { + recordAntigravityCooldown(antigravityAccountId, response.headers.get("retry-after")); + const retryAfter = retryableAntigravity429DelayMs(response.headers.get("retry-after")); + if (!antigravity429RetryAttempted && retryAfter !== null) { + antigravity429RetryAttempted = true; + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + try { + await sleepWithAbort(retryAfter, upstream.signal); + } catch { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + return; + } + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + return; + } + nextContinuationRecoveryKind = "rate-limit-429"; + continue; + } + } if ( response.status === 402 && cursorPoolAccountId @@ -7910,7 +7794,7 @@ async function handleResponsesInner( response.headers.get("retry-after"), ); } - if ( + while ( (response.status === 401 || response.status === 403) && route.providerName === "cursor" && route.provider.authMode === "oauth" @@ -7926,14 +7810,19 @@ async function handleResponsesInner( if (nextAccountId) { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } try { - const accessToken = await getValidAccessTokenForAccount("cursor", nextAccountId); + const snapshot = await getValidAccessSnapshotForAccount("cursor", nextAccountId, { requireUsableAccount: true }); + const admitted = await commitResolvedOAuthSelection(snapshot); + if (!admitted) break; cursorPoolAccountId = nextAccountId; cursorPoolFailovers += 1; parsed._cursorIdentityScope = nextAccountId; - route.provider = { ...route.provider, apiKey: accessToken }; - replayOAuthCredentialSnapshot = undefined; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + replayOAuthCredentialSnapshot = { + accountId: admitted.accountId, + generation: admitted.generation, + }; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -7942,17 +7831,19 @@ async function handleResponsesInner( providerName: route.providerName, provider: route.provider, adapterName: activeAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, }); bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: route.provider, adapterName: activeAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, }); - maybeInvokeResolvedRoute(options, nextParsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); logCtx.provider = formatCursorProviderForLog("cursor", nextAccountId); logCtx.accountLogLabel = nextAccountId; sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); nextContinuationRecoveryKind = "cursor-oauth-auth"; continue; } catch { @@ -7960,7 +7851,7 @@ async function handleResponsesInner( } } } - if ( + while ( response.status === 429 && route.providerName === "cursor" && route.provider.authMode === "oauth" @@ -7977,14 +7868,19 @@ async function handleResponsesInner( if (nextAccountId) { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } try { - const accessToken = await getValidAccessTokenForAccount("cursor", nextAccountId); + const snapshot = await getValidAccessSnapshotForAccount("cursor", nextAccountId, { requireUsableAccount: true }); + const admitted = await commitResolvedOAuthSelection(snapshot); + if (!admitted) break; cursorPoolAccountId = nextAccountId; cursorPoolFailovers += 1; parsed._cursorIdentityScope = nextAccountId; - route.provider = { ...route.provider, apiKey: accessToken }; - replayOAuthCredentialSnapshot = undefined; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + replayOAuthCredentialSnapshot = { + accountId: admitted.accountId, + generation: admitted.generation, + }; invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); @@ -7993,17 +7889,19 @@ async function handleResponsesInner( providerName: route.providerName, provider: route.provider, adapterName: activeAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, }); bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: route.provider, adapterName: activeAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, }); - maybeInvokeResolvedRoute(options, nextParsed, route, route.provider, activeAdapter.name, selectedForwardHeaders); logCtx.provider = formatCursorProviderForLog("cursor", nextAccountId); logCtx.accountLogLabel = nextAccountId; sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); nextContinuationRecoveryKind = "cursor-oauth-429"; continue; } catch { @@ -8036,15 +7934,15 @@ async function handleResponsesInner( // metadata, so a token-only swap would mix one account's credential with another's // routing data. const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); - genericFailoverAccountId = nextAccountId; - genericFailovers += 1; - if (applyFailoverSnapshot(snapshot, nextParsed)) { + genericFailovers += 1; + if (await applyFailoverSnapshot(snapshot, nextParsed)) { invalidateSameTargetRequest(); - activeAdapter = resolveAdapter( + activeAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), config.cacheRetention, ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); nextContinuationRecoveryKind = "oauth-account-429"; continue; } @@ -8096,13 +7994,7 @@ async function handleResponsesInner( const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal); try { if (nextParsed.stream) { - yield* diagnoseAdapterEvents( - activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata), - activeAdapter.name, - diagnosticRequestId, - logCtx, - adapterDiagnosticState, - ); + yield* activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata); } else if (activeAdapter.parseResponse) { yield* await activeAdapter.parseResponse(response, translatorBudget, logCtx.activeTierMetadata); } else { @@ -8112,10 +8004,7 @@ async function handleResponsesInner( detachContinuationBodyGuard(); } } catch (error) { - const validationEvent = requestValidationEvent(error); - if (validationEvent) { - yield validationEvent; - } else if (options.abortSignal?.aborted) { + if (options.abortSignal?.aborted) { yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; } else { yield { type: "error", message: `Provider continuation parse failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; @@ -8137,18 +8026,11 @@ async function handleResponsesInner( }; if (parsed.stream) { - const initialEventStream = diagnoseAdapterEvents( - activeAdapter.parseStream(upstreamResponse, translatorBudget, logCtx.activeTierMetadata), - activeAdapter.name, - diagnosticRequestId, - logCtx, - adapterDiagnosticState, + const initialEventStream = activeAdapter.parseStream( + upstreamResponse, + translatorBudget, + logCtx.activeTierMetadata, ); - if (diagnosticContext) { - diagnosticContext.adapterName = activeAdapter.name; - diagnosticContext.attempt = logCtx.activeAttempt?.ordinal; - diagnosticContext.recovery = logCtx.activeAttempt?.recoveryKinds.at(-1); - } const eventStream = terminalGuardEnabled ? guardTerminalEventStream({ parsed, @@ -8165,14 +8047,12 @@ async function handleResponsesInner( const guardedEventStream = emptyCompletionGuardEnabled ? guardEmptyCompletionEventStream({ firstEvents: eventStream, - maxRetries: emptyCompletionMaxRetries, continuation: fetchGuardedEmptyCompletionRetry, }) : eventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; - const benchmarkUsageGate = { done: false }; const sseStream = bridgeToResponsesSSE( - observeProgressEvents(guardedEventStream, logCtx), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, { translatorBudget, @@ -8186,10 +8066,8 @@ async function handleResponsesInner( ...(routedCompaction ? { compaction: true } : {}), // Same grok-surface split as the runTurn branch above. ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}), - ...(diagnosticContext ? { diagnostic: diagnosticContext } : {}), onUsage: usage => { // Raw adapter usage, pre wire-normalization (see the runTurn branch above). - observeBenchmarkUsage(options.claudeBenchmarkObserver, benchmarkUsageGate, activeAdapter.name, parsed._responseModelId ?? parsed.modelId, usage); logCtx.usageFromBridge = true; if (usage) { logCtx.usage = usage; @@ -8245,19 +8123,16 @@ async function handleResponsesInner( events = []; for await (const event of guardEmptyCompletionEventStream({ firstEvents: (async function* () { yield* guardedEvents; })(), - maxRetries: emptyCompletionMaxRetries, continuation: fetchGuardedEmptyCompletionRetry, })) events.push(event); } else { events = guardedEvents; } - events = await collectProgressEvents(events, logCtx); } finally { cleanupUpstreamAbort(); } const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; let providerState: OcxProviderContinuationState | undefined; - const benchmarkUsageGate = { done: false }; const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._reasoningReplayScope, @@ -8270,7 +8145,6 @@ async function handleResponsesInner( ...(routedCompaction ? { compaction: true } : {}), onProviderState: state => { providerState = state; }, onUsage: usage => { - observeBenchmarkUsage(options.claudeBenchmarkObserver, benchmarkUsageGate, activeAdapter.name, parsed._responseModelId ?? parsed.modelId, usage); logCtx.usageFromBridge = true; if (usage) { logCtx.usage = usage; diff --git a/src/server/responses/core.ts.orig b/src/server/responses/core.ts.orig new file mode 100644 index 0000000000..3c539c6d8e --- /dev/null +++ b/src/server/responses/core.ts.orig @@ -0,0 +1,7753 @@ +import type { Server } from "bun"; +import { randomUUID } from "node:crypto"; +import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; +import { formatPassthroughUpstreamError } from "./passthrough-error"; +import { + createResponsesFieldBackfillBlockRewrite, + backfillResponsesFieldsJson, +} from "./responses-field-backfill"; +import { checkInputAdmission } from "./input-admission"; +import { + checkOutboundBodySize, + describeOutboundBodyRefusal, +} from "./outbound-body-guard"; +import { nativeContextLimits } from "../../codex/catalog"; +import { describeUpstreamConnectFailure } from "./upstream-error"; +import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; +import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota"; +import { isCodexWsQuotaObservedResponse } from "./ws-upstream"; +import { + multiAgentGuidanceEnabled, + resolveEnvValue, +} from "../../config"; +import { parseRequest } from "../../responses/parser"; +import { + bindReasoningReplayScope, + commitReasoningReplayServingIdentity, + reasoningReplayCodexCredentialIdentity, + reasoningReplayDestinationIdentity, + durableReplayDestinationIdentity, + durableReplayCredentialIdentity, + reasoningReplayKeyCredentialIdentity, + reasoningReplayOpaqueBlobRejectionMemoized, + reasoningReplayOAuthCredentialIdentity, + reasoningReplayServingIdentityChanged, + rememberReasoningReplayOpaqueBlobRejection, +} from "../../responses/reasoning-replay-cache"; +import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; +import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; +import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; +import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema"; +import { + copyPreviousResponseReplayProvenance, + expandPreviousResponseInput, + markBodyNonPersistable, + previousResponseProviderState, + previousResponseReplayFailure, + previousResponseScopeMismatch, + rememberResponseState, +} from "../../responses/state"; +import { + bindTurnTerminationScope, + rememberDeliveredFinalAnswer, +} from "../../responses/turn-termination"; +import { + isValidProviderContinuationOwner, + mergeProviderContinuationPayload, + providerContinuationOwnerFromReplayIdentity, + providerContinuationRouteScope, + sameProviderContinuationOwner, +} from "../../responses/provider-continuation"; +import { + comboRouteDecisionTrace, + NoEligiblePolicyCandidateError, + routeCompactionModel, + routeConcreteModel, + routeModel, + type RouteResult, +} from "../../router"; +import { evidenceFromBody } from "../../routing/request-evidence"; +import { resolvePassiveRouteSubjectId } from "../passive-route-linker"; +import { + advanceComboAfterFailure, + comboCooldownRetryAfterSeconds, + comboDefaultEffort, + comboFailureCooldownScope, + comboFailureDecision, + comboIdFromRawBody, + comboRequestHasImageInput, + concreteComboRequestBody, + getCombo, + isComboTargetInCooldown, + NoAvailableComboTargetsError, + noteComboSuccess, + parseRetryAfterMs, + pickComboTarget, + pickComboTargetWithWait, + targetKey, +} from "../../combos"; +import { isInjectionDebugEnabled } from "../../lib/debug-settings"; +import { + CYBER_POLICY_ERROR_CODE, + CYBER_POLICY_FALLBACK_MESSAGE, + adapterFailureFromMessage, + isCyberPolicyCode, + isCyberPolicyMessage, +} from "../../lib/errors"; +import { injectionDebugLog } from "../../lib/injection-debug-log"; +import { resolveClientRetryAfter } from "../../lib/retry-after"; +import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit"; +import { CODE_MODE_EXEC_TOOL_NAME, modelInList, namespacedToolName } from "../../types"; +import type { + AdapterEvent, + OcxConfig, + OcxParsedRequest, + OcxProviderConfig, + OcxProviderContinuationOwner, + OcxProviderContinuationState, + OcxReasoningReplayIdentity, + OcxUsage, + TierDecision, +} from "../../types"; +import { + forceRefreshOAuthAccessSnapshot, + getValidAccessTokenForAccount, + getValidAccessSnapshotForAccount, + getValidAccessTokenSnapshot, + publicOAuthAuthenticationErrorMessage, + type OAuthAccessSnapshot, + UnsupportedOAuthProviderError, +} from "../../oauth"; +import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountCredentialWithStatus } from "../../oauth/store"; +import { + ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST, + anthropicSessionKeyFromParts, + commitAnthropicSelectionRouting, + formatAnthropicProviderForLog, + getAnthropicPoolAccessSnapshot, + getAnthropicPoolRetryAfterSeconds, + isAnthropicAccountPoolEnabled, + hasAnthropicFailoverQuorum, + resolveAnthropicAccountForSession, + rotateAnthropicAccountOn429, + type AnthropicAccountSelectionReason, +} from "../../oauth/anthropic-routing"; +import { stampOAuthAccountLabel } from "../../providers/label"; +import { + failoverAccountSnapshot, + forgetGenericFailoverRoster, + GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST, + isGenericFailoverProvider, + isGenericOAuthFailoverEnabled, + preferredInitialAccount, + rotateGenericOAuthAccountOn429, +} from "../../oauth/generic-account-failover"; +import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot"; +import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; +import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; +import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; +import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; +import { + applyCodexAuthContextToProvider, + createCodexReserveDispatchGuard, + unwrapUpstreamRetryEvidenceError, + codexPoolAffinityKey, + CodexAccountCooldownError, + CodexAuthContextError, + CodexMainProfileDrainingError, + CodexPoolAuthenticationError, + CodexThreadAffinityExpiredError, + headersForCodexAuthContext, + materializeCodexUpstreamAuthAsync, + isCodexAuthContextUsable, + resolveCodexAuthContext, + codexProbeLeaseId, + codexProbeQuotaScope, + releaseCodexAuthContextProbeLease, + stripCodexRuntimeProviderFields, + type CodexAuthContext, + type CodexAuthPolicyConfig, +} from "../../codex/auth-context"; +import { + entitledCodexAccountIdsForModel, + invalidateCodexModelEntitlementsForAccount, + resolveCodexModelEntitlements, +} from "../../codex/model-entitlements"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; +import { + MAIN_CODEX_ACCOUNT_ID, + forceRefreshMainAccountToken, + type NativeMainRefreshDependencies, +} from "../../codex/main-account"; +import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug"; +import { + computeQuotaCooldown, + codexQuotaScopeForModel, + formatCodexProviderForLog, + handOffThreadAffinityGeneration, + previewCodexAccountForRequest, + recordCodexUpstreamOutcome, + type CodexUpstreamOutcome, +} from "../../codex/routing"; +import { + TokenRefreshError, + forceRefreshCodexPoolToken, + readCodexAccountRecord, +} from "../../codex/account-store"; +import { codexAuthContextLogLabel } from "../../codex/account-label"; +import { + applyUpstreamRecoveryInit, + fetchWithResetRetry, + fetchWithTransientRetry, + prepareSameTarget429Wait, +} from "../../lib/upstream-retry"; +import { + ForwardAdmissionCredentialError, + hasForwardableCodexBearer, + validateForwardAdmissionCredential, +} from "../auth-cors"; +import type { DataPlaneAdmission } from "../auth-cors"; +import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; +import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../../codex/loopback-target"; +import { providerContextCap } from "../../providers/context-cap"; +import { + fastPolicyForModel, + serviceTierSupportFromPolicy, + SERVICE_TIER_ADAPTERS, +} from "../../providers/service-tier"; +import { + canonicalFastTierMarker, + decideTier, + tierObservationContext, + tierValueAfterDecision, + type ResolvedFastPolicy, +} from "../../providers/fastwire"; +import { + RequestPacingQueueOverloadError, + waitForProviderRequestSlot, +} from "../../providers/request-pacing"; +import { slugsEquivalent } from "../../providers/slug-codec"; +import { isMuseSubscriptionUsagePayload, parseMuseSubscriptionUsage } from "../../providers/muse-subscription-usage"; +import { hasPassiveAccountQuota, recordPassiveAccountQuota } from "../../providers/quota"; +import { captureConfigGeneration } from "../../lib/state-store-sweeper"; +import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; +import { isUsageDebugEnabled } from "../../usage/debug"; +import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress"; +import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve"; +import { + providerModelResponsesTerminalRepair, + providerModelResponsesUpstreamStreaming, + type InboundWire, +} from "../../providers/registry"; +import type { AdapterRequest, ProviderAdapter } from "../../adapters/base"; +import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../../providers/api-key-selection"; +import { + hasKeyPoolFailover, + rateLimitRetryDelayMs, + rateLimitRetryPolicyFor, + rotateProviderTransportOn429, + rotateProviderTransportOn401, + transientRetryPolicyFor, +} from "../../providers/key-failover"; +import { shouldAttemptImageTierRetry } from "../image-retry"; +import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport"; +import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport"; +import type { WsData } from "../ws-bridge"; +import { + codexAccountSelectionForTurn, + registerTurn, + trackStreamLifetime, + tryClaimNativeMainProfileForTurn, + unregisterTurn, +} from "../lifecycle"; +import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact"; +import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { + ENCRYPTED_FUNCTION_OUTPUT_REJECTION, + isRateLimitOrQuotaFailureMessage, + upstreamErrorMessageFromPayload, +} from "../../lib/errors"; +import type { AdmissionLease } from "../../lib/admission"; +import { supportedLadderFor } from "../effort-policy"; +import { isThreadSpawnRequest } from "../effort-policy"; +import { + applySubagentModelFallback, + maybePrimeSubagentQuota, + recordSubagentQuotaFailureForThreadSpawn, + resolveSubagentFallbackChain, + subagentFallbackNeedsModelEntitlements, + type SubagentModelEligibleAccountIds, + type SubagentPoolAccountPreview, +} from "../../codex/subagent-model-fallback"; +import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup"; +import { + beginRequestAttempt, + finishRequestAttempt, + inspectResponseLogJson, + noteAttemptSend, + readConfiguredCodexServiceTier, + recordAdapterReasoning, + recordAdapterTier, + recordAdapterTierMetadata, + recordAttemptRequestedEffort, + requestLogSpeedLabel, + sealRequestAttemptIdentity, + recordAttemptCredentialSource, + usageFromResponsesPayload, + type RequestLogContext, +} from "../request-log"; +import { + conversationIdFromResponsesRequest, + normalizeLogConversationId, + reasoningReplayConversationIdFromResponsesRequest, + sessionLaneIdFromRequest, + sessionIdHeaderFromRequest, +} from "../request-log-conversation"; +import type { AttemptRecoveryKind } from "../../usage/log"; +import { + consumeForInspection, + consumeForResponseLogMetadata, + createSseInspector, + isEagerRelaySseResponse, + isNativePassthroughSseResponse, + markEagerRelaySseResponse, + markNativePassthroughSseResponse, + relaySseWithFailedTail, + relayWithAbort, + sanitizePassthroughHeaders, +} from "../relay"; +import { + agentTaskRecoveryConfig, + discardEncryptedAgentTaskRecovery, + recoverEncryptedAgentTaskWithResult, + restoreCachedEncryptedAgentTasks, + type AgentTaskRecoveryFailureReason, +} from "./agent-task-recovery"; +import { relaySseEagerBounded } from "../relay-eager"; +import { + relayResponsesSseWithTerminalRepair, + type ResponsesTerminalRepairScheduler, +} from "../responses-terminal-repair"; +import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps"; +import { cancelBodyOnAbort } from "../../lib/abort"; +import { isCodexWsUpstreamResponse, type BunRuntimeGateInput } from "./ws-upstream"; +import { + createResponsesItemIdPayloadRewrite, + hasResponsesItemIdRepair, + repairResponsesJsonItemIds, +} from "../responses-item-id-repair"; +import { + createReasoningSummaryChannelPayloadRewrite, + rewriteReasoningSummaryInJsonString, + routeUsesContentChannelReasoning, +} from "../responses-reasoning-summary-rewrite"; +import { + createImageGenCallRestoreRewrite, + imageGenToolCallAliases, + restoreImageGenCallsInJson, +} from "../responses-image-gen-repair"; +import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite"; +import { parseRequestEffortRowId } from "../effort-row"; +import { parseSyntheticRowId } from "../fast-row"; +import { + collectSelfNamedNamespaceScrubAuthorization, + createSelfNamedToolCallNamespaceScrubRewrite, + scrubSelfNamedToolCallNamespaceInJson, +} from "../responses-self-named-namespace-scrub"; +import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; + +import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; +import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; +import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload"; +import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, type ProviderFetchOptions } from "./fetch-helpers"; +import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability"; +import { + acquireUpstreamHostAdmission, + disableUpstreamHostCircuitForKey, + normalizeUpstreamHostCircuitThreshold, + recordUpstreamHostFailure, + releaseUpstreamHostAdmission, + resetUpstreamHostHealth, + upstreamHostHealthKey, + type UpstreamHostAdmissionLease, +} from "../../codex/upstream-host-health"; +import { createGrokResponsesSparseTerminalBlockRewrite } from "../grok-responses-snapshot-repair"; +import { + createResponsesSnapshotBlockRewrite, + hasResponsesSnapshotRepair, + repairResponsesSnapshotJson, +} from "../responses-snapshot-repair"; +import { + composeSseBlockRewrites, + composeSsePayloadRewrites, + payloadRewriteAsBlockRewrite, + relaySseWithBlockRewrite, +} from "../sse-payload-rewrite"; +import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; +import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; +import { collectFunctionCallRepairSchemas, repairFunctionCallsInJson } from "../../responses/function-call-compat"; +import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair"; +import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; +import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; +import { + createRoutedNamespaceCallRestoreRewrite, + NamespaceToolCollisionError, + restoreRoutedNamespaceCalls, + restoreRoutedNamespaceCallsInJson, + type RoutedNamespaceToolAliases, +} from "../../responses/namespace-tool-compat"; +import { + collectDeclaredNamelessClientCallTypes, + collectDeclaredWireToolNames, + collectProviderExecutedCallTypes, + createUndeclaredToolCallGuardBlockRewrite, + currentTurnWireToolCatalogBody, + hasExplicitWireToolCatalog, + undeclaredToolCallMessage, + undeclaredToolCallName, + undeclaredToolCallNameInResponse, + type ProviderExecutedCallType, +} from "../responses-undeclared-tool-guard"; +import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; +import { responsesJsonToSseStream } from "../responses-json-events"; +import { streamingContextOverflowResponse } from "./context-overflow"; +import { guardTerminalEventStream } from "./terminal-guard"; +import { + emptyCompletionRetryEnabled, + emptyCompletionNotice, + observeEmptyCompletion, + guardEmptyCompletionEventStream, +} from "./empty-completion-guard"; +import { preflightComboStreamResponse } from "./combo-stream-preflight"; + +// runTurn adapters own an event queue and perform their combo preflight before +// bridging. A second byte-stream reader would reinterpret that transport's +// already-committed event boundary and can replay custom adapter work. +const runTurnAdapterSseResponses = new WeakSet(); + +/** + * Adapters whose continuation state must survive Codex's store:false requests. + */ +export function adapterNeedsForcedContinuation(name: string): boolean { + return name === "kiro" || name === "cursor"; +} + +export function sidecarOutcomeRecorder( + config: OcxConfig, + authCtx: CodexAuthContext, +): ((outcome: CodexUpstreamOutcome) => void) | undefined { + return authCtx.kind === "pool" || authCtx.kind === "main-pool" + ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + threadId: authCtx.affinityKey, + fixedAccount: authCtx.fixedAccount, + probeLeaseId: authCtx.probeLeaseId, + probeQuotaScope: authCtx.probeQuotaScope, + writerGeneration: authCtx.writerGeneration, + // A vision or web-search sidecar can return 401/403, and that is evidence about the exact + // stored credential it used. Without the generation it becomes an account-wide quarantine + // that a replacement inherits (#2892 gap 4). `main-pool` has no stored-record generation, so + // it keeps the unfenced account-wide semantics. + ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), + }) + : undefined; +} + + + +import { isShadowSourceModel, shadowSourceModelPrefix, shouldInterceptShadowCall } from "../../lib/shadow-call"; + +export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call"; + + + +export function codexLogAccountId(authCtx: CodexAuthContext): string | null { + return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null; +} + +type ContinuationOwnerRead = + | { kind: "missing" } + | { kind: "invalid" } + | { kind: "valid"; owner: OcxProviderContinuationOwner }; + +function readProviderContinuationOwner( + state: OcxProviderContinuationState | undefined, +): ContinuationOwnerRead { + if (!state || state.__ocxOwner === undefined) return { kind: "missing" }; + const owner = state.__ocxOwner; + if (!isValidProviderContinuationOwner(owner)) return { kind: "invalid" }; + return { kind: "valid", owner: { ...owner } }; +} + +function providerContinuationPayload( + state: OcxProviderContinuationState | undefined, +): OcxProviderContinuationState | undefined { + if (!state) return undefined; + const cloned = structuredClone(state); + delete cloned.__ocxOwner; + return Object.keys(cloned).length > 0 ? cloned : undefined; +} + +function bindProviderContinuationForRoute( + parsed: OcxParsedRequest, + currentOwner: OcxProviderContinuationOwner | undefined, +): void { + const candidate = parsed._providerContinuationCandidate; + const storedOwner = readProviderContinuationOwner(candidate); + const mayRestore = storedOwner.kind === "valid" + && !!currentOwner + && sameProviderContinuationOwner(storedOwner.owner, currentOwner); + const restored = mayRestore ? providerContinuationPayload(candidate) : undefined; + if (restored) parsed._providerContinuation = restored; + else delete parsed._providerContinuation; + const cursorConversationId = restored?.cursor?.conversationId; + if (cursorConversationId) parsed._cursorConversationId = cursorConversationId; + else delete parsed._cursorConversationId; + if (currentOwner) parsed._providerContinuationOwner = { ...currentOwner }; + else delete parsed._providerContinuationOwner; +} + +function providerContinuationDestinationIdentity( + parsed: OcxParsedRequest, + provider: OcxProviderConfig, +): string | undefined { + const kiroContext = parsed._kiroAuthContext; + return reasoningReplayDestinationIdentity(JSON.stringify([ + provider.baseUrl.trim().replace(/\/+$/, ""), + provider.responsesPath ?? "", + kiroContext?.profileArn ?? "", + kiroContext?.apiRegion ?? "", + kiroContext?.ssoRegion ?? "", + ])); +} + +function bindRouteReasoningReplayScope(args: { + parsed: OcxParsedRequest; + providerName: string; + provider: OcxProviderConfig; + adapterName: string; + oauthCredentialSnapshot?: Pick; + codexAuthContext?: CodexAuthContext; + forwardHeaders?: Headers; +}): void { + const { parsed, providerName, provider, adapterName } = args; + let credentialIdentity: string | undefined; + let credentialDurableIdentity: string | undefined; + const durableSalt = thoughtSignatureReplaySalt(); + if (provider.authMode === "oauth") { + credentialIdentity = reasoningReplayOAuthCredentialIdentity( + args.oauthCredentialSnapshot, + provider.headers, + ); + // The persisted account-slot id survives token refresh and restarts; the rotating + // generation deliberately does NOT participate (#1926 design: rotation-safe). + credentialDurableIdentity = durableReplayCredentialIdentity( + "oauth", + args.oauthCredentialSnapshot?.accountId, + provider.headers, + durableSalt, + ); + } else if (provider.authMode === "forward") { + const poolContext = args.codexAuthContext?.kind === "pool" + || args.codexAuthContext?.kind === "main-pool" + ? args.codexAuthContext + : undefined; + credentialIdentity = reasoningReplayCodexCredentialIdentity({ + authorization: poolContext + ? `Bearer ${poolContext.accessToken}` + : args.forwardHeaders?.get("authorization"), + chatgptAccountId: poolContext?.chatgptAccountId + ?? args.forwardHeaders?.get("chatgpt-account-id"), + accountId: poolContext?.accountId, + credentialGeneration: poolContext?.kind === "pool" + ? poolContext.generation + : undefined, + writerGeneration: poolContext?.writerGeneration, + headers: provider.headers, + }); + // Durable identity requires a STABLE, TRUSTED account handle. Pool context comes from + // our own account store; a client-supplied chatgpt-account-id header is attacker + // -influenceable bucket selection and a bearer alone is rotating material — both are + // refused, so direct-forward turns get no durable scope (fail closed; the in-process + // cache still covers same-process replay). + const codexDurableHandle = poolContext?.accountId + ?? poolContext?.chatgptAccountId + ?? undefined; + credentialDurableIdentity = durableReplayCredentialIdentity( + "codex", + codexDurableHandle ?? undefined, + provider.headers, + durableSalt, + ); + } else if (provider.authMode !== "local") { + credentialIdentity = reasoningReplayKeyCredentialIdentity(provider); + credentialDurableIdentity = durableReplayCredentialIdentity( + "key", + nonEmptyProviderApiKey(provider), + provider.headers, + durableSalt, + ); + } + const providerDestinationIdentity = reasoningReplayDestinationIdentity(provider.baseUrl); + const replayIdentity: OcxReasoningReplayIdentity | undefined = credentialIdentity && providerDestinationIdentity + ? { + providerName, + providerDestinationIdentity, + providerDestinationDurableIdentity: durableReplayDestinationIdentity(provider.baseUrl), + adapterName, + modelId: parsed.modelId, + credentialIdentity, + ...(credentialDurableIdentity ? { credentialDurableIdentity } : {}), + } + : undefined; + const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider); + const continuationOwner = providerContinuationOwnerFromReplayIdentity( + replayIdentity && continuationDestinationIdentity + ? { ...replayIdentity, providerDestinationIdentity: continuationDestinationIdentity } + : undefined, + ); + if (adapterName === "cursor") { + // The final route owner is authoritative for Cursor and supersedes the account-derived + // seed assigned before route binding. A Cursor conversation must be scoped to the exact + // provider/destination/adapter/model/credential that serves it. + if (continuationOwner) parsed._cursorIdentityScope = providerContinuationRouteScope(continuationOwner); + else if (!parsed._cursorIdentityScope?.startsWith("cursor-unowned:")) { + // Prevent the adapter's token-only fallback from recreating a provider-private id after the + // route owner failed closed. The sentinel is per parsed request and contains no credential. + parsed._cursorIdentityScope = `cursor-unowned:${randomUUID()}`; + } + } + bindReasoningReplayScope( + parsed._reasoningReplayScope, + replayIdentity, + ); + // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal + // after the first mismatch, but it cannot make history minted by the prior route decodable. + if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) { + parsed._stripReasoningEncryptedContent = true; + } + if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) { + parsed._stripReasoningEncryptedContent = true; + } + bindProviderContinuationForRoute(parsed, continuationOwner); +} + +function adapterResponseReachedServingTerminal( + events: readonly AdapterEvent[], + response: Readonly>, +): boolean { + return (response.status === "completed" || response.status === "incomplete") + && events.some(event => event.type === "done" || event.type === "incomplete"); +} + +const OPAQUE_RESPONSES_INPUT_TYPES = new Set([ + "reasoning", + "compaction", + "compaction_summary", + "context_compaction", +]); +const FUNCTION_OUTPUT_TYPES = new Set(["function_call_output", "custom_tool_call_output"]); +// codex-app subagent results replay as agent_message items whose content parts may carry +// backend-minted encrypted_content; the ChatGPT backend decrypts them in its function-output +// path, so a cross-identity replay of those parts produces ENCRYPTED_FUNCTION_OUTPUT_REJECTION. +const AGENT_MESSAGE_TYPE = "agent_message"; + +function encryptedFunctionOutputParts(output: unknown): boolean { + return Array.isArray(output) && output.some(part => ( + part !== null + && typeof part === "object" + && !Array.isArray(part) + && (part as { type?: unknown }).type === "encrypted_content" + && typeof (part as { encrypted_content?: unknown }).encrypted_content === "string" + && (part as { encrypted_content: string }).encrypted_content.length > 0 + )); +} + +function outboundResponsesInput(bodyText: string | undefined): unknown[] | undefined { + if (!bodyText) return undefined; + try { + const body = JSON.parse(bodyText) as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + const input = (body as { input?: unknown }).input; + return Array.isArray(input) ? input : undefined; + } catch { + return undefined; + } +} + +function outboundResponsesBodyCarriesEncryptedFunctionOutput(bodyText: string | undefined): boolean { + const input = outboundResponsesInput(bodyText); + if (!input) return false; + return input.some(item => { + if (item === null || typeof item !== "object" || Array.isArray(item)) return false; + const candidate = item as { type?: unknown; output?: unknown; content?: unknown }; + const type = String(candidate.type ?? ""); + if (FUNCTION_OUTPUT_TYPES.has(type) && encryptedFunctionOutputParts(candidate.output)) return true; + return type === AGENT_MESSAGE_TYPE && encryptedFunctionOutputParts(candidate.content); + }); +} + +function outboundResponsesBodyCarriesOpaqueBlob(bodyText: string | undefined): boolean { + const input = outboundResponsesInput(bodyText); + if (!input) return false; + return input.some(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const candidate = item as { type?: unknown; encrypted_content?: unknown; output?: unknown }; + if ( + typeof candidate.type === "string" + && OPAQUE_RESPONSES_INPUT_TYPES.has(candidate.type) + && typeof candidate.encrypted_content === "string" + && candidate.encrypted_content.length > 0 + ) return true; + if ( + typeof candidate.type === "string" + && FUNCTION_OUTPUT_TYPES.has(candidate.type) + && encryptedFunctionOutputParts(candidate.output) + ) return true; + return candidate.type === AGENT_MESSAGE_TYPE + && encryptedFunctionOutputParts((candidate as { content?: unknown }).content); + }); +} + +function isEncryptedFunctionOutputRejection(bodyText: string): boolean { + if (bodyText.trim() === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + try { + const payload = JSON.parse(bodyText) as unknown; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as { detail?: unknown; message?: unknown; error?: unknown }; + if (record.detail === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + if (record.message === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + if (record.error === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; + return record.error !== null + && typeof record.error === "object" + && !Array.isArray(record.error) + && (record.error as { message?: unknown }).message === ENCRYPTED_FUNCTION_OUTPUT_REJECTION; + } catch { + return false; + } +} + +function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { + if (isEncryptedFunctionOutputRejection(bodyText)) return true; + try { + if (upstreamErrorMessageFromPayload(JSON.parse(bodyText) as unknown) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) { + return true; + } + } catch { + /* invalid JSON bodies fall through to the exact nested envelope checks */ + } + try { + const payload = JSON.parse(bodyText) as unknown; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as { code?: unknown; error?: unknown }; + + if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) { + const error = record.error as { type?: unknown; code?: unknown; message?: unknown }; + if (error.type === "invalid_request_error") { + if (error.code === "invalid_encrypted_content") return true; + if ( + (error.code === null || error.code === undefined) + && typeof error.message === "string" + && error.message.startsWith("The encrypted content ") + && error.message.endsWith( + " could not be verified. Reason: Encrypted content could not be decrypted or parsed.", + ) + ) return true; + } + } + + if (record.code !== "invalid-argument" || typeof record.error !== "string") return false; + return record.error.startsWith("Could not decode the compaction blob") + || record.error.startsWith("Could not decrypt the provided encrypted_content"); + } catch { + return false; + } +} + +/** + * Whether an upstream Responses 4xx authoritatively rejected opaque replay state. + * + * The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or + * compaction blob that the adapter already lowered, in which case a replay would be byte-identical. + * OpenAI usually exposes a dedicated nested code; ChatGPT also emits one exact code-less + * unverifiable-ciphertext message. xAI's code is generic, so its two concrete decoder error + * identities are also required. Unrelated error prose must never gain a hidden resend. + */ +export function shouldAttemptOpaqueBlobRecovery(args: { + status: number; + adapterName: string; + outboundBody?: string; + errorBody: string; + alreadyAttempted: boolean; +}): boolean { + const acceptedStatus = (args.status >= 400 && args.status < 500) + || ( + args.status === 502 + && outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody) + && isEncryptedFunctionOutputRejection(args.errorBody) + ); + return acceptedStatus + && args.adapterName === "openai-responses" + && !args.alreadyAttempted + && outboundResponsesBodyCarriesOpaqueBlob(args.outboundBody) + && isSelfIdentifiedOpaqueBlobRejection(args.errorBody); +} + +async function opaqueBlobRejectionBodyForRecovery( + response: Response, + outboundBody: string | undefined, + adapterName: string, + alreadyAttempted: boolean, + signal: AbortSignal, +): Promise { + if ( + response.status < 400 + || (response.status >= 500 && response.status !== 502) + || adapterName !== "openai-responses" + || alreadyAttempted + || !outboundResponsesBodyCarriesOpaqueBlob(outboundBody) + ) return undefined; + try { + const body = await readBoundedResponseBody(response.clone(), { signal }); + return body.displaySafe && !body.truncated ? body.text : undefined; + } catch { + return undefined; + } +} + +/** + * Materialize an upstream error body only when the bounded reader observed a complete, + * display-safe payload. Partial timeout and over-limit prefixes are attacker-controlled, + * so callers keep their existing status-only fallback instead. + */ +export async function readDisplaySafeErrorText( + response: Response, + signal: AbortSignal, + fallback: string, +): Promise { + try { + const body = await readBoundedResponseBody(response, { signal }); + return body.displaySafe ? body.text : fallback; + } catch { + // Preserve the former Response.text().catch(fallback) contract. Request-abort + // classification remains owned by the surrounding response pipeline. + return fallback; + } +} + +interface NormalizedUpstreamErrorText { + safeText: string; + message?: string; + type?: string; + code?: string; + cyberPolicy: boolean; +} + +/** + * Extract the structured provider error envelope without making `error.type` authoritative. + * Policy identity comes from the dedicated code (or the legacy message fallback); a credible + * upstream type is only carried through so callers do not erase provider diagnostics. + */ +function normalizeUpstreamErrorText(text: string, fallback: string): NormalizedUpstreamErrorText { + const safeText = redactSecretString(text).slice(0, 500).trim() || fallback; + let message: string | undefined; + let type: string | undefined; + let code: string | undefined; + try { + const parsed = JSON.parse(text) as Record; + const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response) + ? parsed.response as Record + : undefined; + const candidates = [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed]; + const source = candidates.find((candidate): candidate is Record => { + if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) return false; + const record = candidate as Record; + return [record.message, record.type, record.code].some(value => typeof value === "string"); + }); + if (!source) return { safeText, cyberPolicy: isCyberPolicyMessage(safeText) }; + if (typeof source.message === "string" && source.message.trim()) { + message = redactSecretString(source.message.trim()).slice(0, 500); + } + if (typeof source.type === "string" && source.type.trim()) type = source.type.trim(); + if (typeof source.code === "string" && source.code.trim()) code = source.code.trim(); + } catch { + /* non-JSON upstream body — retain the bounded display-safe text */ + } + const cyberPolicy = isCyberPolicyCode(code) || isCyberPolicyMessage(message ?? safeText); + return { safeText, message, type, code, cyberPolicy }; +} + +function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void { + parsed._stripReasoningEncryptedContent = true; + const rawBody = parsed._rawBody; + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return; + const input = (rawBody as { input?: unknown }).input; + if (!Array.isArray(input)) return; + const stripEncryptedParts = (parts: unknown[]): unknown[] => { + let changed = false; + const stripped = parts.map(part => { + if ( + part !== null + && typeof part === "object" + && !Array.isArray(part) + && (part as { type?: unknown }).type === "encrypted_content" + && typeof (part as { encrypted_content?: unknown }).encrypted_content === "string" + && (part as { encrypted_content: string }).encrypted_content.length > 0 + ) { + changed = true; + return { type: "input_text", text: "[encrypted content omitted]" }; + } + return part; + }); + return changed ? stripped : parts; + }; + const strippedInput = input.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const record = item as Record; + const type = String(record.type ?? ""); + if (FUNCTION_OUTPUT_TYPES.has(type) && Array.isArray(record.output)) { + const output = stripEncryptedParts(record.output); + return output !== record.output ? { ...record, output } : item; + } + if (type === AGENT_MESSAGE_TYPE && Array.isArray(record.content)) { + const content = stripEncryptedParts(record.content); + return content !== record.content ? { ...record, content } : item; + } + return item; + }); + Object.assign(rawBody, { input: strippedInput }); +} + +function resetStreamedOpaqueBlobLogContext(logCtx: RequestLogContext): void { + delete logCtx.upstreamError; + delete logCtx.terminalHttpStatus; + delete logCtx.terminalErrorCode; + delete logCtx.terminalIncompleteReason; +} + +type OpaqueBlobRecoveryGuard = { attempted: boolean }; + +type OpaqueBlobRecoveryResult = + | { kind: "skipped" } + | { kind: "recovered"; response: Response } + | { kind: "failed"; response: Response }; + +async function attemptOpaqueBlobRecovery( + args: { + response: Response; + outboundBody?: string; + adapterName: string; + parsed: OcxParsedRequest; + guard: OpaqueBlobRecoveryGuard; + signal: AbortSignal; + }, + rebuild: (kind: AttemptRecoveryKind) => Promise, +): Promise { + const errorBody = await opaqueBlobRejectionBodyForRecovery( + args.response, + args.outboundBody, + args.adapterName, + args.guard.attempted, + args.signal, + ); + if (errorBody === undefined || !shouldAttemptOpaqueBlobRecovery({ + status: args.response.status, + adapterName: args.adapterName, + outboundBody: args.outboundBody, + errorBody, + alreadyAttempted: args.guard.attempted, + })) { + return { kind: "skipped" }; + } + + args.guard.attempted = true; + const rejectedScope = args.parsed._reasoningReplayScope + ? { + clientThreadId: args.parsed._reasoningReplayScope.clientThreadId, + ...(args.parsed._reasoningReplayScope.current + ? { current: { ...args.parsed._reasoningReplayScope.current } } + : {}), + } + : undefined; + prepareOpaqueBlobRecovery(args.parsed); + try { void args.response.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuild("opaque-blob-rejection"); + if (!("failed" in result) && result.ok) { + rememberReasoningReplayOpaqueBlobRejection(rejectedScope); + } + return "failed" in result + ? { kind: "failed", response: result.failed } + : { kind: "recovered", response: result }; +} + +function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined { + return typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0 + ? provider.apiKey + : undefined; +} + +function isFixedCodexAccount(authCtx: CodexAuthContext): boolean { + return (authCtx.kind === "pool" || authCtx.kind === "main-pool") + && authCtx.fixedAccount === true; +} + +export function usesCodexForwardPoolAuth( + authCtx: CodexAuthContext, + provider: OcxProviderConfig, +): authCtx is Extract { + return (authCtx.kind === "pool" || authCtx.kind === "main-pool") + && provider.authMode === "forward" && provider.adapter === "openai-responses"; +} + +function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig): CodexWsQuotaObserver | undefined { + if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined; + const { accountId, writerGeneration } = authCtx; + const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; + return headers => applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter); +} + +export function preAuthUpstreamHostCircuitKey( + route: Pick, + config: OcxConfig, + options: { requireResponsesAdapter?: boolean } = {}, +): string | null { + if ( + normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) === 0 + || route.codexAccountMode !== "pool" + || route.codexAccountId !== undefined + || route.provider.authMode !== "forward" + || (options.requireResponsesAdapter !== false && route.provider.adapter !== "openai-responses") + ) return null; + return upstreamHostHealthKey(route.providerName, safeOriginLabel(route.provider.baseUrl ?? "")); +} + +export function upstreamHostCircuitOpenResponse(retryAfterSeconds: number): Response { + return formatErrorResponse( + 503, + "upstream_host_circuit_open", + "Provider host is temporarily unavailable", + { retryAfter: String(retryAfterSeconds) }, + ); +} + +function normalizeCodexUnsupportedModelDetail(value: string): string { + return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US"); +} + +function isAllowListedCodexAccountModel400( + status: number, + bodyText: string, + modelId: string, +): boolean { + if (status !== 400) return false; + try { + const payload = JSON.parse(bodyText) as unknown; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const detail = (payload as { detail?: unknown }).detail; + if (typeof detail !== "string") return false; + const expected = `The '${modelId}' model is not supported when using Codex with a ChatGPT account.`; + return normalizeCodexUnsupportedModelDetail(detail) + === normalizeCodexUnsupportedModelDetail(expected); + } catch { + return false; + } +} + +async function shouldRetryCodexPoolAccountModel400( + response: Response, + modelId: string, + signal?: AbortSignal, +): Promise { + if (response.status !== 400) return false; + try { + const body = await readBoundedResponseBody(response.clone(), { signal }); + return body.displaySafe + && !body.truncated + && isAllowListedCodexAccountModel400(response.status, body.text, modelId); + } catch { + return false; + } +} + +/** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */ +function codexQuotaFailureMessage(body: string): string | undefined { + try { + const payload = JSON.parse(body) as unknown; + const canonical = upstreamErrorMessageFromPayload(payload); + if (canonical !== undefined) return canonical; + if (typeof payload === "string") return payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const record = payload as Record; + if (typeof record.message === "string") return record.message; + return typeof record.error === "string" ? record.error : undefined; + } catch { + // Plain-text gateways remain supported. Valid JSON is inspected only at recognized + // message fields so echoed request content elsewhere cannot trigger account cooldown. + return body; + } +} + +export async function shouldRetryCodexPoolAccountQuota( + response: Response, + signal?: AbortSignal, +): Promise { + if (response.status === 402 || response.status === 429) return true; + if (response.status < 500 || response.status >= 600) return false; + try { + // Reject malformed UTF-8 instead of matching quota words around replacement characters. + const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true }); + const message = body.displaySafe && !body.truncated + ? codexQuotaFailureMessage(body.text) + : undefined; + return message !== undefined + && isRateLimitOrQuotaFailureMessage(message); + } catch { + return false; + } +} + +interface CodexPoolAccountRetryArgs { + req: Request; + config: OcxConfig; + route: { providerName: string; modelId: string; provider: OcxProviderConfig }; + parsed: OcxParsedRequest; + logCtx: RequestLogContext; + options: { + admission?: DataPlaneAdmission; + codexAuthPolicy?: CodexAuthPolicyConfig; + visionDescribeTerminal?: boolean; + abortSignal?: AbortSignal; + onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void; + deferCodexResetDerivedCooldown?: boolean; + // Narrowed subset of HandleResponsesOptions: the retry rebuilds the adapter, so it + // needs the inbound scope or the retry could land on a different wire than the + // first attempt. + inboundWire?: InboundWire; + codexWsRuntimeIdentity?: BunRuntimeGateInput; + translatorBudget: TranslatorBudget; + turnAdmissionLease?: AdmissionLease; + resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; + }; + firstAuthCtx: Extract; + firstResponse: Response; + outcomeStatus: number; + /** + * Forbid resolving a DIFFERENT account for this retry. + * + * Set when a stored Pool 401 already spent this logical request's account budget on its own + * refresh and replay. The same-account gated-model retry above stays available, because it + * sends to the account that was already paying; only the alternate-account resolution below is + * out of budget. + */ + sameAccountOnly?: boolean; + upstream: AbortController; + connectMs: number; + passthroughEstimate?: number; + stream: boolean; + onResponse?: ( + response: Response, + authCtx: CodexAuthContext, + request: Awaited["buildRequest"]>>, + ) => void; +} + +type CodexPoolAccountRetryResult = + | { + kind: "retried"; + authCtx: CodexAuthContext; + request: Awaited["buildRequest"]>>; + upstreamResponse: Response; + selectedForwardHeaders: Headers; + } + | { kind: "no-alternate" } + | { + kind: "transport"; + error: unknown; + authCtx: CodexAuthContext; + }; + +/** Keep retry-stage entitlement snapshots inside the native-main selection fence. */ +async function resolveCodexRetryModelEntitlements( + config: OcxConfig, + resolver: typeof resolveCodexModelEntitlements, + turnAdmissionLease?: AdmissionLease, +): Promise>> { + // The initial auth selection has already released its admission before the first + // response arrives. Re-enter for every refresh so profile switching cannot overlap + // credential discovery, and omit main entirely when a drain or recovery owns it. + const selectionAdmission = codexAccountSelectionForTurn(turnAdmissionLease)?.(); + const nativeMainReadsForbidden = isNativeMainTrafficBlocked() + || selectionAdmission?.mainProfileDraining === true; + try { + return await resolver(config, { + excludeAccountIds: nativeMainReadsForbidden + ? new Set([MAIN_CODEX_ACCOUNT_ID]) + : undefined, + }); + } finally { + selectionAdmission?.release(); + } +} + +const CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS: ReadonlyMap = new Map([ + // The authenticated catalog currently advertises Daybreak Blue, while successful responses + // identify the serving model as gpt-5.6-sol. Sending the selector itself is shard-dependent: + // live traffic can receive the exact unsupported-model 400 repeatedly from the same entitled + // account. Keep Daybreak as the admission/catalog identity, but use the stable serving id on + // the credential-bearing wire after entitlement selection has completed. + ["gpt-daybreak-blue-latest", "gpt-5.6-sol"], +]); + +export function codexAccountGatedCanonicalWireModel(modelId: string): string | undefined { + const exact = CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS.get(modelId); + if (exact) return exact; + for (const [selector, wireModel] of CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS) { + if (slugsEquivalent(modelId, selector)) return wireModel; + } + return undefined; +} + +function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult, logCtx?: RequestLogContext): void { + if (!isCanonicalOpenAiForwardProvider(route.provider)) return; + const wireModel = codexAccountGatedCanonicalWireModel(route.modelId); + if (!wireModel) return; + + if (logCtx) { + logCtx.preserveResolvedModelFromRoute = true; + delete logCtx.resolvedModel; + } + parsed.modelId = wireModel; + if (!parsed._rawBody || typeof parsed._rawBody !== "object") return; + const raw = parsed._rawBody as Record; + raw.model = wireModel; + // Daybreak's authenticated catalog does not advertise retention support, and the upstream + // rejects this optional Codex hint before model execution. Removing it preserves request + // semantics while avoiding an otherwise terminal pre-stream 400. + delete raw.prompt_cache_retention; +} + +/** + * Workspace-denial evidence for a 403, read from the upstream body. + * + * #1789: a valid K12 credential gets 403 `codex_workspace_access_denied` on a routed prompt. + * Without this the account is quarantined for reauthentication, which cannot fix a workspace + * grant and loops forever. Fails closed: an unreadable body keeps the historical handling. + */ +async function codexDenialOutcomeMeta(response: Response): Promise<{ denial?: "workspace" | "entitlement" }> { + if (response.status !== 403) return {}; + const { classifyCodexPreStreamRejection } = await import("../../codex/quota-rejection"); + const rejection = await classifyCodexPreStreamRejection(response); + return rejection.denial ? { denial: rejection.denial } : {}; +} + +function codexQuotaOutcomeMeta(response: Response): { + retryAfter: string | null; + resetAt: string[]; +} { + return { + retryAfter: response.headers.get("retry-after"), + resetAt: [ + response.headers.get("x-codex-primary-reset-at"), + response.headers.get("x-codex-secondary-reset-at"), + response.headers.get("x-codex-tertiary-reset-at"), + ].filter((value): value is string => !!value), + }; +} + +/** + * A reset timestamp describes a quota window, not an explicit instruction to + * stop using the whole account. A combo may therefore try a later model in the + * same request, while Retry-After and headerless quota failures remain blocking. + */ +function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean { + return enabled === true + && (response.status === 429 || response.status === 402) + && computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived"; +} + +/** + * One bounded alternate-account retry for Codex pool auth. Used for allow-listed + * model-400 and for pre-stream 429/402 quota failures (#584). + */ +async function retryCodexPoolOnAlternateAccount( + args: CodexPoolAccountRetryArgs, +): Promise { + const { + req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, + outcomeStatus, upstream, connectMs, passthroughEstimate, stream, + } = args; + const inboundWire = options.inboundWire ?? "responses"; + const entitlementResolver = options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements; + let retryAuthCtx: CodexAuthContext | undefined; + if (outcomeStatus === 400 && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)) { + invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId); + let refreshed; + try { + refreshed = await resolveCodexRetryModelEntitlements( + config, + entitlementResolver, + options.turnAdmissionLease, + ); + } catch (error) { + await firstResponse.body?.cancel().catch(() => undefined); + releaseCodexAuthContextProbeLease(firstAuthCtx); + throw error; + } + if (entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(firstAuthCtx.accountId)) { + // The authenticated roster still grants this exact model. Retry on the same account: + // upstream shards can briefly disagree during a gated-model rollout, but a pre-stream 400 + // proves no output was committed and keeps this replay bounded. + retryAuthCtx = firstAuthCtx; + } + } + // Exact account selectors may retry the same confirmed account above, but must never resolve + // an alternate. Quota failures and a refreshed entitlement miss remain terminal. + if (!retryAuthCtx && (firstAuthCtx.fixedAccount || args.sameAccountOnly === true)) { + return { kind: "no-alternate" }; + } + try { + retryAuthCtx ??= await resolveCodexAuthContext( + req.headers, + config, + "pool", + { + excludeAccountId: firstAuthCtx.accountId, + admission: options.admission, + codexAuthPolicy: options.codexAuthPolicy, + modelId: route.modelId, + requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), + beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + resolveCodexModelEntitlements: entitlementResolver, + }, + ); + } catch (error) { + const unexpectedRetryError = + !(error instanceof CodexPoolAuthenticationError) + && !(error instanceof CodexAuthContextError) + && !(error instanceof CodexAccountCooldownError) + && !(error instanceof CodexMainProfileDrainingError); + if (unexpectedRetryError) { + await firstResponse.body?.cancel().catch(() => undefined); + releaseCodexAuthContextProbeLease(firstAuthCtx); + throw error; + } + } + // A validated request-owned main bearer is a real alternate when the failed credential was a + // stored Pool account. It has no Pool account id to promote or cool, but it can own this one + // bounded replay. The resolver already refuses it when main itself is the excluded credential. + if ( + retryAuthCtx?.kind !== "pool" + && retryAuthCtx?.kind !== "main-pool" + && retryAuthCtx?.kind !== "main" + ) { + // A body-confirmed quota response may arrive under HTTP 5xx. Without an alternate, + // the ordinary terminal recorder sees only that wire status and would misclassify it + // as transient, leaving the exhausted account immediately selectable next turn. + if (outcomeStatus !== firstResponse.status && (outcomeStatus === 429 || outcomeStatus === 402)) { + recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, { + ...codexQuotaOutcomeMeta(firstResponse), + threadId: firstAuthCtx.affinityKey, + modelId: route.modelId, + probeLeaseId: codexProbeLeaseId(firstAuthCtx), + probeQuotaScope: codexProbeQuotaScope(firstAuthCtx), + writerGeneration: firstAuthCtx.writerGeneration, + }); + } + return { kind: "no-alternate" }; + } + + const quotaMeta = { ...codexQuotaOutcomeMeta(firstResponse), ...(await codexDenialOutcomeMeta(firstResponse)) }; + if (outcomeStatus === 429 || outcomeStatus === 402) { + const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api"); + applyAccountQuotaFromUpstreamHeaders( + firstAuthCtx.accountId, + firstResponse.headers, + firstAuthCtx.writerGeneration, + firstAuthCtx.kind === "main-pool" ? firstAuthCtx.mainQuotaWriter : undefined, + ); + } + const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown( + firstResponse, + options.deferCodexResetDerivedCooldown, + ); + const recordFirstOutcome = (): void => { + recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, { + ...quotaMeta, + threadId: firstAuthCtx.affinityKey, + modelId: route.modelId, + probeLeaseId: codexProbeLeaseId(firstAuthCtx), + probeQuotaScope: codexProbeQuotaScope(firstAuthCtx), + writerGeneration: firstAuthCtx.writerGeneration, + // Retry already advanced the RR ring via excludeAccountId — reuse for promotion. + ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}), + }); + }; + // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and + // ordinary requests must block the first account before the alternate send. + if (!deferFirstOutcome) recordFirstOutcome(); + const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission); + const retryProvider = applyCodexAuthContextToProvider( + stripCodexRuntimeProviderFields(route.provider), + retryAuthCtx, + "pool", + ); + const retryAdapter = resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: retryProvider, + adapterName: retryAdapter.name, + codexAuthContext: retryAuthCtx, + forwardHeaders: retryHeaders, + }); + const request = await retryAdapter.buildRequest(parsed, { + headers: retryHeaders, + translatorBudget: options.translatorBudget, + }); + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + + await firstResponse.body?.cancel().catch(() => undefined); + options.onCodexAuthContextResolved?.(retryAuthCtx); + route.provider = retryProvider; + logCtx.provider = formatCodexProviderForLog( + route.providerName, + retryAuthCtx.accountId, + config, + ); + logCtx.accountLogLabel = codexAuthContextLogLabel(retryAuthCtx, config); + sealRequestAttemptIdentity( + logCtx.activeAttempt, + logCtx.provider, + retryAdapter.name, + logCtx.accountLogLabel, + ); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name); + + const retrySameConfirmedAccount = outcomeStatus === 400 + && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId) + && retryAuthCtx.accountId === firstAuthCtx.accountId; + // Live Daybreak traffic has produced long runs of unsupported-model 400s from different + // upstream shards even while the authenticated roster continues to grant the model. Permit + // seven additional same-account sends (eight total including the original), re-checking the + // exact allow-listed body and fresh entitlement before every later send. Alternate-account and + // quota recovery retain their historical one-send bound. + const maxRetrySends = retrySameConfirmedAccount ? 7 : 1; + let retrySendCount = 0; + let upstreamResponse: Response; + try { + while (true) { + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate); + try { + upstreamResponse = await fetchWithHeaderTimeout( + request.url, + { + method: request.method, + headers: request.headers, + body: request.body, + }, + upstream.signal, + connectMs, + stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + // Credential-bearing forward send: never follow a redirect into a + // dead-host rejection after the credential was seen (#914). + route.provider.authMode === "forward", + ); + } catch (error) { + // Only the forward send is a transport boundary. Entitlement resolver throws below are + // deliberately outside this catch so programming errors retain their original path. + return { kind: "transport", error, authCtx: retryAuthCtx }; + } + retrySendCount += 1; + args.onResponse?.(upstreamResponse, retryAuthCtx, request); + if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break; + // Caller-owned main is an alternate-account replay and can never enter the bounded + // same-stored-account 400 loop above. Keep that invariant explicit for the account-id reads. + if (retryAuthCtx.kind === "main") break; + if (!await shouldRetryCodexPoolAccountModel400( + upstreamResponse, + route.modelId, + options.abortSignal, + )) break; + invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId); + let refreshed: Awaited>; + try { + refreshed = await resolveCodexRetryModelEntitlements( + config, + entitlementResolver, + options.turnAdmissionLease, + ); + } catch (error) { + await upstreamResponse.body?.cancel().catch(() => undefined); + await firstResponse.body?.cancel().catch(() => undefined); + releaseCodexAuthContextProbeLease(firstAuthCtx); + releaseCodexAuthContextProbeLease(retryAuthCtx); + throw error; + } + if (!entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(retryAuthCtx.accountId)) break; + await upstreamResponse.body?.cancel().catch(() => undefined); + } + } finally { + request.releaseBodyObservation?.(); + } + // A real HTTP response proves the host was reached (#914). + const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url)); + if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) { + resetUpstreamHostHealth(retryHostKey, null); + } else { + resetUpstreamHostHealth(retryHostKey); + } + if (deferFirstOutcome && upstreamResponse.ok) { + // Deferral keeps the first account eligible for a later combo model while an + // alternate attempt is still fallible. Commit its quota outcome only once the + // alternate account returns a successful HTTP response; otherwise the combo may + // still need the first account for its next target. + recordFirstOutcome(); + } + return { + kind: "retried", + authCtx: retryAuthCtx, + request, + upstreamResponse, + selectedForwardHeaders: retryHeaders, + }; +} + + + +export function codexForwardTerminalOutcomeRecorder( + config: OcxConfig, + authCtx: CodexAuthContext, + provider: OcxProviderConfig, + modelId?: string, + logCtx?: RequestLogContext, +): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined { + if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined; + return (status, httpStatusOverride) => { + const quotaStatus = [httpStatusOverride, logCtx?.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (status === "incomplete" && quotaStatus === undefined) { + // Normal limit/content-filter/stall terminal — the account served the + // request. Don't penalize account health; record success to clear any + // prior soft-avoid so a healthy account isn't stuck avoided. + recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { + threadId: authCtx.affinityKey, + fixedAccount: authCtx.fixedAccount, + modelId, + probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), + writerGeneration: authCtx.writerGeneration, + }); + return; + } + // status === "completed" or "failed": use the semantic HTTP status derived + // from the terminal SSE error payload (httpStatusFromTerminalError in + // request-log inspection) instead of collapsing every non-completed terminal + // to 502. A 400 invalid_request_error must not soft-avoid the account or + // rebind threads — only genuine transport/5xx failures should trigger + // transient health recording. + // httpStatusOverride: the combo WS path inspects SSE payloads into the parent + // logCtx, but this recorder closes over the child logCtx. The caller passes + // the parent's terminalHttpStatus so the semantic status is not lost. + const outcome = status === "completed" + ? 200 + : (quotaStatus ?? httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); + recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + threadId: authCtx.affinityKey, + fixedAccount: authCtx.fixedAccount, + modelId, + probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), + writerGeneration: authCtx.writerGeneration, + // A mid-stream terminal can carry a semantic 401 long after the credential was + // replaced. It is never replayed — the client already saw output — but it must + // not retire the replacement either (#2887). + ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), + }); + }; +} + + + +export function decodeRequestErrorResponse(err: unknown, label: string): Response { + if (isTranslatorBudgetExceededError(err)) { + return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { + code: "translation_buffer_limit", + }); + } + if (err instanceof UnsupportedContentEncodingError) { + return formatErrorResponse(415, "invalid_request_error", err.message); + } + if (err instanceof DecompressedBodyTooLargeError) { + return formatErrorResponse(413, "invalid_request_error", err.message); + } + console.warn(`[${label}] request body decode/parse failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`); + return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body"); +} + + + +export function comboUnavailableResponse( + message: string, + options?: { retryAfter?: string | null }, +): Response { + const headers = new Headers({ "Content-Type": "application/json" }); + const retryAfter = options?.retryAfter?.trim(); + if (retryAfter && retryAfter.length > 0 && retryAfter.length <= 128) { + headers.set("Retry-After", retryAfter); + } + return new Response( + JSON.stringify({ + error: { message, type: "server_error", code: "combo_unavailable" }, + }), + { status: 503, headers }, + ); +} + +function comboUnavailable(comboId: string, now = Date.now()): Response { + return comboUnavailableResponse(`No available targets for combo: ${comboId}`, { + retryAfter: comboCooldownRetryAfterSeconds(comboId, now), + }); +} + + + +export interface ConsumedComboFailure { + response: Response; + classificationText: string; + /** Structured upstream `error.code` when present in the failure body. */ + upstreamCode?: string; + /** Valid numeric/date value used only for cooldown calculation. */ + retryAfter?: string; + /** Upstream Codex quota-window reset timestamps used for combo cooldowns. */ + resetAt?: string[]; + /** Reserved for 040 usage attribution without adding another body read. */ + usage?: OcxUsage; +} + + + +export interface HandleResponsesOptions { + /** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */ + codexAuthPolicy?: CodexAuthPolicyConfig; + turnAdmissionLease?: AdmissionLease; + /** + * How the caller proved data-plane admission (#1686). + * + * A bearer-presented admission secret is one of OUR OWN secrets, so a Direct turn must + * SUBSTITUTE the stored main credential rather than forward it. Without this fact at the + * decision point, Direct cannot tell an admission bearer from the user own ChatGPT bearer, + * which is why it refused the whole env_key flow instead of serving it. + */ + admission?: DataPlaneAdmission; + /** Called at most once after the complete client body is read and accepted for dispatch. */ + onRequestBodyRead?: () => void; + forceEmptyResponseId?: boolean; + abortSignal?: AbortSignal; + /** One-shot TTFT callback: first non-empty model output observed (WP4). */ + onFirstOutput?: () => void; + onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void; + /** Internal deterministic seam for account-gated native fallback tests. */ + resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; + recordTerminalOutcomes?: boolean; + setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void; + onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void; + onNativePassthroughCancel?: () => void; + /** Internal deterministic clock/timer seam for provider terminal repair. */ + responsesTerminalRepairScheduler?: ResponsesTerminalRepairScheduler; + /** Internal deterministic runtime-identity seam for Codex upstream WS selection tests. */ + codexWsRuntimeIdentity?: BunRuntimeGateInput; + /** Test seam for native main refresh without live OAuth traffic. */ + nativeMainRefreshDependencies?: NativeMainRefreshDependencies; + /** + * When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort + * (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity. + */ + promptCacheKeyIsSharedCohort?: boolean; + /** + * Wire protocol the ORIGINAL client spoke. The Chat and Anthropic surfaces translate + * their body into a Responses shape and replay through this function, so without an + * explicit value the replay would look like a native Responses request and an + * inbound-scoped registry wire default would fire for a client that never asked for + * it. Omitted means a genuine Responses inbound. + */ + inboundWire?: InboundWire; + /** Internal transport identity for route-scoped upstream compatibility policy. */ + inboundTransport?: "websocket"; + /** + * Claude replay may add native-main auth so OpenAI sidecars remain available. + * Strip only that internal credential when the final route is a noncanonical + * forward destination; final routing can differ from Claude's preflight route. + */ + stripClaudeMainAuthForNoncanonicalForward?: boolean; + /** Internal recursion guard; callers outside this module must not set it. */ + comboAttempt?: boolean; + /** Internal combo handoff for one parent-validated continuation snapshot. */ + comboReplaySnapshot?: { + sourceBody: unknown; + previousResponseInputExpanded: boolean; + providerContinuation: OcxProviderContinuationState | undefined; + recoveredPlaintext: boolean; + }; + /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */ + deferCodexResetDerivedCooldown?: boolean; + /** 030-owned handoff when a child consumed the original failure under bounds. */ + onConsumedComboFailure?: (failure: ConsumedComboFailure) => void; + /** A stored Pool credential was refreshed and its one allowed same-account replay was sent. */ + onStoredPool401ReplayDispatched?: () => void; + /** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */ + translatorBudget?: TranslatorBudget; + /** + * Terminal vision-describe marker (roadmap 180): true when the inbound + * request IS the vision sidecar's own loopback describe call. The plan site + * then STRIPS images instead of planning another describe — a depth cap of 1 + * that holds under predicate drift and combo re-resolution. The Chat surface + * detects the raw `x-opencodex-vision-describe` header before its bridge + * rebuilds headers and carries the fact through this flag. + */ + visionDescribeTerminal?: boolean; +} + + + +/** + * Build the 499 JSON error the proxy returns when the client disconnects before the + * response completes (`client_cancelled`). + */ +export function clientCancelledResponse(): Response { + return formatErrorResponse(499, "client_cancelled", "Client cancelled request"); +} + + + +export function sanitizedRetryAfter(value: string | null, now: number): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || trimmed.length > 128) return undefined; + return parseRetryAfterMs(trimmed, now) !== undefined ? trimmed : undefined; +} + + + +export async function consumeComboFailure( + response: Response, + signal?: AbortSignal, + now = Date.now(), +): Promise { + const fallback = `Provider error ${response.status}`; + let classificationText = fallback; + let usage: OcxUsage | undefined; + let upstreamCode: string | undefined; + let upstreamMessage: string | undefined; + let upstreamType: string | undefined; + // Whether the body itself confirms a quota/rate-limit refusal, computed on the SAME read as + // the classification below. `shouldRetryCodexPoolAccountQuota` cannot be called here without + // a second body read, so this mirrors its normalization: raw 402/429, or a 5xx whose intact, + // display-safe body carries a recognized quota message. + let quotaConfirmedByBody = false; + try { + const body = await readBoundedResponseBody(response, { + signal, + // Match shouldRetryCodexPoolAccountQuota before treating a 5xx body as quota evidence. + fatalUtf8: response.status >= 500 && response.status < 600, + }); + usage = usageFromComboFailureText(body.text); + if ( + response.status >= 500 && response.status < 600 + && body.displaySafe && !body.truncated + ) { + const quotaMessage = codexQuotaFailureMessage(body.text); + quotaConfirmedByBody = quotaMessage !== undefined + && isRateLimitOrQuotaFailureMessage(quotaMessage); + } + if (body.displaySafe) { + const normalized = normalizeUpstreamErrorText(body.text, fallback); + classificationText = normalized.safeText; + upstreamCode = normalized.code; + upstreamMessage = normalized.message; + upstreamType = normalized.type; + } + } catch (error) { + if (signal?.aborted) throw error; + classificationText = fallback; + } + const cyberFailure = isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(classificationText); + const normalizedUpstreamCode = cyberFailure ? CYBER_POLICY_ERROR_CODE : upstreamCode; + const message = cyberFailure + ? upstreamMessage + ?? (isCyberPolicyCode(upstreamCode) ? CYBER_POLICY_FALLBACK_MESSAGE : classificationText) + : classificationText === fallback + ? fallback + : `${fallback}: ${classificationText}`; + const upstreamRetryAfter = response.headers.get("retry-after"); + // Past HTTP dates are an immediate retry directive, just like the numeric value zero. + // Normalize before the client helper discards them and substitutes a default delay. + const effectiveRetryAfter = parseRetryAfterMs(upstreamRetryAfter, now) === undefined + && parseRetryAfterMs(upstreamRetryAfter, now, { preserveImmediate: true }) !== undefined + ? "0" + : upstreamRetryAfter; + // Client response may get the synthetic "2" fallback; cooldown metadata must not — + // otherwise coolComboTarget treats it as a 2s cooldown instead of the 60s default. + const clientRetryAfter = resolveClientRetryAfter({ + status: response.status, + message, + upstreamRetryAfter: effectiveRetryAfter, + now, + }); + const cooldownRetryAfter = resolveClientRetryAfter({ + status: response.status, + message, + upstreamRetryAfter: effectiveRetryAfter, + now, + includeDefault: false, + }); + return { + response: formatErrorResponse( + response.status, + cyberFailure ? (upstreamType ?? CYBER_POLICY_ERROR_CODE) : "upstream_error", + message, + { + ...(normalizedUpstreamCode !== undefined ? { code: normalizedUpstreamCode } : {}), + ...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}), + }, + ), + classificationText, + ...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}), + ...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}), + // The EFFECTIVE classification decides, not the raw status. An upstream that wraps a quota + // refusal in a 5xx still carries `x-codex-*-reset-at`, and gating on 402/429 alone threw + // those away, so the combo target came back up immediately instead of waiting for the + // window it was told about. `cyberFailure` stays excluded: a policy block is not a quota. + ...(!cyberFailure + && (response.status === 429 || response.status === 402 || quotaConfirmedByBody) + ? { resetAt: codexQuotaOutcomeMeta(response).resetAt } + : {}), + ...(usage ? { usage } : {}), + }; +} + + + +export function usageFromComboFailureText(text: string): OcxUsage | undefined { + try { + const payload = JSON.parse(text) as Record; + const nested = payload.response; + const source = nested && typeof nested === "object" && !Array.isArray(nested) + ? nested as Record + : payload; + return usageFromResponsesPayload(source.usage); + } catch { + return undefined; + } +} + + + +export function createChildPassthroughCallbackGate(options: HandleResponsesOptions) { + type Pending = + | { kind: "terminal"; status: ResponsesTerminalStatus } + | { kind: "cancel" }; + let state: "pending" | "committed" | "discarded" = "pending"; + let pending: Pending | undefined; + let accepted = false; + const publish = (value: Pending): void => { + if (value.kind === "terminal") options.onNativePassthroughTerminal?.(value.status); + else options.onNativePassthroughCancel?.(); + }; + const receive = (value: Pending): void => { + if (state === "discarded" || accepted) return; + accepted = true; + if (state === "committed") return publish(value); + pending ??= value; + }; + return { + onTerminal: (status: ResponsesTerminalStatus) => receive({ kind: "terminal", status }), + onCancel: () => receive({ kind: "cancel" }), + commit: () => { + if (state !== "pending") return; + state = "committed"; + if (pending) publish(pending); + pending = undefined; + }, + discard: () => { + state = "discarded"; + pending = undefined; + }, + }; +} + + + +export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers { + const childHeaders = new Headers(parentHeaders); + // Combo children re-serialize already-decoded JSON. Keeping transport metadata from + // the parent would make the child decoder treat plain JSON as compressed bytes. + childHeaders.delete("content-length"); + childHeaders.delete("content-encoding"); + return childHeaders; +} + +const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE = + "Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model."; + +// Whole-body policy for non-streaming upstream JSON responses (see the application/json +// branch of the passthrough return path). 32 MiB matches the continuation snapshot read +// bound and is far above any legitimate non-streaming completion, including base64 image +// payloads. The stall deadlines only govern the body transfer — generation time before +// the response headers is untouched. Generation after early/chunked headers but before +// the first body byte previously used the 30-second inactivity deadline; this call site +// gives it the full body deadline instead. +const MAX_UPSTREAM_JSON_BODY_BYTES = 32 * 1024 * 1024; +const UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS = 180_000; +const UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS = 30_000; +const MAX_FAST_WIRE_CAPABILITY_WARNINGS = 256; +const warnedFastWireCapabilityGaps = new Set(); + +function warnFastWireCapabilityGap(providerName: string, modelId: string): void { + const safeProvider = sanitizeLogMetadataString(providerName) ?? "unknown"; + const safeModel = sanitizeLogMetadataString(modelId) ?? "unknown"; + const key = `${safeProvider}\0${safeModel}`; + if (warnedFastWireCapabilityGaps.has(key)) return; + if (warnedFastWireCapabilityGaps.size >= MAX_FAST_WIRE_CAPABILITY_WARNINGS) { + const oldest = warnedFastWireCapabilityGaps.values().next().value; + if (oldest !== undefined) warnedFastWireCapabilityGaps.delete(oldest); + } + warnedFastWireCapabilityGaps.add(key); + console.warn( + `[opencodex] Fast policy for ${safeProvider}/${safeModel} has service-tier capability but no Fast wire; preserving only caller-permitted tier behavior`, + ); +} +export const UPSTREAM_JSON_BODY_READ_OPTIONS = { + maxBytes: MAX_UPSTREAM_JSON_BODY_BYTES, + totalTimeoutMs: UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS, + inactivityTimeoutMs: UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS, + firstByteTimeoutMs: UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS, +}; + +function unreadableEncryptedAgentTaskResponse(reason?: AgentTaskRecoveryFailureReason): Response { + return new Response( + JSON.stringify({ + error: { + message: UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE, + type: "invalid_request_error", + code: "unreadable_encrypted_agent_task", + ...(reason === undefined ? {} : { recovery_reason: reason }), + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); +} + +/** + * Keep this trust boundary deliberately narrow: only a key-auth Responses route may consume + * opaque child-task ciphertext, and the model's final wire override must still be Responses. + * Callers keep combo attempts on their existing native-only recovery/fail-closed behavior. + */ +function canPassThroughEncryptedV2AgentTask( + route: RouteResult, + inboundWire: InboundWire, +): boolean { + if (route.combo !== undefined) return false; + const provider = route.provider; + if ( + inboundWire !== "responses" + || provider.allowEncryptedV2AgentTasks !== true + || (provider.authMode ?? "key") !== "key" + ) return false; + + return resolveWireProtocolOverride( + route.providerName, + route.modelId, + provider, + inboundWire, + ).adapter === "openai-responses"; +} + +type ResponsesAuthResolution = + | { ok: true; authCtx: CodexAuthContext; headers: Headers; substituteMainCredential: boolean } + | { ok: false; response: Response }; + +/** + * Resolve Codex auth for a route. On unusable contexts, releases any probe lease + * before returning the 401 (nothing reaches upstream). + */ +async function resolveResponsesCodexAuth( + req: Request, + config: OcxConfig, + route: RouteResult, + options: HandleResponsesOptions, +): Promise { + try { + // #1686: a caller that proved admission with a BEARER presented one of our own secrets. + // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct. + // Admitting it is only safe because the stored main credential is substituted below, so + // the admission secret still never leaves this process. + // + // #2132: substitution answers "does THIS ROUTE need our stored ChatGPT credential", not + // "how did the caller authenticate". Only a native Codex route reaches the ChatGPT backend + // and can consume that credential; a key-authenticated routed provider carries its own and + // never touches it. Keying on the caller alone made an install that deliberately never + // logged into ChatGPT fail every routed request with "No usable Codex main credential". + // + // But ask that question the way the ADAPTER asks it. `codexAccountMode` is derived from the + // provider NAME (`providerCodexAccountMode`), while the passthrough adapter decides whether + // to forward caller credentials from the TRANSPORT — adapter, auth mode, and base URL + // (`isCanonicalOpenAiForwardProvider`). A row the operator named anything other than + // `openai`, pointed at the canonical ChatGPT backend with `authMode: "forward"`, satisfies + // the adapter's test and fails this one, so substitution was skipped and the adapter then + // forwarded our own admission secret upstream. Two predicates answering one question is the + // bug; the transport is the authority, because the transport is what actually carries the + // header. A key-authenticated routed provider is still not canonical-forward, so #2132's + // no-ChatGPT-login install keeps working. + const substituteMainCredential = options.admission?.source === "bearer" + && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider)); + const requestScopedMainCredential = route.codexAccountMode !== undefined + && !substituteMainCredential + && hasForwardableCodexBearer(req.headers, config); + if (route.codexAccountMode === "direct" && !substituteMainCredential) { + validateForwardAdmissionCredential(req.headers, config); + } + let authCtx: CodexAuthContext; + if (route.codexAccountMode) { + authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + admission: options.admission, + codexAuthPolicy: options.codexAuthPolicy, + accountId: route.codexAccountId, + modelId: route.modelId, + substituteMainCredentialForDirect: substituteMainCredential, + requestScopedMainCredential, + beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + resolveCodexModelEntitlements: options.resolveCodexModelEntitlements, + signal: options.abortSignal, + nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, + }); + options.onCodexAuthContextResolved?.(authCtx); + } else { + // A custom-named canonical-forward provider has no Codex account mode, but an + // admission bearer still substitutes the stored main credential below. Claim the + // same physical profile before synthesizing the main context so transport-based + // substitution cannot bypass a switch drain. + if ( + substituteMainCredential + && ( + isNativeMainTrafficBlocked() + || !tryClaimNativeMainProfileForTurn(options.turnAdmissionLease) + || isNativeMainTrafficBlocked() + ) + ) { + throw new CodexMainProfileDrainingError(); + } + authCtx = { kind: "main", accountId: null }; + options.onCodexAuthContextResolved?.(undefined); + } + // This resolver also builds a synthetic main context for unrelated keyed routes. Only + // the actual Codex-forward transport consumes main quota; provider names are not proof + // (custom-named canonical-forward providers must retain the same protection). + const mainPolicyConfig = isCanonicalOpenAiForwardProvider(route.provider) + ? options.codexAuthPolicy ?? config : undefined; + const headers = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { + admission: options.admission, + config: mainPolicyConfig, + modelId: route.modelId, + beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + substituteMainCredential, + signal: options.abortSignal, + nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, + }); + // Awaiting even a cached materialization yields. Preserve the policy error if the live + // quota/config changed during that yield, before usability could mislabel it as reauth. + headersForCodexAuthContext(headers, authCtx, mainPolicyConfig, route.modelId, options.admission); + if (!isCodexAuthContextUsable(authCtx, config)) { + releaseCodexAuthContextProbeLease(authCtx); + return { + ok: false, + response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), + }; + } + return { + ok: true, + authCtx, + headers, + substituteMainCredential, + }; + } catch (err) { + if (options.abortSignal?.aborted || req.signal.aborted) { + return { ok: false, response: clientCancelledResponse() }; + } + if (err instanceof CodexAuthContextError) { + const safeAccountLabel = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : formatCodexProviderForLog(route.providerName, err.accountId, config); + console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`); + } + if (err instanceof ForwardAdmissionCredentialError) { + return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) }; + } + const response = mapCodexAuthContextErrorToResponse(err, { + accountSelector: route.codexAccountNamespace, + now: Date.now(), + }); + if (response) return { ok: false, response }; + throw err; + } +} + +/** + * Terminal means the grant itself is dead and no retry can help. Everything else — + * an untyped network failure, a token-endpoint 5xx surfacing as `unknown`, an abort, + * refresh capacity, lock contention, a superseded flight — is transient, and treating + * it as terminal would quarantine a healthy account on an upstream blip, which is the + * defect this path exists to fix (#2887). + */ +function isTerminalPoolRefreshFailure(error: unknown): boolean { + return error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired"); +} + +/** + * One forced refresh and one same-account rebuild for a stored pool credential that + * upstream rejected with a pre-stream 401. `quarantine` distinguishes a dead grant, + * which must retire the account, from a transient failure, which must not. + */ +async function refreshPoolForwardAuth(args: { + req: Request; + config: OcxConfig; + route: RouteResult; + authCtx: CodexAuthContext & { kind: "pool" }; + substituteMainCredential: boolean; + options: HandleResponsesOptions; +}): Promise< + | { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers } + | { ok: false; response: Response; quarantine: boolean; quarantineGeneration?: number } +> { + const { req, config, route, authCtx, substituteMainCredential, options } = args; + try { + const refreshed = await forceRefreshCodexPoolToken(authCtx.accountId, { + rejectedGeneration: authCtx.generation, + rejectedAccessToken: authCtx.accessToken, + signal: options.abortSignal, + }); + if (!refreshed.rotated) { + // The store resolved to the same bearer upstream just rejected. Replaying it + // would spend another upstream call to earn the identical 401. Upstream can do + // this on a SUCCESSFUL response by rotating only the refresh grant, so the + // credential generation may already have moved — quarantine has to be fenced on + // where the credential actually is, not on the generation we started from. + return { + ok: false, + quarantine: true, + quarantineGeneration: refreshed.generation, + response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), + }; + } + // Only a CAS this request performed itself proves the new credential descends from + // the rejected one. Somebody else's replacement may be a different identity, and + // its affinity must be retired rather than inherited. + if (refreshed.selfRefreshed) { + handOffThreadAffinityGeneration(authCtx.accountId, authCtx.generation, refreshed.generation); + } + const refreshedAuthCtx: CodexAuthContext = { + ...authCtx, + accessToken: refreshed.accessToken, + chatgptAccountId: refreshed.chatgptAccountId, + generation: refreshed.generation, + }; + const provider = applyCodexAuthContextToProvider( + stripCodexRuntimeProviderFields(route.provider), + refreshedAuthCtx, + route.codexAccountMode, + ); + const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + admission: options.admission, + config: options.codexAuthPolicy ?? config, + modelId: route.modelId, + substituteMainCredential, + signal: options.abortSignal, + nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, + }); + return { ok: true, authCtx: refreshedAuthCtx, provider, headers }; + } catch (error) { + if (isTerminalPoolRefreshFailure(error)) { + return { + ok: false, + quarantine: true, + response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), + }; + } + const response = formatErrorResponse( + 503, + "server_busy", + "Codex credential refresh did not complete; retry this request", + ); + const headers = new Headers(response.headers); + headers.set("Retry-After", "1"); + return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) }; + } +} + +async function refreshNativeMainForwardAuth(args: { + req: Request; + config: OcxConfig; + route: RouteResult; + authCtx: CodexAuthContext; + substituteMainCredential: boolean; + options: HandleResponsesOptions; +}): Promise< + | { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers } + | { ok: false; response: Response } +> { + const { req, config, route, authCtx, substituteMainCredential, options } = args; + if (authCtx.kind !== "main-pool") { + return { ok: false, response: formatErrorResponse(401, "authentication_error", "No native main credential to refresh") }; + } + try { + const refreshed = await forceRefreshMainAccountToken(authCtx.accessToken, { + signal: options.abortSignal, + ...(options.nativeMainRefreshDependencies ?? {}), + }); + if (!refreshed) { + return { ok: false, response: formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication") }; + } + const refreshedAuthCtx: CodexAuthContext = { + ...authCtx, + accessToken: refreshed.accessToken, + chatgptAccountId: refreshed.chatgptAccountId, + }; + const provider = applyCodexAuthContextToProvider( + stripCodexRuntimeProviderFields(route.provider), + refreshedAuthCtx, + route.codexAccountMode, + ); + const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + admission: options.admission, + config: options.codexAuthPolicy ?? config, + modelId: route.modelId, + substituteMainCredential, + signal: options.abortSignal, + nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, + }); + return { ok: true, authCtx: refreshedAuthCtx, provider, headers }; + } catch (error) { + if (options.abortSignal?.aborted || req.signal.aborted) { + return { ok: false, response: clientCancelledResponse() }; + } + return { ok: false, response: mapCodexAuthContextErrorToResponse(error, { + now: Date.now(), accountSelector: route.codexAccountNamespace, + }) ?? nativeMainRefreshFailureResponse(error) }; + } +} + +async function resolveSubagentFallbackModelEligibility(args: { + config: OcxConfig; + fallbackChain: readonly string[] | null; + nativeMainReadsForbidden: boolean; + resolver: typeof resolveCodexModelEntitlements; +}): Promise { + if (!subagentFallbackNeedsModelEntitlements(args.fallbackChain, args.config)) return undefined; + const excludeAccountIds = args.nativeMainReadsForbidden + ? new Set([MAIN_CODEX_ACCOUNT_ID]) + : undefined; + const snapshot = await args.resolver(args.config, { excludeAccountIds }); + return (modelId) => { + const entitledAccountIds = entitledCodexAccountIdsForModel(snapshot, modelId); + return entitledAccountIds + ? new Set([...entitledAccountIds].filter(accountId => !excludeAccountIds?.has(accountId))) + : undefined; + }; +} + +/** + * Apply every route-dependent request mutation against the final selected route. + * Must run only after subagent fallback has settled the model/provider. + */ +async function applyFinalRouteRequestNormalization(args: { + parsed: OcxParsedRequest; + route: RouteResult; + config: OcxConfig; + req: Request; + logCtx: RequestLogContext; + inboundWire: InboundWire; + inboundTransport?: "websocket"; +}): Promise { + const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args; + + // Only Anthropic message routes retain the Codex-facing selector. Other providers must keep + // their existing response.model contract even when their public and wire model ids differ. + const responseModelId = parsed.modelId; + const preserveAnthropicResponseModel = route.providerName === "anthropic" + || route.provider.adapter === "anthropic"; + + // Apply the routed model id upstream: routing may strip a "/" namespace. + if (route.modelId !== parsed.modelId) { + if (parsed._rawBody && typeof parsed._rawBody === "object") { + (parsed._rawBody as { model?: string }).model = route.modelId; + } + parsed.modelId = route.modelId; + } + // Transport-neutral reliability policy (#875): applies to any Responses + // upstream whose final adapter is openai-responses, not only WS turns. + const responsesUpstreamStreaming = providerModelResponsesUpstreamStreaming( + route.providerName, + route.provider, + route.modelId, + ); + + // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter + // this request will actually use (#404). + route.provider = resolveOpenCodeGoTransport(route.provider, sessionLaneIdFromRequest(req.headers)); + route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; + logCtx.model = route.modelId; + logCtx.provider = route.providerName; + logCtx.providerAdapter = route.provider.adapter; + logCtx.routeDecision = route.routeDecision; + if (route.routeReason === "model-alias" || route.modelId !== responseModelId && responseModelId.includes("/")) logCtx.requestedAlias = responseModelId; + + if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") { + parsed.stream = false; + if (parsed._rawBody && typeof parsed._rawBody === "object") { + (parsed._rawBody as Record).stream = false; + } + } + + // Generic Responses clients (e.g. AI-SDK apps) omit `store`, but the canonical + // forward Codex backend rejects a native request without an explicit store:false. + // Default it only there — every other Responses upstream (key-auth providers and + // custom forward gateways) intentionally keeps the omitted-store server-side + // default for previous_response_id reuse — and never override an explicit value. + if ( + isCanonicalOpenAiForwardProvider(route.provider) + && parsed._rawBody && typeof parsed._rawBody === "object" + && (parsed._rawBody as Record).store === undefined + ) { + (parsed._rawBody as Record).store = false; + } + + // Final selected model before virtual wire-model rewriting (Pro aliases). + const finalSelectedModelId = route.modelId; + + // Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro". + applyOpenAiVirtualModel(parsed, route, logCtx); + if (parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId) { + logCtx.resolvedModel = route.modelId; + logCtx.preserveResolvedModelFromRoute = true; + } + + // Resolve Fast policy after the final route/wire settles. A1 records the decision on parsed + // options; the Responses adapter owns the final outbound body write. + const fastPolicy = fastPolicyForModel( + route.provider, + route.modelId, + route.providerName, + inboundWire, + config.providers[route.providerName], + ); + const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy); + const callerTier = parsed.options.serviceTier; + // The ChatGPT-internal Codex backend echoes `service_tier: "default"` even on turns it + // scheduled as priority, so its echo cannot confirm OR deny Fast. Believing it reported every + // Fast request as `response-declined` (#2558). The public API's echo stays authoritative. + parsed.options.tierObservation = tierObservationContext( + fastPolicy, + config.fastMode, + callerTier, + isCanonicalOpenAiForwardProvider(route.provider) ? false : undefined, + ); + parsed.options.tierDecision = decideTier(fastPolicy, config.fastMode, callerTier); + parsed.options.serviceTier = tierValueAfterDecision(parsed.options.tierDecision, callerTier); + if (fastPolicy.capability === true && fastPolicy.fastWire === null) { + warnFastWireCapabilityGap(route.providerName, route.modelId); + } + applyServiceTierGate( + route.provider, + parsed._rawBody, + parsed.options, + route.modelId, + route.providerName, + inboundWire, + fastPolicy, + ); + if (modelServiceTierSupport === false) { + logCtx.requestedServiceTier = undefined; + logCtx.requestedSpeedLabel = undefined; + } + + { + const guidance = await multiAgentGuidanceText(parsed, { + multiAgentGuidanceEnabled: config.multiAgentGuidanceEnabled, + codexAccountNamespace: route.codexAccountNamespace, + injectionModel: config.injectionModel, + injectionEffort: config.injectionEffort, + subagentModels: config.subagentModels, + subagentModelFallback: config.subagentModelFallback, + injectionPrompt: config.injectionPrompt, + }); + if (guidance) { + injectDeveloperMessage(parsed, guidance); + if (isInjectionDebugEnabled()) { + injectionDebugLog(`[opencodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, guidanceEnabled=${multiAgentGuidanceEnabled(config)}, ${guidance.length} chars)`); + } + } else if (isInjectionDebugEnabled() && collabSurface(parsed) !== null) { + injectionDebugLog(`[opencodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`); + } + } + + { + const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); + const surface = collabSurface(parsed); + if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) { + const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route)); + if (capped) { + logCtx.requestedEffort = `${capped.from}->${capped.to}`; + if (isInjectionDebugEnabled()) { + injectionDebugLog(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`); + } + } + } else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) { + injectionDebugLog(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`); + } + } + + { + const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog"); + const clamped = shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId) + ? nativeEffortClamp(route.modelId, parsed.options.reasoning) + : null; + if (clamped) { + parsed.options.reasoning = clamped; + const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined; + if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = clamped; + logCtx.requestedEffort = `${logCtx.requestedEffort ?? "max"}->${clamped}`; + } + } + recordAttemptRequestedEffort(logCtx); + logCtx.modelSupportsServiceTier = SERVICE_TIER_ADAPTERS.has(route.provider.adapter) + ? modelServiceTierSupport + : undefined; +} + + + +export async function handleComboResponses( + req: Request, + rawBody: unknown, + comboId: string, + config: OcxConfig, + logCtx: RequestLogContext, + options: HandleResponsesOptions & { translatorBudget: TranslatorBudget }, +): Promise { + const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string" + ? (rawBody as { model: string }).model + : `combo/${comboId}`; + Object.assign(logCtx, { + requestedModel, + model: requestedModel, + provider: "combo", + comboId, + }); + const combo = getCombo(config, comboId); + if (!combo) { + return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`); + } + // Expand previous_response_id before image policy and child dispatch so a + // continuation that only references prior images still fails closed when + // imageInput is disabled (and so targets see the full replayed input). + const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; + const body = expandPreviousResponseInput(rawBody, inboundClientThreadId); + const scopeMismatch = previousResponseScopeMismatch(body); + if (scopeMismatch) { + console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh"); + } + if (previousResponseReplayFailure(body)) { + return formatErrorResponse( + 400, + "previous_response_not_found", + "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", + ); + } + // Missing state returns the original body without a failure marker. Reject + // that unresolved continuation for image-disabled combos so a target cannot + // resolve prior images out of band. A successful expansion yields a new + // object (still carrying previous_response_id) and must not be treated as + // unresolved — text-only stored continuations remain allowed. + const requestedPreviousId = typeof (rawBody as { previous_response_id?: unknown } | null)?.previous_response_id === "string" + ? (rawBody as { previous_response_id: string }).previous_response_id.trim() + : ""; + const unresolvedPrevious = requestedPreviousId.length > 0 && body === rawBody; + if (combo.imageInput === "disabled" && unresolvedPrevious) { + return formatErrorResponse( + 400, + "previous_response_not_found", + "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", + ); + } + if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) { + return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`); + } + const comboReplaySnapshot = { + sourceBody: body, + previousResponseInputExpanded: body !== rawBody + && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string", + providerContinuation: !scopeMismatch && body !== rawBody && requestedPreviousId + ? previousResponseProviderState(requestedPreviousId) + : undefined, + recoveredPlaintext: false, + }; + const adoptFailedChildLog = (childLog: RequestLogContext): void => { + // Attempts remain the complete physical history; the logical row mirrors the most recent + // failed target so an exhausted combo still has useful top-level reasoning diagnostics. + Object.assign(logCtx, childLog, { + requestedModel, + model: requestedModel, + provider: "combo", + comboId, + routeDecision: logCtx.routeDecision, + attempts: logCtx.attempts, + activeAttempt: undefined, + activeAttemptStartedAt: undefined, + }); + }; + + const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( + (body as { input?: unknown } | undefined)?.input, + ); + const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => { + const provider = config.providers[target.provider]; + if (!provider || provider.disabled === true) return false; + try { + const route = routeConcreteModel(config, `${target.provider}/${target.model}`); + return isCanonicalOpenAiForwardProvider(route.provider); + } catch { + return false; + } + }; + let comboPayloadReadable = false; + const payloadEligible = (target: (typeof combo.targets)[number]): boolean => + comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); + let encryptedTaskRecoveryAttempted = false; + let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined; + let storedPool401ReplayDispatched = false; + const recoverUnreadableEncryptedTask = async (): Promise => { + if (encryptedTaskRecoveryAttempted) return false; + encryptedTaskRecoveryAttempted = true; + const recovery = agentTaskRecoveryConfig(config); + if ( + (options.inboundWire ?? "responses") !== "responses" + || !isThreadSpawnRequest(req.headers) + || !recovery + || options.comboAttempt + ) { + discardEncryptedAgentTaskRecovery( + req, + (body as { input?: unknown } | undefined)?.input, + config, + { parentThreadId: inboundClientThreadId }, + ); + return false; + } + let recovered = false; + try { + const result = await recoverEncryptedAgentTaskWithResult( + req, + (body as { input?: unknown } | undefined)?.input, + recovery, + config, + { parentThreadId: inboundClientThreadId, abortSignal: options.abortSignal }, + ); + recovered = result.recovered; + recoveryFailureReason = result.recovered ? undefined : result.reason; + } catch { + recovered = false; + recoveryFailureReason = undefined; + } + // Recovery has the same in-place input mutation contract as the direct routed path. + if ( + !recovered + || hasUnreadableEncryptedAgentTask((body as { input?: unknown } | undefined)?.input) + ) { + discardEncryptedAgentTaskRecovery( + req, + (body as { input?: unknown } | undefined)?.input, + config, + { parentThreadId: inboundClientThreadId }, + ); + return false; + } + comboPayloadReadable = true; + comboReplaySnapshot.recoveredPlaintext = true; + return true; + }; + const initialNow = Date.now(); + const pickWithWait = (pickOptions: { + exclude?: Iterable; + eligible?: (target: NonNullable["targets"][number]) => boolean; + now?: number; + }) => pickComboTargetWithWait(config, comboId, { + ...pickOptions, + waitForCooldownMs: combo.waitForCooldownMs, + abortSignal: options.abortSignal, + }); + let pick = await pickWithWait({ + eligible: payloadEligible, + now: initialNow, + }); + + if (unreadableEncryptedAgentTask && !pick) { + pick = await pickWithWait({ now: initialNow }); + if (!pick) { + discardEncryptedAgentTaskRecovery( + req, + (body as { input?: unknown } | undefined)?.input, + config, + { parentThreadId: inboundClientThreadId }, + ); + return options.abortSignal?.aborted + ? clientCancelledResponse() + : comboUnavailable(comboId); + } + if (!(await recoverUnreadableEncryptedTask())) { + return options.abortSignal?.aborted + ? clientCancelledResponse() + : unreadableEncryptedAgentTaskResponse(recoveryFailureReason); + } + } + + if (!pick) { + return options.abortSignal?.aborted + ? clientCancelledResponse() + : comboUnavailable(comboId); + } + // One immutable combo selection trace, before any child dispatch; child + // adoption below must never replace it with a concrete child route trace. + logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel); + + let lastFailure: Response | null = null; + while (pick) { + if (options.abortSignal?.aborted) return clientCancelledResponse(); + const childLog: RequestLogContext = { + model: pick.target.model, + provider: pick.target.provider, + ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}), + ...(logCtx.surface ? { surface: logCtx.surface } : {}), + }; + const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`); + const childBody = concreteComboRequestBody( + body, + pick.target, + comboDefaultEffort(config, comboId), + supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }), + ); + const childHeaders = buildComboChildHeaders(req.headers); + const childRequest = new Request(req.url, { + method: req.method, + headers: childHeaders, + body: JSON.stringify(childBody), + }); + let resolvedAuth: CodexAuthContext | undefined; + let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; + const started = Date.now(); + const attempt = beginRequestAttempt( + (logCtx.attempts?.length ?? 0) + 1, + pick.target.provider, + pick.target.model, + config.providers[pick.target.provider]!.adapter, + ); + childLog.activeAttempt = attempt; + let attemptRetained = false; + const retainCancelledAttempt = (): void => { + if (attemptRetained) return; + sealRequestAttemptIdentity( + attempt, + childLog.provider, + childLog.providerAdapter ?? attempt.adapter, + childLog.accountLogLabel, + ); + finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage); + (logCtx.attempts ??= []).push(attempt); + attemptRetained = true; + }; + let consumedChildFailure: ConsumedComboFailure | undefined; + const callbackGate = createChildPassthroughCallbackGate({ + ...options, + onNativePassthroughTerminal: status => { + // A committed stream can acquire terminal metadata after preflight copied + // the child log. Publish it before the outer logger finalizes, but only + // through the gate: discarded attempts must never affect the parent. + // Undefined child fields must preserve metadata already inspected by WS. + if (childLog.terminalHttpStatus !== undefined) logCtx.terminalHttpStatus = childLog.terminalHttpStatus; + if (childLog.terminalIncompleteReason !== undefined) logCtx.terminalIncompleteReason = childLog.terminalIncompleteReason; + if (childLog.terminalErrorCode !== undefined) logCtx.terminalErrorCode = childLog.terminalErrorCode; + if (childLog.upstreamError !== undefined) logCtx.upstreamError = childLog.upstreamError; + options.onNativePassthroughTerminal?.(status); + }, + }); + let response: Response; + try { + const currentTargetProvider = pick.target.provider; + const deferCodexResetDerivedCooldown = combo.strategy === "failover" + && combo.targets.slice(pick.targetIndex + 1).some(target => + target.provider === currentTargetProvider + && payloadEligible(target) + && !isComboTargetInCooldown(comboId, target), + ); + response = await handleResponses(childRequest, config, childLog, { + ...options, + comboAttempt: true, + comboReplaySnapshot, + deferCodexResetDerivedCooldown, + // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later + // Object.assign(logCtx, childLog) would overwrite the request-relative value). + onFirstOutput: () => { + if (attempt.firstOutputMs === undefined) { + attempt.firstOutputMs = Math.max(0, Date.now() - started); + } + options.onFirstOutput?.(); + }, + onCodexAuthContextResolved: value => { resolvedAuth = value; }, + setTerminalOutcomeRecorder: value => { terminalRecorder = value; }, + onConsumedComboFailure: value => { consumedChildFailure = value; }, + onStoredPool401ReplayDispatched: () => { storedPool401ReplayDispatched = true; }, + onNativePassthroughTerminal: callbackGate.onTerminal, + onNativePassthroughCancel: callbackGate.onCancel, + }); + } catch (error) { + callbackGate.discard(); + if (options.abortSignal?.aborted) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + throw error; + } + + if (options.abortSignal?.aborted) { + callbackGate.discard(); + retainCancelledAttempt(); + return clientCancelledResponse(); + } + + if (response.ok && !runTurnAdapterSseResponses.has(response)) { + const nativePassthrough = isNativePassthroughSseResponse(response); + const eagerRelay = isEagerRelaySseResponse(response); + let preflight; + try { + preflight = await preflightComboStreamResponse(response, childLog); + } catch (error) { + callbackGate.discard(); + if (options.abortSignal?.aborted) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + throw error; + } + if (preflight.kind === "failed") { + callbackGate.discard(); + terminalRecorder?.("failed", preflight.response.status); + response = preflight.response; + } else { + response = preflight.response; + if (nativePassthrough) markNativePassthroughSseResponse(response); + if (eagerRelay) markEagerRelaySseResponse(response); + } + } + + if (response.ok) { + sealRequestAttemptIdentity( + attempt, + childLog.provider, + childLog.providerAdapter ?? attempt.adapter, + childLog.accountLogLabel, + ); + (logCtx.attempts ??= []).push(attempt); + attemptRetained = true; + noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration); + Object.assign(logCtx, childLog, { + requestedModel, + model: requestedModel, + provider: "combo", + comboId, + routeDecision: logCtx.routeDecision, + attempts: logCtx.attempts, + activeAttempt: attempt, + activeAttemptStartedAt: started, + resolvedModel: childLog.resolvedModel ?? childLog.model, + }); + options.onCodexAuthContextResolved?.(resolvedAuth); + options.setTerminalOutcomeRecorder?.(terminalRecorder); + callbackGate.commit(); + return response; + } + + callbackGate.discard(); + if (response.status === 499) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + let failure: ConsumedComboFailure; + try { + failure = consumedChildFailure + ?? await consumeComboFailure(response, options.abortSignal); + } catch (error) { + if (options.abortSignal?.aborted) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + throw error; + } + if (options.abortSignal?.aborted) { + retainCancelledAttempt(); + return clientCancelledResponse(); + } + sealRequestAttemptIdentity( + attempt, + childLog.provider, + childLog.providerAdapter ?? attempt.adapter, + childLog.accountLogLabel, + ); + finishRequestAttempt( + attempt, + failure.response.status, + Date.now() - started, + failure.usage, + ); + (logCtx.attempts ??= []).push(attempt); + attemptRetained = true; + lastFailure = failure.response; + const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + }); + if (storedPool401ReplayDispatched) { + if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) { + const recoveredTarget = await pickWithWait({ + exclude: pick.attempted, + eligible: target => { + try { + const route = routeConcreteModel(config, `${target.provider}/${target.model}`); + return route.codexAccountMode === undefined + && !isCanonicalOpenAiForwardProvider(route.provider); + } catch { + return false; + } + }, + }); + if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (recoveredTarget && await recoverUnreadableEncryptedTask()) { + pick = recoveredTarget; + continue; + } + if (options.abortSignal?.aborted) return clientCancelledResponse(); + } + // Keep the spent Pool budget sticky even after a recovered routed child: + // no later failure may reopen ordinary combo/native account hopping. + adoptFailedChildLog(childLog); + return lastFailure; + } + if (failureDecision === "stop") { + adoptFailedChildLog(childLog); + if ( + failure.response.status === 413 + && (rawBody as { stream?: unknown } | null)?.stream === true + ) { + return streamingContextOverflowResponse(requestedModel, options.translatorBudget); + } + return lastFailure; + } + console.warn( + `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`, + ); + const failureNow = Date.now(); + const attemptedTargets = pick.attempted; + const nextPick = advanceComboAfterFailure(config, pick, { + retryAfter: failure.retryAfter, + resetAt: failure.resetAt, + cooldownMs: combo.cooldownMs, + now: failureNow, + cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + }), + eligible: payloadEligible, + status: failure.response.status, + code: failure.upstreamCode, + message: failure.classificationText, + }); + if (nextPick) { + pick = nextPick; + } else { + pick = await pickWithWait({ + exclude: pick.attempted, + eligible: payloadEligible, + now: failureNow, + }); + } + if (!pick) { + if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (unreadableEncryptedAgentTask && !comboPayloadReadable) { + const recoveredTarget = await pickWithWait({ + exclude: attemptedTargets, + now: failureNow, + }); + if (recoveredTarget && await recoverUnreadableEncryptedTask()) { + pick = recoveredTarget; + continue; + } + } + // Waiting or recovery may have observed cancellation after the check above. + if (options.abortSignal?.aborted) return clientCancelledResponse(); + adoptFailedChildLog(childLog); + } + } + if ( + lastFailure?.status === 413 + && (rawBody as { stream?: unknown } | null)?.stream === true + ) { + return streamingContextOverflowResponse(requestedModel, options.translatorBudget); + } + return lastFailure!; +} + + + +function finalizeOwnedTranslatorBudget(response: Response, budget: TranslatorBudget): Response { + if (!response.body) { + budget.dispose(); + return response; + } + const reader = response.body.getReader(); + let finalized = false; + const finalize = () => { + if (finalized) return; + finalized = true; + budget.dispose(); + }; + const body = new ReadableStream({ + async pull(controller) { + try { + const result = await reader.read(); + if (result.done) { + finalize(); + controller.close(); + } else { + controller.enqueue(result.value); + } + } catch (error) { + finalize(); + controller.error(error); + } + }, + async cancel(reason) { + try { await reader.cancel(reason); } finally { finalize(); } + }, + }); + const finalizedResponse = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + if (isNativePassthroughSseResponse(response)) { + markNativePassthroughSseResponse(finalizedResponse); + } + if (isEagerRelaySseResponse(response)) { + markEagerRelaySseResponse(finalizedResponse); + } + return finalizedResponse; +} + +/** + * Service-tier capability gate, applied after the final route/wire is settled. A + * provider explicitly documented as NOT supporting `service_tier` must never + * receive it: strip the field and clear the logging value even when the caller + * supplied one (fail closed). A policy-produced canonical Fast decision has + * already passed capability validation and cannot be vetoed by Chat's caller + * forwarding permission. On unclassified routes every caller tier remains subject + * to `forwardCallerTier`. + */ +export function applyServiceTierGate( + provider: OcxProviderConfig, + rawBody: unknown, + options: { serviceTier?: string; tierDecision?: TierDecision }, + modelId?: string, + providerName?: string, + inbound: InboundWire = "responses", + resolvedPolicy?: ResolvedFastPolicy, +): void { + // A direct unit caller without a model id retains the historical tri-state behavior for + // adapters outside the OpenAI service-tier family. Once a model is known, resolve the final + // model adapter as well: an explicit override to Anthropic (or another non-OpenAI wire) must + // not carry a caller-supplied `service_tier` through a route that cannot forward it. + if (modelId === undefined && !SERVICE_TIER_ADAPTERS.has(provider.adapter)) return; + const policy = modelId === undefined + ? undefined + : resolvedPolicy ?? fastPolicyForModel(provider, modelId, providerName, inbound); + const forwardCallerTier = modelId === undefined + ? provider.supportsServiceTier !== false + : policy!.forwardCallerTier; + const rawTier = rawBody && typeof rawBody === "object" + ? (rawBody as Record).service_tier + : undefined; + const canonicalDecision = options.tierDecision?.kind === "set"; + const callerTierIsForeign = rawTier !== undefined + && (typeof rawTier !== "string" || canonicalFastTierMarker(rawTier) === undefined); + const dropForeignCallerTier = policy?.capability === true + && policy.fastWire?.kind === "service-tier" + && policy.fastWire?.foreignCallerTiers === "drop" + && callerTierIsForeign; + if (policy && policy.capability !== false && canonicalDecision) return; + if (forwardCallerTier && !dropForeignCallerTier) return; + if (rawBody && typeof rawBody === "object") { + delete (rawBody as Record).service_tier; + } + options.serviceTier = undefined; +} + +/** + * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough + * wire, image/web-search bridges, and the terminal-guard continuation. + */ +export async function handleResponses( + req: Request, + config: OcxConfig, + logCtx: RequestLogContext, + options: HandleResponsesOptions = {}, +): Promise { + const ownsBudget = options.translatorBudget === undefined; + const translatorBudget = options.translatorBudget ?? createTranslatorBudget(); + try { + const response = await handleResponsesInner(req, config, logCtx, { + ...options, + // Capture before combo replay rebuilds the Request headers; children carry options. + visionDescribeTerminal: options.visionDescribeTerminal === true + || req.headers.get("x-opencodex-vision-describe") === "1", + translatorBudget, + }); + return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response; + } catch (error) { + if (ownsBudget) translatorBudget.dispose(); + throw error; + } +} + +/** + * Inner implementation of `handleResponses`; owns the pre-stream recovery loop and the + * per-request same-target 429 retry budgets. + */ +async function handleResponsesInner( + req: Request, + config: OcxConfig, + logCtx: RequestLogContext, + options: HandleResponsesOptions & { translatorBudget: TranslatorBudget }, +): Promise { + let pendingHostAdmissionLease: UpstreamHostAdmissionLease | null = null; + let authCtx: CodexAuthContext = { kind: "main", accountId: null }; + try { + // The Chat and Anthropic surfaces replay through here with a Responses-shaped body, + // so an omitted value means a genuine Responses inbound. + const inboundWire = options.inboundWire ?? "responses"; + const translatorBudget = options.translatorBudget; + const agentTaskRecovery = agentTaskRecoveryConfig(config); + let body: unknown; + try { + body = await readJsonRequestBody(req, translatorBudget); + } catch (err) { + if (options.abortSignal?.aborted || req.signal.aborted) { + return clientCancelledResponse(); + } + return decodeRequestErrorResponse(err, "responses"); + } + // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher + // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model. + const comboRows = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body) + && typeof (body as { model?: unknown }).model === "string" + // One parse for both grammars, from the selector as the client sent it. Parsing them + // separately made the outcome depend on which ran first. + ? parseSyntheticRowId((body as { model: string }).model, config) + : { fastRow: null, effortRow: null }; + const comboEffortRow = comboRows.effortRow; + if (comboRows.fastRow) { + // Same reason as the effort row above: the combo dispatcher reads `model` next, so the + // selector has to be normalized before it, or a combo child is built from a synthetic id. + const raw = body as Record; + raw.model = comboRows.fastRow.baseId; + // A caller INTENT, not a decision. decideTier still rules on eligibility downstream, so + // fastMode:false and an ineligible route both still suppress it. + raw.service_tier = "priority"; + } + if (comboEffortRow) { + const raw = body as Record; + raw.model = comboEffortRow.baseId; + const rawReasoning = raw.reasoning; + raw.reasoning = { + ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning) + ? rawReasoning as Record + : {}), + effort: comboEffortRow.effort, + }; + } + const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null; + if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { + options.onRequestBodyRead?.(); + return handleComboResponses(req, body, comboId, config, logCtx, { + ...options, + // The original request body was accepted above. Combo children are synthetic + // replays and must not repeat the caller-owned timeout transition. + onRequestBodyRead: undefined, + }); + } + let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( + (body as { input?: unknown } | undefined)?.input, + ); + const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; + const cursorClientThreadId = codexPoolAffinityKey(req.headers); + const originalBody = body; + if (options.comboReplaySnapshot) { + copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body); + } else { + body = expandPreviousResponseInput(body, inboundClientThreadId); + if (previousResponseScopeMismatch(body)) { + console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh"); + } + if (previousResponseReplayFailure(body)) { + return formatErrorResponse( + 400, + "previous_response_not_found", + "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", + ); + } + } + const previousResponseInputExpanded = options.comboReplaySnapshot?.previousResponseInputExpanded + ?? (body !== originalBody + && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string"); + + // Spawn-message compatibility (both directions): agent_message task payloads ride in + // encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE + // parsing so every consumer sees the payload: parseRequest (routed/translated providers read + // the parsed messages) and the native passthrough (_rawBody is this same object, serialized + // verbatim). Genuine backend ciphertext is left byte-identical (looksLikeBackendCiphertext). + { + const rewritten = sanitizeEncryptedContentInPlace( + (body as { input?: unknown } | undefined)?.input, + ); + if (rewritten > 0) + console.warn( + `[opencodex] rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (spawn-message compatibility)`, + ); + } + + let parsed: OcxParsedRequest; + let toolBridgeMaps: ReturnType; + try { + parsed = parseRequest(body); + parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; + // Captured before any parser mutates it, so both grammars see the client's id. + const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config); + if (fastRow) { + parsed.modelId = fastRow.baseId; + parsed.options.serviceTier = "priority"; + const raw = parsed._rawBody as Record; + raw.model = fastRow.baseId; + raw.service_tier = "priority"; + } + if (effortRow) { + parsed.modelId = effortRow.baseId; + parsed.options.reasoning = effortRow.effort; + const raw = parsed._rawBody as Record; + const rawReasoning = raw.reasoning; + raw.model = effortRow.baseId; + raw.reasoning = { + ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning) + ? rawReasoning as Record + : {}), + effort: effortRow.effort, + }; + } + if (options.comboReplaySnapshot?.recoveredPlaintext) { + markBodyNonPersistable(parsed._rawBody); + } + toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); + if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true; + const providerContinuationCandidate = options.comboReplaySnapshot + ? options.comboReplaySnapshot.providerContinuation + : previousResponseProviderState(parsed.previousResponseId); + if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate; + if (inboundClientThreadId) { + parsed._clientThreadId = inboundClientThreadId; + } else if ( + options.inboundWire === "anthropic" + && options.promptCacheKeyIsSharedCohort !== true + && typeof parsed.options.promptCacheKey === "string" + && parsed.options.promptCacheKey.trim().length > 0 + ) { + // Claude Code has no Codex parent-thread header, but its metadata.user_id is + // translated into a stable per-session prompt_cache_key. Use it as the replay + // thread identity so Gemini thought signatures are remembered by call_id for + // Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so + // existing provider session-id derivation (first-user-text fallback) is unchanged. + // Normalize through anthropicSessionKeyFromParts so overlong keys are hashed and + // trimming matches the affinity/session-key path exactly (no raw >128-char ids). + const normalizedCacheKey = anthropicSessionKeyFromParts({ + promptCacheKey: parsed.options.promptCacheKey, + // The enclosing branch already proves this is not the shared cohort. + promptCacheKeyIsSharedCohort: false, + }); + if (normalizedCacheKey) { + parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey }; + } + } + if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId; + } catch (err) { + if (isTranslatorBudgetExceededError(err)) { + return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { + code: "translation_buffer_limit", + }); + } + return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err)); + } + options.onRequestBodyRead?.(); + const responseStateOptions = (force = false): { force?: boolean; clientThreadId?: string } => ({ + ...(force ? { force: true } : {}), + ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}), + }); + const resolvedConversationId = conversationIdFromResponsesRequest({ + clientThreadId: parsed._clientThreadId, + sessionIdHeader: sessionIdHeaderFromRequest(req.headers), + threadIdHeader: req.headers.get("thread-id"), + cursorConversationId: parsed._cursorConversationId, + }); + bindTurnTerminationScope(parsed, resolvedConversationId); + const rememberKiroDeliveredFinalAnswer = (adapterName: string, response: unknown): void => { + if (adapterName === "kiro") rememberDeliveredFinalAnswer(parsed, response); + }; + // _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses + // a dedicated raw conversation namespace so mixed headers that carry the same identity still + // match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations. + // Keep an Anthropic prompt_cache_key scope already bound above (#1735/#1926). + if (!parsed._reasoningReplayScope) { + const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({ + clientThreadId: parsed._clientThreadId, + threadIdHeader: req.headers.get("thread-id"), + cursorConversationId: parsed._cursorConversationId, + sessionIdHeader: sessionIdHeaderFromRequest(req.headers), + }); + if (reasoningReplayConversationId) { + parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId }; + } + } + // Prefer a pre-populated id (routed Claude) over Responses headers that may be + // absent or synthetically injected (session_id from prompt_cache_key). + if (!logCtx.conversationId) { + logCtx.conversationId = resolvedConversationId; + } + logCtx.requestedModel = parsed.modelId; + logCtx.requestedEffort = parsed.options.reasoning; + logCtx.callerServiceTier = sanitizeLogMetadataString(parsed.options.serviceTier); + logCtx.requestedServiceTier = parsed.options.serviceTier; + logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier); + logCtx.configuredServiceTier = readConfiguredCodexServiceTier(); + logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier); + + let route: RouteResult; + try { + // A `compaction_trigger` turn may name a bare native model the operator has + // no canonical OpenAI route for (#2901). Only the initial compaction route + // may fall back to the configured default provider; combo attempts and the + // later fallback/recovery re-routes keep the ordinary reservation. + const resolveRoute = (modelId: string) => options.comboAttempt + ? routeConcreteModel(config, modelId) + : parsed._compactionRequest === true + ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody)) + : routeModel(config, modelId, evidenceFromBody(parsed._rawBody)); + const _sci = config.shadowCallIntercept; + let shadowRoute: RouteResult | undefined; + if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) { + const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!; + let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix }; + try { + const resolvedSource = routeConcreteModel(config, parsed.modelId); + sourceIdentity = { providerName: resolvedSource.providerName, modelId: sourcePrefix }; + } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ } + const targetRoute = resolveRoute(_sci.model); + if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) { + const _sciOriginal = parsed.modelId; + parsed.modelId = _sci.model; + if (parsed._rawBody && typeof parsed._rawBody === "object") { + (parsed._rawBody as { model?: string }).model = _sci.model; + } + // Record the operator-configured prefix that matched, NOT the caller's raw model string. + // Matching is by prefix, so a caller can append arbitrary text and still intercept; that + // raw value would then land in usage.jsonl and /api/logs behind a pattern-based redactor + // that does not recognize every credential family. The prefix is a value the operator + // configured, so no caller-controlled string is persisted. + logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString( + shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels), + ); + // Helpers must not resume/append into the parent thread's Cursor conversation. + parsed._cursorIsolateConversation = true; + shadowRoute = targetRoute; + } + } + if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true; + route = shadowRoute ?? resolveRoute(parsed.modelId); + logCtx.routeDecision = route.routeDecision; + } catch (err) { + if (err instanceof NoAvailableComboTargetsError) { + return comboUnavailable(err.comboId); + } + if (err instanceof NoEligiblePolicyCandidateError) { + // Persist the evaluation trace (per-candidate exclusions + the + // no-eligible reason) so failed policy requests stay auditable. + logCtx.routeDecision = err.trace; + } + return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err)); + } + + const hasUnexpandedPreviousResponse = !!parsed.previousResponseId + && parsed._previousResponseInputExpanded !== true; + // Exact account selectors are isolated from Pool-wide quota work. A canonical replay miss must + // also fail closed without polling quota upstream. Cached fallback state can still select a + // provider with native continuation support below. + const threadSpawn = isThreadSpawnRequest(req.headers); + const initialSubagentFallbackChain = threadSpawn && !options.comboAttempt + ? resolveSubagentFallbackChain(parsed, config) + : null; + const previewSelectionAdmission = threadSpawn + && !options.comboAttempt + && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null) + ? codexAccountSelectionForTurn(options.turnAdmissionLease)?.() + : undefined; + const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked(); + const nativeMainReadsForbidden = nativeMainRecoveryBlocked + || previewSelectionAdmission?.mainProfileDraining === true; + const previewSelectionOptions = { + nativeMainSelectionOnly: !nativeMainRecoveryBlocked + && previewSelectionAdmission?.mainProfileDraining === true, + }; + let selectedForwardHeaders = req.headers; + let subagentFallbackAccountId = config.activeCodexAccountId ?? null; + let subagentFallbackAccountPreview: SubagentPoolAccountPreview | undefined; + let subagentFallbackModelEligibleAccountIdsForModel: SubagentModelEligibleAccountIds | undefined; + let subagentQuotaFailureModel = parsed.modelId; + const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null; + const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null; + + try { + if ( + threadSpawn + && route.codexAccountId === undefined + && !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider)) + ) { + await maybePrimeSubagentQuota(config, Date.now(), { nativeMainReadsForbidden }); + } + + // Subagent fallback must settle the final model/provider BEFORE route-dependent + // normalization (virtual models, effort caps, service tier, wire protocol). + // Preview the preferred Codex account without acquiring a probe lease or refreshing + // tokens — auth is resolved only after the final route is selected. + if ( + threadSpawn + && !options.comboAttempt + && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null) + ) { + // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId), + // so the preview must read the same scope slot — an undefined scope would map to the + // "legacy" affinity bucket and never find a binding made under "shared" or a native + // model scope, making the preview diverge from the account that actually authenticates. + const fallbackChain = initialSubagentFallbackChain; + subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({ + config, + fallbackChain, + nativeMainReadsForbidden, + resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, + }); + const fallbackNow = Date.now(); + subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest( + poolAffinityKey, + config, + previewNow, + codexQuotaScopeForModel(modelId), + { ...previewSelectionOptions, modelEligibleAccountIds }, + modelId, + ); + const previewAccountId = route.codexAccountId ?? subagentFallbackAccountPreview( + route.modelId, + fallbackNow, + subagentFallbackModelEligibleAccountIdsForModel?.(route.modelId), + ); + subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null; + const fallback = applySubagentModelFallback( + parsed, + req.headers, + config, + previewAccountId, + fallbackNow, + unreadableEncryptedAgentTask, + previewSelectionOptions, + subagentFallbackAccountPreview, + subagentFallbackModelEligibleAccountIdsForModel, + fallbackChain, + candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire), + ); + if (fallback) { + (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; + (logCtx as unknown as Record).subagentModelFallbackTo = fallback.to; + if (isInjectionDebugEnabled()) { + injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`); + } + } + subagentQuotaFailureModel = fallback?.to ?? parsed.modelId; + + if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { + try { + route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)); + logCtx.routeDecision = route.routeDecision; + } catch (err) { + if (err instanceof NoAvailableComboTargetsError) { + return comboUnavailable(err.comboId); + } + if (err instanceof NoEligiblePolicyCandidateError) { + logCtx.routeDecision = err.trace; + } + return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err)); + } + } + } + } finally { + previewSelectionAdmission?.release(); + } + + let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined; + // Native fallback and explicitly trusted direct Responses routes can consume ciphertext, + // so recover only after final route selection. + if ( + inboundWire === "responses" + && + threadSpawn + && agentTaskRecovery + && !isCanonicalOpenAiForwardProvider(route.provider) + && !options.comboAttempt + && !canPassThroughEncryptedV2AgentTask(route, inboundWire) + ) { + let recovered = restoreCachedEncryptedAgentTasks( + req, (body as { input?: unknown } | undefined)?.input, config, { parentThreadId }, + ) > 0; + unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( + (body as { input?: unknown } | undefined)?.input, + ); + if (unreadableEncryptedAgentTask) try { + const result = await recoverEncryptedAgentTaskWithResult( + req, + (body as { input?: unknown } | undefined)?.input, + agentTaskRecovery, + config, + { parentThreadId, abortSignal: options.abortSignal }, + ); + recovered = result.recovered; + recoveryFailureReason = result.recovered ? undefined : result.reason; + } catch { + recovered = false; + recoveryFailureReason = undefined; + } + if (recovered) { + unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( + (body as { input?: unknown } | undefined)?.input, + ); + if (!unreadableEncryptedAgentTask) { + try { + const reparsed = parseRequest(body); + const kept: Array = [ + "_previousResponseInputExpanded", + "_providerContinuation", + "_providerContinuationCandidate", + "_providerContinuationOwner", + "_cursorConversationId", + "_clientThreadId", + "_promptCacheKeyIsSharedCohort", + "_cursorClientThreadId", + "_reasoningReplayScope", + "_cursorIsolateConversation", + ]; + for (const key of kept) { + if (parsed[key] !== undefined) { + (reparsed as unknown as Record)[key] = parsed[key]; + } + } + bindTurnTerminationScope(reparsed, resolvedConversationId); + parsed = reparsed; + // The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task + // text. Bar it from the continuation cache before any recording path can reach it — + // that cache is persisted to disk, which would defeat the recovery cache's TTL. + markBodyNonPersistable(parsed._rawBody); + + // The ciphertext-only pass intentionally excludes routed candidates. Once recovery + // makes the assignment readable, run selection again with the full configured chain + // and keep the route in sync with any newly selected fallback. + const recoverySelectionAdmission = codexAccountSelectionForTurn(options.turnAdmissionLease)?.(); + const fallback = (() => { + try { + const recoveryNativeMainBlocked = isNativeMainTrafficBlocked(); + const recoverySelectionOptions = { + nativeMainSelectionOnly: !recoveryNativeMainBlocked + && recoverySelectionAdmission?.mainProfileDraining === true, + }; + const recoveryNow = Date.now(); + // Carry the entitlement filter through recovery too (#2509/#2623). The scope was + // already re-previewed per candidate here; the ELIGIBLE-ACCOUNT set was not, so a + // recovered assignment could select an account that is not entitled to the model + // and then fail closed at final auth — the same class of stale-selection bug as + // the quota scope, one layer over. + subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest( + poolAffinityKey, + config, + previewNow, + codexQuotaScopeForModel(modelId), + { ...recoverySelectionOptions, modelEligibleAccountIds }, + modelId, + ); + const recoveryPreviewAccountId = subagentFallbackAccountPreview( + parsed.modelId, + recoveryNow, + subagentFallbackModelEligibleAccountIdsForModel?.(parsed.modelId), + ); + return applySubagentModelFallback( + parsed, + req.headers, + config, + recoveryPreviewAccountId, + recoveryNow, + false, + recoverySelectionOptions, + subagentFallbackAccountPreview, + subagentFallbackModelEligibleAccountIdsForModel, + ); + } finally { + recoverySelectionAdmission?.release(); + } + })(); + if (fallback) { + (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; + (logCtx as unknown as Record).subagentModelFallbackTo = fallback.to; + if (isInjectionDebugEnabled()) { + injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`); + } + } + subagentQuotaFailureModel = fallback?.to ?? parsed.modelId; + + if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { + try { + route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)); + logCtx.routeDecision = route.routeDecision; + } catch (err) { + if (err instanceof NoAvailableComboTargetsError) { + return comboUnavailable(err.comboId); + } + if (err instanceof NoEligiblePolicyCandidateError) { + logCtx.routeDecision = err.trace; + } + return formatErrorResponse( + 404, + "invalid_request_error", + err instanceof Error ? err.message : String(err), + ); + } + } + } catch { + unreadableEncryptedAgentTask = true; + } + } + } + } + + if (options.abortSignal?.aborted) return clientCancelledResponse(); + + // Encrypted child tasks may reach the canonical native backend or an explicitly trusted + // direct Responses route. This runs against the FINAL route so native-only fallback can + // rescue an incompatible primary without weakening combo behavior. + const finalRouteCanPassThroughEncryptedTask = !options.comboAttempt + && canPassThroughEncryptedV2AgentTask(route, inboundWire); + if ( + (route.combo !== undefined || !isCanonicalOpenAiForwardProvider(route.provider)) + && !finalRouteCanPassThroughEncryptedTask + && unreadableEncryptedAgentTask + ) { + return unreadableEncryptedAgentTaskResponse(recoveryFailureReason); + } + + // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no + // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream + // I/O instead of stripping the id and silently forwarding a context-free delta (#702). + if ( + hasUnexpandedPreviousResponse + && isCanonicalOpenAiForwardProvider(route.provider) + ) { + return formatErrorResponse( + 400, + "invalid_request_error", + "OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.", + ); + } + + // Captured before normalization: whether the CLIENT asked for SSE. The + // transport-neutral upstream-streaming policy below may force a bounded JSON + // upstream for reliability (#875); the answer must then be reframed to SSE + // for streaming clients. + const clientRequestedStream = parsed.stream; + await applyFinalRouteRequestNormalization({ + parsed, + route, + config, + req, + logCtx, + inboundWire, + inboundTransport: options.inboundTransport, + }); + // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before + // the normal post-resolution provider label is assigned. + if (route.codexAccountNamespace) { + logCtx.provider = `${route.providerName}-${route.codexAccountNamespace}`; + } + + if (options.abortSignal?.aborted) return clientCancelledResponse(); + // Resolve aliases/combo children before refusing helpers; do not spend main auth or host budget. + if (isCanonicalOpenAiForwardProvider(route.provider) + && isCodexReserveHelperUnsupported(options.codexAuthPolicy ?? config, route.modelId, + options.admission, options.visionDescribeTerminal === true)) { + return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE); + } + // Refuse an input that cannot plausibly fit the model context window before spending auth, + // circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412). + // + // Compaction turns are exempt: Codex sends compaction_trigger BECAUSE context is full, so + // refusing the turn that shrinks the context would deadlock the client against the very + // limit this gate reports — it would be told to compact and then denied the compaction. + if (parsed._compactionRequest !== true) { + const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config)); + if (!inputAdmission.admitted) { + // #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo + // fallback must be able to skip this candidate and try one whose context window fits, + // instead of treating the first incompatible candidate as the end of the chain. The + // distinct code is what lets the fallback layer tell the two apart -- an upstream + // `context_length_exceeded` still stops, because retrying it elsewhere is guesswork. + if (clientRequestedStream && !options.comboAttempt) { + return streamingContextOverflowResponse( + parsed._responseModelId ?? parsed.modelId, + translatorBudget, + ); + } + return formatErrorResponse( + 413, + "input_admission_refused", + `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window ` + + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a ` + + `model with a larger context window.`, + ); + } + } + const preAuthHostKey = preAuthUpstreamHostCircuitKey(route, config); + if (preAuthHostKey) { + const admission = acquireUpstreamHostAdmission( + preAuthHostKey, + config.upstreamHostCircuitThreshold, + ); + if (admission.kind === "blocked") { + return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds); + } + pendingHostAdmissionLease = admission.lease; + } + + let substituteMainCredential = false; + { + const finalAuth = await resolveResponsesCodexAuth(req, config, route, options); + if (!finalAuth.ok) return finalAuth.response; + authCtx = finalAuth.authCtx; + selectedForwardHeaders = finalAuth.headers; + substituteMainCredential = finalAuth.substituteMainCredential; + } + + route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); + applyCodexAccountGatedWireNormalization(parsed, route, logCtx); + logCtx.provider = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); + logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); + // Seed an account-derived scope before final adapter binding. Cursor never treats it as + // authoritative: bindRouteReasoningReplayScope replaces it with the exact route owner or a + // per-request fail-closed sentinel after the final provider and credential are known. + const identityScope = codexLogAccountId(authCtx); + if (identityScope) parsed._cursorIdentityScope = identityScope; + subagentFallbackAccountId = authCtx.kind === "pool" || authCtx.kind === "main-pool" + ? authCtx.accountId + : config.activeCodexAccountId ?? null; + + // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the + // existing openai-chat / anthropic adapters authenticate with no change. + const isOAuth401ReplayProvider = ( + route.providerName === "xai" + || route.providerName === "github-copilot" + || route.providerName === "kiro" + || route.providerName === "google-antigravity" + ) && route.provider.authMode === "oauth"; + let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; + let replayOAuthCredentialSnapshot: Pick | undefined; + let anthropicPoolAccountId: string | null = null; + let anthropicPoolFailovers = 0; + // Generic OAuth rotation (#2568) for providers with no pool of their own. Bound to the account + // the request actually used, so a concurrent rotation cannot cool an innocent replacement. + let genericFailoverAccountId: string | null = null; + let genericFailovers = 0; + let oauthSelection = route.provider.authMode === "oauth" + ? captureOAuthAccountSelection(route.providerName) : null; + let servingOAuthSnapshot: OAuthAccessSnapshot | undefined; + // These owners also serve early passthrough and sidecar sends. A dispatch-time + // rebuild must update every later builder, without entering a later block's TDZ. + let adapter: ProviderAdapter; + let activeAdapter: ProviderAdapter; + let runTurnAdapter: ProviderAdapter; + let sameTargetRequest: AdapterRequest | undefined; + let sameTargetParsed: OcxParsedRequest | undefined; + let sameTargetToken = 0; + let transportToken = 0; + let imageTierBias = 0; + const invalidateSameTargetRequest = (): void => { transportToken += 1; }; + type DispatchBinding = + | { kind: "oauth"; selection: NonNullable; snapshot: OAuthAccessSnapshot } + | { kind: "api-key"; provider: OcxProviderConfig }; + const requestBindings = new WeakMap(); + const adapterBindings = new WeakMap(); + const rawRunTurns = new WeakMap>(); + const commitResolvedOAuthSelection = async ( + candidate: OAuthAccessSnapshot, + proactive = false, + anthropicReason?: AnthropicAccountSelectionReason, + ): Promise => { + const maxSelectionAttempts = 3; + for (let attempt = 0; attempt < maxSelectionAttempts; attempt++) { + if (!oauthSelection) return null; + const proactiveEnabled = route.providerName === "anthropic" + ? isAnthropicAccountPoolEnabled(config) + : (config.providers[route.providerName]?.oauthAccountFailover?.enabled + ?? config.oauthAccountFailover?.enabled) === true; + if (proactive && candidate.accountId !== oauthSelection.accountId && !proactiveEnabled) { + oauthSelection = captureOAuthAccountSelection(route.providerName); + if (!oauthSelection) return null; + candidate = route.providerName === "anthropic" + ? await getAnthropicPoolAccessSnapshot(oauthSelection.accountId) + : await getValidAccessSnapshotForAccount(route.providerName, oauthSelection.accountId, { requireUsableAccount: true }); + } + const committed = await commitOAuthAccountSelection(route.providerName, candidate.accountId, { + expectedSelection: oauthSelection, + expectedCredentialGeneration: candidate.generation, + requireUsableAccount: true, + }); + if (committed) { + if (route.providerName === "anthropic" && !commitAnthropicSelectionRouting( + candidate.accountId, oauthSelection, committed, + { config, sessionKey: anthropicSessionKey, reason: anthropicReason, expectedCredentialGeneration: candidate.generation }, + )) return null; + oauthSelection = committed; + servingOAuthSnapshot = candidate; + forgetGenericFailoverRoster(route.providerName); + return candidate; + } + // A newer manual choice wins over this request's old proposal, including A→B→A. + // Resolve that choice, not the rejected candidate, before trying admission again. + oauthSelection = captureOAuthAccountSelection(route.providerName); + if (!oauthSelection) return null; + candidate = route.providerName === "anthropic" + ? await getAnthropicPoolAccessSnapshot(oauthSelection.accountId) + : await getValidAccessSnapshotForAccount(route.providerName, oauthSelection.accountId, { requireUsableAccount: true }); + if (route.provider.googleMode === "cloud-code-assist" && !candidate.projectId) return null; + } + return null; + }; + const refreshResolvedOAuthSelection = async (sent: OAuthAccessSnapshot): Promise => { + const current = captureOAuthAccountSelection(route.providerName); + const unchanged = current?.accountId === oauthSelection?.accountId + && current?.revision === oauthSelection?.revision; + const candidate = unchanged ? await forceRefreshOAuthAccessSnapshot(sent) : sent; + const admitted = await commitResolvedOAuthSelection(candidate); + if (!admitted) throw new Error("OAuth selection changed during credential recovery"); + genericFailoverAccountId = admitted.accountId; + stampOAuthAccountLabel(logCtx, route.providerName, route.provider, admitted.accountId); + return admitted; + }; + /** + * Config generation captured where the serving credential is RESOLVED, not where the + * quota is written. A streaming turn is a long await, so a generation captured at write + * time cannot see a config or account change that happened earlier in the same turn — + * the case the fence exists for. Stays 0 for every provider without a passive quota. + */ + let passiveQuotaWriterGeneration = 0; + /** + * Apply a rotated account's FULL credential snapshot to the live route (#2568d). + * + * One helper for all three rotation sites on purpose. Each site used to inline the same four + * lines, and the divergence that produced was the bug: `apiKey` was swapped while the routing + * metadata paired with it stayed behind. + * + * Returns false when the snapshot cannot be used safely, and the caller must then abandon the + * rotation rather than send a half-applied identity: + * + * - Copilot pins its bearer to an account-scoped regional origin, so transport is re-resolved + * with the new account's `apiBaseUrl` instead of inheriting the previous account's host. The + * snapshot value is RESOLVED first: `rotatedProvider` is a clone of the FAILED account's + * provider, so passing a bare `undefined` origin let the transport resolver fall through its + * own `?? validateCopilotApiBaseUrl(provider.baseUrl)` step to the previous account's host — + * pairing B's bearer with A's accepted origin. Login and refresh always persist a resolved + * origin, so this fallback protects malformed or manually seeded credentials. + * - A Cloud Code Assist provider needs an account-matched project. Antigravity's refresh path + * tolerates project discovery failing, so a stored account can legitimately have no project; + * sending that account's bearer with the FAILED account's project is worse than not rotating. + */ + const applyFailoverSnapshot = async ( + snapshot: OAuthAccessSnapshot, + retryParsed: OcxParsedRequest = parsed, + ): Promise => { + if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return false; + const committed = await commitResolvedOAuthSelection(snapshot); + if (!committed) return false; + snapshot = committed; + let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken }; + if (route.providerName === "github-copilot") { + rotatedProvider = resolveProviderTransport( + route.providerName, + rotatedProvider, + parsed.options.promptCacheKey, + resolveCopilotApiBaseUrl(snapshot.apiBaseUrl), + ) as OcxProviderConfig; + } + if (snapshot.projectId) rotatedProvider = { ...rotatedProvider, project: snapshot.projectId }; + route.provider = rotatedProvider; + if (route.providerName === "kiro") { + const kiroContext = { ...(snapshot.kiro ?? {}) }; + // Terminal-guard continuations are rebuilt from a shallow clone. Updating only the + // outer request pairs the new bearer with the failed account's region/profile on + // the retry. Keep both owners synchronized; for ordinary paths they are identical. + parsed._kiroAuthContext = kiroContext; + if (retryParsed !== parsed) retryParsed._kiroAuthContext = { ...kiroContext }; + } + // Re-stamp: a request that rotated accounts must be attributed to the account that actually + // served it. All three rotation sites funnel through here, so this is the only re-stamp + // needed -- and putting it anywhere else would let one of the three drift. + stampOAuthAccountLabel(logCtx, route.providerName, route.provider, snapshot.accountId); + if (route.providerName === "anthropic") { + anthropicPoolAccountId = snapshot.accountId; + logCtx.provider = formatAnthropicProviderForLog("anthropic", snapshot.accountId, config); + } else { + genericFailoverAccountId = snapshot.accountId; + } + sentOAuthSnapshot = snapshot; + replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation }; + return true; + }; + const selectionIsCurrent = (binding: DispatchBinding | undefined): boolean => { + if (route.provider.authMode === "forward") return true; + if (!binding) return false; + if (binding.kind === "api-key") return providerApiKeySelectionIsCurrent(config, route.providerName, binding.provider); + const selected = captureOAuthAccountSelection(route.providerName); + const row = getAccountCredentialWithStatus(route.providerName, binding.snapshot.accountId); + return selected?.accountId === binding.selection.accountId && selected?.revision === binding.selection.revision + && !!row && !row.needsReauth && row.credential.expires > Date.now() + && credentialGeneration(row.credential) === binding.snapshot.generation; + }; + const resolveSelectionAdapter = (provider: OcxProviderConfig, retention = config.cacheRetention): ProviderAdapter => { + const resolved = resolveAdapter(provider, retention); + if (route.provider.authMode === "forward") return resolved; + const binding: DispatchBinding | undefined = route.provider.authMode === "oauth" + ? oauthSelection && servingOAuthSnapshot + ? { kind: "oauth", selection: { ...oauthSelection }, snapshot: servingOAuthSnapshot } + : undefined + : { kind: "api-key", provider: { ...route.provider } }; + if (binding) adapterBindings.set(resolved, binding); + const build = resolved.buildRequest.bind(resolved); + resolved.buildRequest = async (requestParsed, incoming) => { + const request = await build(requestParsed, incoming); + // Capture at adapter creation, never from mutable serving state after an await. + if (binding) requestBindings.set(request, binding); + return request; + }; + if (resolved.runTurn) { + rawRunTurns.set(resolved, resolved.runTurn.bind(resolved)); + resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit); + } + return resolved; + }; + const refreshDispatchAdapter = async (requestParsed: OcxParsedRequest): Promise => { + if (route.provider.authMode === "oauth") { + if (!servingOAuthSnapshot || !await applyFailoverSnapshot(servingOAuthSnapshot, requestParsed)) { + throw new Error("OAuth account selection changed before dispatch"); + } + } else { + const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, route.provider); + if (!current) throw new Error("API key selection is unavailable before dispatch"); + route.provider = current; + } + adapter = activeAdapter = runTurnAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + ); + invalidateSameTargetRequest(); + return adapter; + }; + const refreshRunTurnAdapter = async (requestParsed: OcxParsedRequest): Promise => { + requestParsed._cursorIdentityScope = undefined; + requestParsed._cursorConversationId = undefined; + if (requestParsed._providerContinuation?.cursor) { + const { cursor: _oldCursor, ...rest } = requestParsed._providerContinuation; + requestParsed._providerContinuation = rest; + } + return refreshDispatchAdapter(requestParsed); + }; + const runSelectedTurn = async ( + selectedAdapter: ProviderAdapter, + ...[requestParsed, incoming, emit]: Parameters> + ): Promise => { + for (let attempt = 0; attempt < 3; attempt++) { + if (!selectionIsCurrent(adapterBindings.get(selectedAdapter))) selectedAdapter = await refreshRunTurnAdapter(requestParsed); + const binding = adapterBindings.get(selectedAdapter); + const run = rawRunTurns.get(selectedAdapter); + if (!run) throw new Error("Selected provider no longer supports this turn transport"); + let sent = false; + let refused = false; + // Both main and image-loop callers already acquired the initial pacing slot. + // Subsequent physical messages retain this adapter/credential and are paced normally. + const fetch = providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, modelId: route.modelId, pacingSlotAcquired: true, + beforeDispatch: () => { + if (sent) return; + if (!selectionIsCurrent(binding)) { + refused = true; + throw new Error("Account selection changed before the first turn dispatch"); + } + sent = true; + }, + }); + try { + await run(requestParsed, { ...incoming, providerFetch: fetch }, event => { if (!refused) emit(event); }); + } catch (error) { + if (!refused) throw error; + } + if (!refused) return; + // The adapter may map the guard's exception to an error event. Neither that + // event nor a refused send may escape before retrying the newly selected account. + selectedAdapter = await refreshRunTurnAdapter(requestParsed); + } + throw new Error("Account selection changed repeatedly before turn dispatch"); + }; + const oauthDispatch = (wireRequest: AdapterRequest, requestParsed = parsed): ProviderFetchOptions["dispatchOverride"] => { + if (route.provider.authMode === "forward") return undefined; + return async (input, init, execute) => { + let destination = input; + let dispatchInit = init; + for (let attempt = 0; attempt < 3; attempt++) { + if (selectionIsCurrent(requestBindings.get(wireRequest))) { + const fetchImpl = (route.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? execute; + return fetchImpl(destination, dispatchInit); + } + const nextAdapter = await refreshDispatchAdapter(requestParsed); + const rebuilt = await nextAdapter.buildRequest(requestParsed, { + headers: selectedForwardHeaders, translatorBudget, + ...(imageTierBias > 0 ? { imageTierBias } : {}), + }); + const bodySize = checkOutboundBodySize(rebuilt.body, config.maxUpstreamBodyBytes); + if (!bodySize.admitted) { + rebuilt.releaseBodyObservation?.(); + return formatErrorResponse(413, "outbound_body_too_large", describeOutboundBodyRefusal(bodySize)); + } + const headers = new Headers(dispatchInit.headers); + for (const name of Object.keys(wireRequest.headers)) headers.delete(name); + for (const [name, value] of Object.entries(rebuilt.headers)) headers.set(name, value); + wireRequest.releaseBodyObservation?.(); + Object.assign(wireRequest, rebuilt); + const binding = requestBindings.get(rebuilt); + if (binding) requestBindings.set(wireRequest, binding); + else requestBindings.delete(wireRequest); + sameTargetRequest = wireRequest; + sameTargetParsed = requestParsed; + sameTargetToken = transportToken; + destination = rebuilt.url; + dispatchInit = { ...dispatchInit, method: rebuilt.method, headers, body: rebuilt.body }; + bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider, + adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); + // The next iteration validates synchronously and calls fetch in that same turn. + } + throw new Error("OAuth account selection changed repeatedly before dispatch"); + }; + }; + const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" + ? anthropicSessionKeyFromParts({ + sessionIdHeader: sessionIdHeaderFromRequest(req.headers), + threadIdHeader: req.headers.get("thread-id"), + promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null, + clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null, + promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true, + }) + : null; + if (route.provider.authMode === "oauth") { + try { + if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) { + const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config); + if (!selection.accountId) { + if (selection.reason === "all-cooled") { + const retryAfterSec = getAnthropicPoolRetryAfterSeconds(); + return formatErrorResponse( + 429, + "rate_limit_error", + "All Anthropic OAuth accounts are temporarily rate-limited", + retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined, + ); + } + return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available"); + } + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(selection.accountId), true, selection.reason); + if (!admitted) return formatErrorResponse(409, "conflict_error", "OAuth account selection changed; retry the request"); + anthropicPoolAccountId = admitted.accountId; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); + } else { + // Prefer the account with known headroom BEFORE the first attempt. Rotation alone + // only reacts to a 429, so a turn could open on an account a previous probe already + // measured as spent. A null answer means "use the active account", so every provider + // without quota evidence keeps the resolution it has today. + const preferredAccountId = isGenericFailoverProvider(route.providerName, route.provider) + ? preferredInitialAccount(config, route.providerName) + : null; + // Resolved account-scoped, NOT through failoverAccountSnapshot: that helper marks a + // rotation site, and rotation sites must apply their credential through + // applyFailoverSnapshot's pairing rules. This is initial resolution — the code below + // already pairs the snapshot's Kiro metadata, Copilot origin and Antigravity project + // with this same bearer, exactly as it does for the active account. + let usedPreferredAccount = preferredAccountId !== null; + let resolved: OAuthAccessSnapshot; + if (preferredAccountId) { + try { + // `requireUsableAccount` makes a removed OR reauth-flagged account throw from + // inside the resolver's own store read. Without it a revoked account resolves + // successfully — its credential is still readable — and the request would + // dispatch on an account already known to need a fresh login. + resolved = await getValidAccessSnapshotForAccount( + route.providerName, + preferredAccountId, + { requireUsableAccount: true }, + ); + } catch { + // The roster is read behind a short TTL, so a preferred account can be removed + // or flagged for reauth in the window after it was cached. Resolving it then + // throws, and a PREFERENCE that turns a healthy request into a 401 is worse + // than no preference at all — the active account is still perfectly usable. + // Drop the stale roster so the next request re-reads it, and carry on. + forgetGenericFailoverRoster(route.providerName); + usedPreferredAccount = false; + resolved = await getValidAccessTokenSnapshot(route.providerName); + } + } else { + resolved = await getValidAccessTokenSnapshot(route.providerName); + } + // A Cloud Code Assist account needs its own project. Antigravity's refresh path + // tolerates project discovery failing, so a stored account can legitimately have + // none — and a PREFERENCE must never turn a working request into an error. Fall + // back to the ordinary active-account resolution instead, which is exactly what + // would have happened had the preference never existed. + if (usedPreferredAccount && route.provider.googleMode === "cloud-code-assist" && !resolved.projectId) { + resolved = await getValidAccessTokenSnapshot(route.providerName); + usedPreferredAccount = false; + } + const admitted = await commitResolvedOAuthSelection(resolved, true); + if (!admitted) return formatErrorResponse(409, "conflict_error", "OAuth account selection changed; retry the request"); + if (admitted.accountId !== resolved.accountId) usedPreferredAccount = true; + resolved = admitted; + replayOAuthCredentialSnapshot = { + accountId: resolved.accountId, + generation: resolved.generation, + }; + if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; + route.provider = { ...route.provider, apiKey: resolved.accessToken }; + // Attribution is independent of failover (#2699): stamped from the resolved snapshot + // itself, not from inside the `isGenericFailoverProvider` branch below, so a future + // narrowing of that predicate cannot silently switch attribution off. + stampOAuthAccountLabel(logCtx, route.providerName, route.provider, resolved.accountId); + // Remember which account actually served this request so a 429 cools THAT one, not + // whichever account is active by the time the response comes back (#2568). + if (isGenericFailoverProvider(route.providerName, route.provider)) { + genericFailoverAccountId = resolved.accountId; + } + // Anthropic is excluded from isGenericFailoverProvider -- its own pool owns affinity and + // a fail-closed local-cli credential rule -- so without this stamp its identity is + // dropped whenever the pool flag is off, and a later 429 has no account to cool. Reactive + // failover needs only the id: no affinity bind, no promotion, no quota-ranked pick. Those + // are proactive and stay behind anthropicAccountPool.enabled. + if (route.providerName === "anthropic" && hasAnthropicFailoverQuorum()) { + anthropicPoolAccountId = resolved.accountId; + } + // Captured beside the account it fences, so the two can never disagree. + if (hasPassiveAccountQuota(route.providerName)) { + passiveQuotaWriterGeneration = captureConfigGeneration(); + } + if (route.providerName === "kiro") { + // `{}` is intentional: this is an account-scoped request with no stored routing metadata. + // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. + parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) }; + } + // Project identity belongs to the admitted account on EVERY request, including + // the request after a pool transition made that account the persisted active one. + if (route.provider.googleMode === "cloud-code-assist") { + if (!resolved.projectId) return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist account project is unavailable"))); + route.provider = { ...route.provider, project: resolved.projectId }; + } + } + } catch (err) { + if (err instanceof UnsupportedOAuthProviderError) { + const safeProviderName = redactSecretString(route.providerName); + return formatErrorResponse( + 400, + "invalid_request_error", + `${redactSecretString(err.message)}. Remove or reconfigure provider '${safeProviderName}' in the OpenCodex configuration.`, + ); + } + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); + } + } + route.provider = resolveProviderTransport( + route.providerName, + route.provider, + parsed.options.promptCacheKey, + route.providerName === "github-copilot" && route.provider.authMode === "oauth" + ? resolveCopilotApiBaseUrl(sentOAuthSnapshot?.apiBaseUrl) + : undefined, + ); + let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); + const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true + && adapterProvider.adapter === "openai-responses" + && adapterProvider.authMode === "forward" + && !isCanonicalOpenAiForwardProvider(adapterProvider); + if (stripClaudeMainAuth) { + releaseCodexAuthContextProbeLease(authCtx); + authCtx = { kind: "main", accountId: null }; + route.provider = stripCodexRuntimeProviderFields(route.provider); + adapterProvider = stripCodexRuntimeProviderFields(adapterProvider); + selectedForwardHeaders = new Headers(selectedForwardHeaders); + selectedForwardHeaders.delete("authorization"); + selectedForwardHeaders.delete("chatgpt-account-id"); + delete route.codexAccountMode; + delete route.codexAccountId; + delete route.codexAccountNamespace; + logCtx.provider = route.providerName; + delete logCtx.accountLogLabel; + } + adapter = resolveSelectionAdapter(adapterProvider, config.cacheRetention); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: adapterProvider, + adapterName: adapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, + codexAuthContext: authCtx, + forwardHeaders: selectedForwardHeaders, + }); + if (!logCtx.conversationId && parsed._cursorConversationId) { + logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); + } + logCtx.providerAdapter = adapter.name; + // Ordinary requests receive one durable attempt only after their final initial + // adapter is resolved. Combo children own their attempt and retries keep it. + if (!options.comboAttempt && !logCtx.activeAttempt) { + const attempt = beginRequestAttempt( + (logCtx.attempts?.length ?? 0) + 1, + logCtx.provider, + route.modelId, + adapter.name, + ); + logCtx.activeAttempt = attempt; + logCtx.activeAttemptStartedAt = Date.now(); + (logCtx.attempts ??= []).push(attempt); + } + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, adapterProvider, adapter.name); + runTurnAdapter = adapter; + if (adapter.runTurn) { + recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed)); + } + // Optional route-identity linkage for attempt correlation (CL-09 consumes it). The slot + // resolves to null unless an opt-in subsystem registered a linker, so an install without + // routing profiles does no work here and loads no additional module. The non-throwing + // guarantee lives in the slot helper. + if (logCtx.activeAttempt && !logCtx.activeAttempt.labRouteSubjectId) { + const passiveSubjectId = resolvePassiveRouteSubjectId( + config, + route.providerName, + route.modelId, + route.provider, + inboundWire, + ); + if (passiveSubjectId) logCtx.activeAttempt.labRouteSubjectId = passiveSubjectId; + } + const isPassthrough = "passthrough" in adapter && !!adapter.passthrough; + + const rawInput = (parsed._rawBody as { input?: unknown }).input; + if (!isPassthrough && Array.isArray(rawInput) && rawInput.some( + item => item !== null && typeof item === "object" && item.type === "computer_call_output", + )) { + return formatErrorResponse( + 400, + "invalid_request_error", + "computer_call_output requires a Responses passthrough route; send screenshots as user input_image content on translated routes.", + ); + } + + if (adapter.name === "kiro" && parsed.previousResponseId && !parsed._previousResponseInputExpanded) { + return formatErrorResponse( + 400, + "invalid_request_error", + "Kiro continuation state is missing; start a new session instead of reusing this previous_response_id.", + ); + } + + let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined; + const needsOpenAiVision = shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed); + const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough); + if (needsOpenAiVision || needsOpenAiSearch) { + try { + openAiSidecar = await resolveFirstUsableOpenAiSidecar( + listOpenAiForwardSidecarCandidates(config), + req.headers, + config, + { + admission: options.admission, + codexAuthPolicy: options.codexAuthPolicy, + // Account-qualified native routes are passthrough, so their in-turn helper is vision. + // Scope its cooldown and outcome to the helper model, not the routed text model. + ...(route.codexAccountId !== undefined + ? { exactAccount: { accountId: route.codexAccountId, modelId: resolveOpenAiVisionModel(config) } } + : {}), + beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + }, + ); + } catch (err) { + // Sidecars are optional helpers for an otherwise independent routed turn. + // An unavailable/cooling/expired Multi credential disables the helper; it + // must not turn a valid routed-provider request into a Codex-auth failure. + if ( + !(err instanceof CodexPoolAuthenticationError) + && !(err instanceof CodexAuthContextError) + && !(err instanceof CodexAccountCooldownError) + && !(err instanceof CodexThreadAffinityExpiredError) + && !(err instanceof CodexMainProfileDrainingError) + ) throw err; + } + } + + // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each + // attached image through the selected sidecar backend and replace it with text BEFORE the main + // call, so the text-only model can reason about it. + // Terminal describe fence (roadmap 180): the sidecar's OWN loopback describe + // call must never plan another describe. The flag arrives from the Chat + // surface (whose bridge rebuilds headers) or as the raw header for native + // Responses callers. Marked + text-only routed model → strip, depth cap 1. + const visionDescribeTerminal = options.visionDescribeTerminal === true; + const visionPlan = visionDescribeTerminal + ? undefined + : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, { + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, + }); + const recordSidecarOutcome = openAiSidecar?.recordOutcome; + if (visionPlan) { + await describeImagesInPlace( + parsed, + visionPlan, + openAiSidecar?.headers ?? selectedForwardHeaders, + options.abortSignal, + recordSidecarOutcome, + translatorBudget, + ); + } else if (isModelTextOnly(route.provider, route.modelId)) { + // Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar + // disabled): fail closed — never forward raw images to a text-only upstream. + stripImagesInPlace(parsed, translatorBudget); + } + + const recordTerminalOutcomes = options.recordTerminalOutcomes !== false; + + const continuationStateForResponse = ( + emitted?: OcxProviderContinuationState, + ): OcxProviderContinuationState | undefined => { + const cursorConversationId = parsed._cursorConversationId; + const inherited = providerContinuationPayload(parsed._providerContinuation); + const emittedPayload = providerContinuationPayload(emitted); + if (!emittedPayload && !inherited && !cursorConversationId) return undefined; + const merged = mergeProviderContinuationPayload( + inherited ?? {}, + emittedPayload ?? {}, + ) as OcxProviderContinuationState; + if (cursorConversationId) { + merged.cursor = { ...(merged.cursor ?? {}), conversationId: cursorConversationId }; + } + return parsed._providerContinuationOwner + ? { ...merged, __ocxOwner: { ...parsed._providerContinuationOwner } } + : merged; + }; + + // Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly + // one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it + // natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search + // sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts). + // A Responses-shaped wire does not imply support for Codex's private + // `compaction_trigger` item — only the canonical ChatGPT backend speaks that + // contract. An API-key gateway would receive the trigger, answer with an ordinary + // message, and leave Codex fataling on a missing compaction item (#422). + const routedCompaction = parsed._compactionRequest === true + && !isCanonicalOpenAiForwardProvider(route.provider); + const commitReasoningReplayServingRoute = (): void => { + commitReasoningReplayServingIdentity(parsed._reasoningReplayScope); + }; + if (routedCompaction) { + delete parsed.context.tools; + delete parsed._webSearch; + delete parsed.options.toolChoice; + delete parsed.options.parallelToolCalls; + // The compaction turn is a plain prose summary; a surviving structured-output format + // would force schema-constrained JSON into the synthetic compaction item. The flag and + // the raw `text` control go too: the key-mode openai-responses adapter builds from + // _rawBody, so a surviving format there would still reach the upstream. (The Kiro + // guard no longer reads _rawBody.text; it refuses structured output only.) + delete parsed.options.textFormat; + delete parsed._structuredOutput; + if (parsed._rawBody && typeof parsed._rawBody === "object") { + delete (parsed._rawBody as Record).text; + } + parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() }); + } + + let routedNamespaceToolAliases: RoutedNamespaceToolAliases = new Map(); + const refreshRoutedNamespaceToolAliases = (builtRequest: AdapterRequest): void => { + routedNamespaceToolAliases = builtRequest.convertedRoutedNamespaceToolAliases ?? new Map(); + }; + + if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { + let hostAdmissionLease = pendingHostAdmissionLease; + pendingHostAdmissionLease = null; + try { + const imageGenCallAliases = route.provider.authMode === "forward" + ? new Map() + : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); + const routedCustomToolNames = new Set(); + const routedCustomToolRepairNames = new Set(); + const routedToolSearchNames = new Set(); + // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with + // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex + // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY + // way a chained turn keeps its earlier context is the local replay expansion. Record + // completed passthrough responses (force bypasses Codex's blanket store:false) so the next + // turn's expansion hits. Never record a body whose own previous_response_id failed to + // expand: its input is a delta, and storing it would replay a truncated conversation. + // Compaction turns are excluded: _rawBody still carries the full pre-compaction history and + // recording it would let a later expansion rehydrate the chain Codex just replaced. + const passthroughRecordEligible = parsed._compactionRequest !== true + && (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true); + const rememberPassthroughResponse = passthroughRecordEligible + ? (response: { id?: unknown; output?: unknown; status?: unknown }) => + rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true)) + : undefined; + if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) { + console.warn( + `[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state ` + + `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`, + ); + } + // Preserve the caller's readable catalog boundary before provider-specific normalization can + // remove an unsupported final entry (for example xAI cached-only web search). + const replayedInputPrefixLength = parsed._replayPrefixLen ?? 0; + const clientToolAuthorizationBody = currentTurnWireToolCatalogBody( + parsed._rawBody, + replayedInputPrefixLength, + ); + const selfNamedNamespaceScrubAuthorization = collectSelfNamedNamespaceScrubAuthorization( + clientToolAuthorizationBody, + toolBridgeMaps.bareCustomToolNames, + toolBridgeMaps.bareFunctionToolNames, + ); + const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody); + const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody); + const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes( + clientToolAuthorizationBody, + ); + // Hosted calls the PROVIDER runs itself. Gated on the destination actually being xAI, so a + // declaration alone cannot buy the exemption on some other upstream that never serves it. + // Provider-executed declarations are authorized from the actual outbound body, after the + // adapter has applied destination-specific injection and normalization. Client-executed tool + // authority remains bounded to the caller-owned catalog above. + const providerExecutedCallTypes = new Set(); + let request: Awaited>; + try { + request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); + } catch (error) { + releaseCodexAuthContextProbeLease(authCtx); + // A tool catalog this proxy cannot lower onto one wire namespace is a client input error, and + // the rotation-rebuild and bridged paths already answer 400 for the identical throw. Rethrowing + // it here escaped every catch up to the Bun handler, so the same request produced an + // unstructured 500 — and no request log — depending only on whether a rotation ran first. + // Same shape for a tool_choice this proxy cannot honor: the destination rejects a schema the + // catalog had to drop, so the selector naming it is a client input error, not a 500. + if (error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError) { + return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); + } + throw error; + } + const functionRepairSchemas = isCanonicalOpenAiForwardProvider(route.provider) + ? new Map() + : collectFunctionCallRepairSchemas(clientToolAuthorizationBody); + if (!isCanonicalOpenAiForwardProvider(route.provider)) { + for (const name of request.convertedRoutedCustomToolNames ?? []) { + if ( + toolBridgeMaps.freeformToolNames.has(name) + || toolBridgeMaps.toolNsMap.get(name)?.freeform === true + ) routedCustomToolNames.add(name); + } + for (const name of request.routedCustomToolRepairNames ?? []) { + if ( + toolBridgeMaps.freeformToolNames.has(name) + || toolBridgeMaps.toolNsMap.get(name)?.freeform === true + ) routedCustomToolRepairNames.add(name); + } + } + for (const name of request.convertedRoutedToolSearchNames ?? []) { + // The adapter already keeps this set empty when tool_choice forbids the private search. + // Its wire name may be collision-aliased, so comparing it to the caller-facing name here + // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes. + routedToolSearchNames.add(name); + } + refreshRoutedNamespaceToolAliases(request); + // #1700: the bridged paths refuse a call to a tool the request never declared + // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed + // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested + // `tools.apply_patch(...)` helper inside `exec`, never as a wire tool — reached Codex as a + // call it cannot execute, and the turn showed a bare `aborted` with the file untouched. + // Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a + // routed provider, so it keeps passing through unguarded, as it does for the rewrites above. + // The guard needs a catalog to compare against, so it stands down when the request omits one. + // An explicit empty catalog is still authoritative: it declares that no client tools may be + // called. A passthrough request can legitimately omit `tools` entirely and still receive a call + // the client understands — `tests/providers/github-copilot/github-copilot-stream-contract.test.ts` sends + // `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for + // `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there + // too because the proxy cannot establish the caller's declared authorization boundary. + const parseOutboundRequestBody = (bodyText: string): Record | undefined => { + try { + const body = JSON.parse(bodyText) as unknown; + return body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : undefined; + } catch { + return undefined; + } + }; + let outboundRequestBody: Record | undefined; + const declaredWireToolNames = new Set(); + const declaredNamelessClientCallTypes = new Set(); + // `buildToolBridgeMaps` creates a bare alias only when the caller selected exactly one + // namespaced tool through a bare tool_choice. Restore that request-bounded identity before + // authorization checks instead of admitting the bare name into the declared set: for `exec`, + // the latter would also authorize the unrelated code-mode helper names. + const authorizedBareNamespaceToolAliases: RoutedNamespaceToolAliases = new Map( + [...toolBridgeMaps.toolNsMap].flatMap(([alias, identity]) => + alias === identity.name + ? [[alias, { + namespace: identity.namespace, + name: identity.name, + kind: identity.freeform ? "custom" as const : "function" as const, + }] as const] + : [] + ), + ); + const restoreAuthorizedBareNamespaceToolCalls = (value: unknown): unknown => + restoreRoutedNamespaceCalls(value, authorizedBareNamespaceToolAliases).value; + const normalizeFunctionCompletionJson = (text: string): string => { + const snapshot = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) + ? repairResponsesSnapshotJson(text, outboundRequestBody) + : text; + // Sparse gateways need completion status inferred before schema repair can + // distinguish completed arguments from in-progress placeholders. + return repairFunctionCallsInJson(backfillResponsesFieldsJson(snapshot), functionRepairSchemas); + }; + let undeclaredToolGuardActive = false; + const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => { + outboundRequestBody = parseOutboundRequestBody(builtRequest.body); + providerExecutedCallTypes.clear(); + if (isXaiResponsesDestination(route.provider)) { + // Preserve the caller-declared authorization recognized by the original classifier, then + // add adapter-injected declarations from the actual current-turn outbound catalog. + for (const callType of collectProviderExecutedCallTypes(clientToolAuthorizationBody)) { + providerExecutedCallTypes.add(callType); + } + const currentOutboundCatalog = currentTurnWireToolCatalogBody( + outboundRequestBody, + replayedInputPrefixLength, + ); + for (const callType of collectProviderExecutedCallTypes(currentOutboundCatalog)) { + providerExecutedCallTypes.add(callType); + } + } + declaredWireToolNames.clear(); + // With no replay prefix the full outbound body belongs to this turn and its normalized + // aliases are authoritative. A continuation's outbound body still contains historical + // catalogs (and may promote historical tool-search definitions), so it can never widen the + // current caller snapshot captured above. + if (replayedInputPrefixLength === 0) { + for (const name of collectDeclaredWireToolNames(outboundRequestBody)) { + declaredWireToolNames.add(name); + } + } + for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name); + declaredNamelessClientCallTypes.clear(); + if (replayedInputPrefixLength === 0) { + for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) { + declaredNamelessClientCallTypes.add(callType); + } + } + for (const callType of clientDeclaredNamelessCallTypes) { + declaredNamelessClientCallTypes.add(callType); + } + // On an ordinary request these maps capture caller-catalog identities that normalization may + // replace on the outbound wire (for example a client image tool becoming hosted). On replay, + // however, the parsed maps also contain historical catalog entries, so only the bounded + // current-turn wire snapshot above may authorize a call. + if (replayedInputPrefixLength === 0) { + for (const name of toolBridgeMaps.declaredToolNames) { + // `buildToolBridgeMaps` also aliases a namespaced tool under its bare name when the + // caller's `tool_choice` selected it unambiguously, which the bridge needs to route the + // call back. For `exec` alone that alias would also switch on nested-helper + // normalization and re-authorize `exec_command`/`shell_command`/`apply_patch`, so it is + // admitted here only when the caller's own catalog declared a bare `exec`. Selecting an + // MCP `exec` is not a declaration of the code-mode shell tool. + if ( + name === CODE_MODE_EXEC_TOOL_NAME + && !clientDeclaredWireToolNames.has(CODE_MODE_EXEC_TOOL_NAME) + ) continue; + declaredWireToolNames.add(name); + } + } + undeclaredToolGuardActive = ( + declaredWireToolNames.size > 0 + || clientDeclaredNamelessCallTypes.size > 0 + || clientExplicitWireToolCatalog + ) && route.provider.authMode !== "forward"; + }; + refreshUndeclaredToolGuard(request); + // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the + // untouched upstream stream, so it can still observe a `response.completed` the client never + // received; checking the payload itself rather than a flag shared with the client relay keeps + // this free of tee ordering races. + // + // Checking only the terminal snapshot is not enough. An upstream can announce the undeclared + // call in `response.output_item.added`, which trips the client guard, and then close with a + // `response.completed` whose `output` is empty. The client gets `response.failed`, the terminal + // check sees nothing undeclared, and the refused turn enters continuation state anyway. So the + // rejection is sticky for the whole turn, set from every parsed payload on the inspection side. + let inspectionSawUndeclaredTool = false; + const passiveQuotaObserved = hasPassiveAccountQuota(route.providerName) + && route.provider.authMode === "oauth"; + const noteInspectedPayload = (payload: unknown) => { + // Meta reports subscription usage ONLY as an in-stream event; there is no endpoint + // to poll (003 §E probed 17 paths, all 404). Observed here rather than behind a + // dedicated inspector handler because onParsedPayload already reaches every + // passthrough shape -- eager relay and both tee consumers -- through this one + // function. + // + // Placed BEFORE the undeclared-tool early return below, which is load-bearing: that + // guard latches for the rest of the turn once it fires, and a turn that tripped it + // still legitimately reports usage. + if (passiveQuotaObserved && isMuseSubscriptionUsagePayload(payload)) { + const quota = parseMuseSubscriptionUsage(payload); + // Read at EVENT time, not at handler construction: failover rebinds this, and the + // quota belongs to the account that actually served the turn. + const servingAccountId = genericFailoverAccountId; + if (quota && servingAccountId) { + recordPassiveAccountQuota(route.providerName, servingAccountId, quota, passiveQuotaWriterGeneration); + } + } + // Gated on the same flag as the guard itself: with no readable catalog (or a forward-auth + // provider) every name looks undeclared, and flipping this would stop recording continuation + // state for exactly the passthrough traffic the guard deliberately stands down for. + if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return; + if (undeclaredToolCallName( + restoreAuthorizedBareNamespaceToolCalls(payload), + declaredWireToolNames, + declaredNamelessClientCallTypes, + providerExecutedCallTypes, + ) !== undefined) { + inspectionSawUndeclaredTool = true; + } + }; + const rememberPassthroughResponseChecked = rememberPassthroughResponse + ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { + if (inspectionSawUndeclaredTool) return; + const restored = restoreRoutedCustomCalls( + restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), + routedCustomToolNames, + routedCustomToolRepairNames, + declaredWireToolNames, + ).value; + const restoredResponse = (functionRepairSchemas.size > 0 + ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) + : restored) as { id?: unknown; output?: unknown; status?: unknown }; + if ( + undeclaredToolGuardActive + && undeclaredToolCallNameInResponse( + restoredResponse, + declaredWireToolNames, + declaredNamelessClientCallTypes, + providerExecutedCallTypes, + ) !== undefined + ) { + return; + } + rememberPassthroughResponse(restoredResponse); + } + : undefined; + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + const actualHostKey = upstreamHostHealthKey( + route.providerName, + safeOriginLabel(request.url), + ); + const hostKey = route.provider.authMode === "forward" + ? actualHostKey + : null; + const hostCircuitEnabled = hostKey !== null + && normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0; + if (hostKey !== null && !hostCircuitEnabled) { + disableUpstreamHostCircuitForKey(actualHostKey); + } + if (hostAdmissionLease && hostAdmissionLease.key !== hostKey) { + return formatErrorResponse(502, "upstream_error", "Provider host changed after circuit admission"); + } + if (options.abortSignal?.aborted) { + releaseCodexAuthContextProbeLease(authCtx); + return clientCancelledResponse(); + } + if (!hostAdmissionLease && hostCircuitEnabled) { + const admission = acquireUpstreamHostAdmission( + hostKey!, + config.upstreamHostCircuitThreshold, + ); + if (admission.kind === "blocked") { + releaseCodexAuthContextProbeLease(authCtx); + return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds); + } + hostAdmissionLease = admission.lease; + } + const settleObservedHostResponse = (): void => { + if (hostCircuitEnabled) { + resetUpstreamHostHealth(actualHostKey, hostAdmissionLease); + } else { + resetUpstreamHostHealth(actualHostKey); + } + hostAdmissionLease = null; + }; + let passthroughEstimate = typeof request.usageLog?.inputTokens === "number" + ? request.usageLog.inputTokens + : undefined; + if (passthroughEstimate !== undefined) { + logCtx.usageLogInputTokens = passthroughEstimate; + } + // Abort the upstream if the client disconnects. A directly-relayed body does not propagate the + // consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort, + // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path). + const upstream = new AbortController(); + linkAbortSignal(upstream, options.abortSignal); + const connectMs = config.connectTimeoutMs ?? 200_000; + let upstreamResponse: Response; + /** + * Refuse a built body that exceeds the operator's configured ceiling, before it is sent. + * + * Unconfigured this measures nothing and returns undefined, so an unset proxy behaves + * exactly as it does today. Runs at every point a body is built or rebuilt, because a + * rebuild can produce a payload the initial check never saw. + */ + const refuseOversizedOutboundBody = ( + builtRequest: AdapterRequest, + refusalAuthCtx: CodexAuthContext = authCtx, + ): Response | undefined => { + const result = checkOutboundBodySize(builtRequest.body, config.maxUpstreamBodyBytes); + if (result.admitted) return undefined; + + // This returns before the surrounding fetch/finally owns the observation, so release + // it here or one refused body holds translator budget for the process lifetime. + builtRequest.releaseBodyObservation?.(); + upstream.abort(); + releaseUpstreamHostAdmission(hostAdmissionLease); + hostAdmissionLease = null; + releaseCodexAuthContextProbeLease(refusalAuthCtx); + logCtx.errorCode = "outbound_body_too_large"; + console.warn( + `[responses] refused an oversized outbound body: bytes=${result.bytes} limit=${result.limit} ` + + `input_images=${result.imageCount} image_bytes=${result.imageBytes} ` + + `model=${JSON.stringify(parsed.modelId)}`, + ); + // A streaming client treats HTTP 413 as a retryable transport error and resends the same + // oversized body — the reconnect loop #3177 exists to stop. Terminal overflow is the + // honest shape, and it is what the upstream-413 path already returns. + if (clientRequestedStream) { + return streamingContextOverflowResponse( + parsed._responseModelId ?? parsed.modelId, + translatorBudget, + ); + } + return formatErrorResponse( + 413, + "outbound_body_too_large", + describeOutboundBodyRefusal(result), + ); + }; + const transportFailureResponse = (err: unknown): Response => { + upstream.abort(); + if (options.abortSignal?.aborted) { + releaseUpstreamHostAdmission(hostAdmissionLease); + hostAdmissionLease = null; + releaseCodexAuthContextProbeLease(authCtx); + return clientCancelledResponse(); + } + const localRefusal = mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(err), { + now: Date.now(), accountSelector: route.codexAccountNamespace, + }); + if (localRefusal) { + releaseUpstreamHostAdmission(hostAdmissionLease); + hostAdmissionLease = null; + releaseCodexAuthContextProbeLease(authCtx); + return localRefusal; + } + const outcome = classifyTransportFailureKind(err); + // Host-level evidence stands regardless of pool membership: a direct + // forward send has no pool accounting, but the reachability failure is + // still host-wide, not account evidence (#914 review). + if (outcome === "connect_neutral") { + if (hostCircuitEnabled) { + recordUpstreamHostFailure(actualHostKey, { + code: transportErrorCode(err), + threshold: config.upstreamHostCircuitThreshold, + lease: hostAdmissionLease, + }); + } else { + recordUpstreamHostFailure(actualHostKey, { code: transportErrorCode(err) }); + } + hostAdmissionLease = null; + } else { + releaseUpstreamHostAdmission(hostAdmissionLease); + hostAdmissionLease = null; + } + if (usesCodexForwardPoolAuth(authCtx, route.provider)) { + recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + threadId: authCtx.affinityKey, + fixedAccount: authCtx.fixedAccount, + modelId: route.modelId, + probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), + writerGeneration: authCtx.writerGeneration, + }); + } + const msg = outcome === "timeout" + ? `Provider connect timeout after ${connectMs}ms` + : describeUpstreamConnectFailure(err, connectMs); + return formatErrorResponse(502, "upstream_error", msg); + }; + const initialBodyRefusal = refuseOversizedOutboundBody(request); + if (initialBodyRefusal) return initialBodyRefusal; + try { + // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010): + // the ChatGPT backend emits transient 502/520s that an immediate retry absorbs. + // Body is a replayable string; nothing has streamed to the client yet. + upstreamResponse = await fetchWithTransientRetry( + recovery => { + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery); + return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, recovery), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + route.provider.authMode === "forward") + // Every real attempt response — including an intermediate 5xx the + // retry wrapper replaces — proves the host was reached (#914 review). + .then(res => { + settleObservedHostResponse(); + return res; + }); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + ); + } catch (err) { + return transportFailureResponse(err); + } finally { + request.releaseBodyObservation?.(); + } + + const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; + let oauth401ReplayAttempted = false; + let codex401ReplayKind: "main" | "stored" | null = null; + const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider); + let rateLimitRetries = 0; + const rebuildAndRefetch = async ( + recovery: AttemptRecoveryKind, + ): Promise => { + const retryAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + if (!("passthrough" in retryAdapter) || !retryAdapter.passthrough) { + upstream.abort(); + return { failed: formatErrorResponse(502, "upstream_error", "Recovery changed the provider wire unexpectedly") }; + } + try { + request = await retryAdapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + }); + refreshRoutedNamespaceToolAliases(request); + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + } catch (err) { + upstream.abort(); + if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; + const msg = err instanceof Error ? err.message : String(err); + return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) }; + } + passthroughEstimate = typeof request.usageLog?.inputTokens === "number" + ? request.usageLog.inputTokens + : undefined; + if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate; + refreshUndeclaredToolGuard(request); + logCtx.providerAdapter = retryAdapter.name; + sealRequestAttemptIdentity( + logCtx.activeAttempt, + logCtx.provider, + retryAdapter.name, + logCtx.accountLogLabel, + ); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name); + const rebuiltBodyRefusal = refuseOversizedOutboundBody(request); + if (rebuiltBodyRefusal) return { failed: rebuiltBodyRefusal }; + try { + return await fetchWithTransientRetry( + innerRecovery => { + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery); + return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, innerRecovery), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + route.provider.authMode === "forward") + .then(response => { + settleObservedHostResponse(); + return response; + }); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + ); + } catch (err) { + return { failed: transportFailureResponse(err) }; + } finally { + request.releaseBodyObservation?.(); + } + }; + + // Keep recovery kinds in sync with the generic `recovery:` loop below. + passthroughRecovery: for (;;) { + + if ( + upstreamResponse.status === 401 + && (authCtx.kind === "main-pool" || authCtx.kind === "pool") + && usesCodexForwardPoolAuth(authCtx, route.provider) + && codex401ReplayKind === null + ) { + codex401ReplayKind = authCtx.kind === "pool" ? "stored" : "main"; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed */ } + const poolAuthCtx = authCtx.kind === "pool" ? authCtx : undefined; + const poolReplay = poolAuthCtx + ? await refreshPoolForwardAuth({ req, config, route, authCtx: poolAuthCtx, substituteMainCredential, options }) + : undefined; + const replay = poolReplay + ?? await refreshNativeMainForwardAuth({ req, config, route, authCtx, substituteMainCredential, options }); + if (!replay.ok) { + // Compact already records this; core historically returned without recording, + // so a dead grant stayed selectable and every request repeated the same doomed + // refresh. Fenced by the generation the 401 belongs to (#2887). + if (poolAuthCtx && poolReplay && !poolReplay.ok && poolReplay.quarantine) { + recordCodexUpstreamOutcome(config, poolAuthCtx.accountId, 401, { + threadId: poolAuthCtx.affinityKey, + fixedAccount: poolAuthCtx.fixedAccount, + modelId: route.modelId, + writerGeneration: poolAuthCtx.writerGeneration, + credentialGeneration: poolReplay.quarantineGeneration ?? poolAuthCtx.generation, + }); + } + upstream.abort(); + releaseCodexAuthContextProbeLease(authCtx); + return replay.response; + } + authCtx = replay.authCtx; + route.provider = replay.provider; + selectedForwardHeaders = replay.headers; + const replayAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire), + config.cacheRetention, + ); + if (!("passthrough" in replayAdapter) || !replayAdapter.passthrough) { + upstream.abort(); + return formatErrorResponse(502, "upstream_error", "Native main refresh changed the provider wire unexpectedly"); + } + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: replay.provider, + adapterName: replayAdapter.name, + codexAuthContext: authCtx, + forwardHeaders: selectedForwardHeaders, + }); + logCtx.providerAdapter = replayAdapter.name; + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, replayAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, replayAdapter.name); + try { + request = await replayAdapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + }); + refreshRoutedNamespaceToolAliases(request); + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + refreshUndeclaredToolGuard(request); + // The 401 replay rebuilds the body before sending, so it needs the same ceiling as + // every other build site; a replay is exactly when a grown payload reappears. + const replayBodyRefusal = refuseOversizedOutboundBody(request); + if (replayBodyRefusal) return replayBodyRefusal; + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, "oauth-401"); + upstreamResponse = await fetchWithHeaderTimeout( + request.url, + { method: request.method, headers: request.headers, body: request.body }, + upstream.signal, + connectMs, + parsed.stream, + // The replay-dispatched signal is what bounds the rest of this logical request, so it + // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing + // admission BEFORE calling the executor, so signalling at the call site would spend the + // budget even when a rejected pacing wait means nothing reaches the network. Wrapping + // the executor moves the signal to the last moment before the send, where a throw from + // here on is a genuine transport attempt. + storedPoolReplayDispatchNotifier( + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined, + ), + route.provider.authMode === "forward", + ).then(response => { + settleObservedHostResponse(); + return response; + }); + } catch (err) { + return transportFailureResponse(err); + } finally { + request.releaseBodyObservation?.(); + } + continue passthroughRecovery; + } + + if (codex401ReplayKind !== null && upstreamResponse.status === 401) break; + + // Native Responses providers return before the generic adapter recovery loop below. Keep + // their OAuth contract identical: one pre-stream 401 forces a credential refresh and one + // rebuilt replay. xAI's current subscription models use this branch now that their official + // Grok CLI catalog declares the Responses backend. + if ( + upstreamResponse.status === 401 + && isOAuth401ReplayProvider + && sentOAuthSnapshot + && !oauth401ReplayAttempted + ) { + oauth401ReplayAttempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + let refreshed: OAuthAccessSnapshot; + try { + refreshed = await refreshResolvedOAuthSelection(sentOAuthSnapshot); + } catch (err) { + upstream.abort(); + releaseCodexAuthContextProbeLease(authCtx); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); + } + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { + upstream.abort(); + releaseCodexAuthContextProbeLease(authCtx); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); + } + sentOAuthSnapshot = refreshed; + replayOAuthCredentialSnapshot = { + accountId: refreshed.accountId, + generation: refreshed.generation, + }; + if (route.providerName === "kiro") { + parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + } + const refreshedProvider = resolveProviderTransport( + route.providerName, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), + }, + parsed.options.promptCacheKey, + route.providerName === "github-copilot" + ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) + : undefined, + ); + route.provider = refreshedProvider; + const refreshedAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), + config.cacheRetention, + ); + if (!("passthrough" in refreshedAdapter) || !refreshedAdapter.passthrough) { + upstream.abort(); + return formatErrorResponse(502, "upstream_error", "OAuth refresh changed the provider wire unexpectedly"); + } + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: refreshedProvider, + adapterName: refreshedAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, + }); + logCtx.providerAdapter = refreshedAdapter.name; + sealRequestAttemptIdentity( + logCtx.activeAttempt, + logCtx.provider, + refreshedAdapter.name, + logCtx.accountLogLabel, + ); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, refreshedAdapter.name); + try { + request = await refreshedAdapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + }); + refreshRoutedNamespaceToolAliases(request); + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + } catch (err) { + upstream.abort(); + if (options.abortSignal?.aborted) return clientCancelledResponse(); + const msg = err instanceof Error ? err.message : String(err); + return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)); + } + refreshUndeclaredToolGuard(request); + const refreshedBodyRefusal = refuseOversizedOutboundBody(request); + if (refreshedBodyRefusal) return refreshedBodyRefusal; + try { + upstreamResponse = await fetchWithTransientRetry( + recovery => { + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401"); + return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, recovery), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + route.provider.authMode === "forward") + .then(res => { + settleObservedHostResponse(); + return res; + }); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + ); + } catch (err) { + return transportFailureResponse(err); + } finally { + request.releaseBodyObservation?.(); + } + } + + // Native Responses returns before the generic adapter's OAuth rotation loop. Keep + // the same quorum, cooldown and request budget here, before any client bytes flow. + if ( + upstreamResponse.status === 429 + && genericFailoverAccountId + && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && isGenericOAuthFailoverEnabled(config, route.providerName) + ) { + const nextAccountId = rotateGenericOAuthAccountOn429( + config, route.providerName, genericFailoverAccountId, + upstreamResponse.headers.get("retry-after"), + ); + let snapshot: OAuthAccessSnapshot | undefined; + if (nextAccountId) { + try { snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); } + catch { /* Keep the original 429 body readable when the next credential is unavailable. */ } + } + if (snapshot && await applyFailoverSnapshot(snapshot)) { + genericFailovers += 1; + route.provider = resolveProviderTransport( + route.providerName, route.provider, parsed.options.promptCacheKey, sentOAuthSnapshot?.apiBaseUrl, + ); + bindRouteReasoningReplayScope({ + parsed, providerName: route.providerName, provider: route.provider, + adapterName: "openai-responses", oauthCredentialSnapshot: replayOAuthCredentialSnapshot, + }); + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already closed */ } + const result = await rebuildAndRefetch("oauth-account-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue passthroughRecovery; + } + } + + // Same-target 429 wait-and-retry (opt-in `retryOn429`) for key-auth providers on the + // passthrough wire. This branch returns before the recovery loop below, so Responses-shaped + // key-auth gateways (e.g. the built-in DeepSeek preset) would otherwise surface 429 + // immediately with no same-key replay. Pre-stream only — nothing has been relayed yet, so + // the replay is lossless (same invariant as the recovery loop). Forward/OAuth providers + // keep their pool logic below (rateLimitRetryPolicyFor returns null for them). + while ( + upstreamResponse.status === 429 + && rateLimitPolicy !== null + && rateLimitRetries < rateLimitPolicy.attempts + ) { + rateLimitRetries += 1; + // Release unread body + deliberate wait via the shared same-target helper. + const retryAfterHeader = upstreamResponse.headers.get("retry-after"); + try { + for await (const _ of prepareSameTarget429Wait({ + body: upstreamResponse.body, + signal: options.abortSignal, + delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()), + })) { + // pre-stream: no stall watchdog to feed + } + } catch { + upstream.abort(); + return clientCancelledResponse(); + } + // Client cancellation wins over any stale timer edge: re-check before dispatching the + // replay so the wire never starts work for a request the client already abandoned. + if (options.abortSignal?.aborted || upstream.signal.aborted) { + upstream.abort(); + return clientCancelledResponse(); + } + try { + upstreamResponse = await fetchWithTransientRetry( + recovery => { + // The first send of every replay is itself a rate-limit retry; inner transient-5xx + // recoveries keep their own label (recovery is provided for those). + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "rate-limit-429"); + return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, recovery), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request), + providerName: route.providerName, + modelId: route.modelId, + onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, + }), + route.provider.authMode === "forward") + .then(res => { + settleObservedHostResponse(); + return res; + }); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + ); + } catch (err) { + return transportFailureResponse(err); + } + } + + const captureAffinityResponse = ( + response: Response, + captureAuthCtx: CodexAuthContext = authCtx, + captureRequest: Awaited> = request, + credentialSubstituted = substituteMainCredential + || captureAuthCtx.kind === "pool" + || captureAuthCtx.kind === "main-pool", + ): void => { + if (!isCanonicalOpenAiForwardProvider(route.provider)) return; + captureCodexAffinityDiagnostic({ + inboundHeaders: req.headers, + outboundHeaders: captureRequest.headers, + authKind: captureAuthCtx.kind, + accountMode: route.codexAccountMode, + fixedAccount: isFixedCodexAccount(captureAuthCtx), + credentialSubstituted, + accountGatedModel: ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId), + wireModelNormalized: parsed.modelId !== route.modelId, + status: response.status, + }); + }; + captureAffinityResponse(upstreamResponse); + + if (usesCodexForwardPoolAuth(authCtx, route.provider)) { + let poolRetryOutcome: number | undefined; + if (await shouldRetryCodexPoolAccountModel400( + upstreamResponse, + route.modelId, + options.abortSignal, + )) { + poolRetryOutcome = 400; + } else if (!authCtx.fixedAccount && await shouldRetryCodexPoolAccountQuota( + upstreamResponse, + options.abortSignal, + )) { + // Pre-stream only: once SSE has begun, mid-stream quota stays terminal. + // ChatGPT sometimes wraps quota exhaustion in a generic 5xx. Normalize only + // body-confirmed cases to quota evidence so cooldown and rotation both apply. + poolRetryOutcome = upstreamResponse.status >= 500 ? 429 : upstreamResponse.status; + } + + if (poolRetryOutcome !== undefined) { + // A stored Pool 401 spent this request's account budget on its own refresh and replay, so + // nothing afterwards may be paid for out of a DIFFERENT account. One flag carries that, + // rather than a status check here as well: a quota failure has no same-account move, so + // `sameAccountOnly` makes it terminal by refusing the alternate; the gated-model 400 + // ladder does have one — retrying the account the refreshed roster still grants — and + // keeps it. An earlier revision also broke here on a non-400 outcome, which no test could + // justify because this flag already produced the identical result. + const storedReplaySpent = codex401ReplayKind === "stored"; + const retry = await retryCodexPoolOnAlternateAccount({ + req, + config, + route, + parsed, + logCtx, + options, + firstAuthCtx: authCtx, + firstResponse: upstreamResponse, + outcomeStatus: poolRetryOutcome, + sameAccountOnly: storedReplaySpent, + upstream, + connectMs, + passthroughEstimate, + stream: parsed.stream, + onResponse: (response, retryAuthCtx, retryRequest) => { + captureAffinityResponse( + response, + retryAuthCtx, + retryRequest, + retryAuthCtx.kind !== "main", + ); + }, + }); + if (retry.kind === "transport") { + authCtx = retry.authCtx; + return transportFailureResponse(retry.error); + } + if (retry.kind === "retried") { + authCtx = retry.authCtx; + request = retry.request; + refreshRoutedNamespaceToolAliases(request); + refreshUndeclaredToolGuard(request); + upstreamResponse = retry.upstreamResponse; + selectedForwardHeaders = retry.selectedForwardHeaders; + // Keep subagent quota-failure health keyed to the account that actually served. + subagentFallbackAccountId = retry.authCtx.accountId; + } + } + } + // The deterministic route record cannot classify history it never observed (restart, expiry, + // eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound + // Responses body still carries opaque state, then rebuild once through the ordinary adapter + // sanitation path. A second rejection falls through unchanged because the guard stays armed. + const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({ + response: upstreamResponse, + outboundBody: request.body, + adapterName: adapter.name, + parsed, + guard: opaqueBlobRecoveryGuard, + signal: upstream.signal, + }, rebuildAndRefetch); + if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response; + if (opaqueBlobRecovery.kind === "recovered") { + upstreamResponse = opaqueBlobRecovery.response; + continue passthroughRecovery; + } + + const recoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; + const streamedFunctionOutputCandidate = upstreamResponse.ok + && !!upstreamResponse.body + && (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && parsed.stream)) + && !opaqueBlobRecoveryGuard.attempted + && outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body); + if (streamedFunctionOutputCandidate) { + const preflightLog: RequestLogContext = { model: logCtx.model, provider: logCtx.provider }; + const preflight = await preflightComboStreamResponse(upstreamResponse, preflightLog, + payload => { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const type = (payload as { type?: unknown }).type; + return (type === "error" || type === "response.failed" || type === "response.incomplete") + && upstreamErrorMessageFromPayload(payload) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION; + }, { + allowMissingContentType: !recoveryContentType && parsed.stream, + replayReadErrors: true, + }); + if (options.abortSignal?.aborted) return transportFailureResponse(options.abortSignal.reason); + upstreamResponse = preflight.response; + if (preflight.kind === "failed") { + const streamedOpaqueRecovery = await attemptOpaqueBlobRecovery({ + response: upstreamResponse, + outboundBody: request.body, + adapterName: adapter.name, + parsed, + guard: opaqueBlobRecoveryGuard, + signal: upstream.signal, + }, rebuildAndRefetch); + if (streamedOpaqueRecovery.kind === "failed") return streamedOpaqueRecovery.response; + if (streamedOpaqueRecovery.kind === "recovered") { + resetStreamedOpaqueBlobLogContext(logCtx); + upstreamResponse = streamedOpaqueRecovery.response; + continue passthroughRecovery; + } + logCtx.upstreamError = preflightLog.upstreamError; + logCtx.terminalHttpStatus = preflightLog.terminalHttpStatus; + logCtx.terminalErrorCode = preflightLog.terminalErrorCode; + logCtx.terminalIncompleteReason = preflightLog.terminalIncompleteReason; + } + } + break; + } + const headers = sanitizePassthroughHeaders(upstreamResponse.headers); + const resolvedModel = headers.get("openai-model")?.trim(); + if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; + if (isUsageDebugEnabled()) { + const upstreamContentType = upstreamResponse.headers.get("content-type"); + if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType; + } + // The chatgpt backend may omit Content-Type on SSE responses. Fall back to + // treating a successful body as SSE when the caller requested streaming. + const passthroughCt = headers.get("content-type")?.toLowerCase(); + const isEventStream = passthroughCt?.includes("text/event-stream") + || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream); + const recordTerminalOutcome = codexForwardTerminalOutcomeRecorder( + config, + authCtx, + route.provider, + route.modelId, + logCtx, + ); + let terminalOutcomeRecorded = false; + const terminalRecorder = recordTerminalOutcome + ? (status: ResponsesTerminalStatus, httpStatusOverride?: number): void => { + if (terminalOutcomeRecorded) return; + terminalOutcomeRecorded = true; + recordTerminalOutcome(status, httpStatusOverride); + } + : undefined; + const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream; + // Capture quota from upstream response for multi-account tracking + if (usesCodexForwardPoolAuth(authCtx, route.provider)) { + // primary was the 5h window; it now carries weekly data for GPT plans. + // Prefer primary when present, fall back to secondary for compatibility. + const quotaMeta = { ...codexQuotaOutcomeMeta(upstreamResponse), ...(await codexDenialOutcomeMeta(upstreamResponse)) }; + const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api"); + if (!isCodexWsQuotaObservedResponse(upstreamResponse)) { + applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers, + authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined); + } + if (terminalBodyWillRecord) { + options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { + terminalRecorder(status, httpStatusOverride); + if (status === "failed" || status === "incomplete") { + const quotaFailureMessage = [httpStatusOverride, logCtx.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { + recordSubagentQuotaFailureForThreadSpawn( + req.headers, + subagentQuotaFailureModel, + quotaFailureMessage, + config, + subagentFallbackAccountId, + ); + } + } + options.onNativePassthroughTerminal?.(status); + }); + } else if (!shouldDeferCodexResetDerivedCooldown( + upstreamResponse, + options.deferCodexResetDerivedCooldown, + )) { + recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, { + ...quotaMeta, + threadId: authCtx.affinityKey, + fixedAccount: authCtx.fixedAccount, + modelId: route.modelId, + probeLeaseId: codexProbeLeaseId(authCtx), + probeQuotaScope: codexProbeQuotaScope(authCtx), + writerGeneration: authCtx.writerGeneration, + // Includes a replay's second 401, which is the case that actually retires the + // account — fence it on the credential the request was holding. + ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}), + }); + } + } + + // Non-2xx passthrough failures must never reach Codex as an empty body — + // Codex renders that as the opaque "Unknown error" (#452). Combo attempts + // keep their typed failure envelope. Non-empty bodies are relayed verbatim + // (headers included) so pool-retry Activation B/D and client diagnostics stay intact. + // Manual-redirect policy (#914): a 3xx is relayed as-is (Location preserved + // through sanitizePassthroughHeaders) so a redirect to a dead host can never + // masquerade as a pre-connection failure after the credential was seen. + // The numeric outcome above already classified it neutral — no streak. + if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) { + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: sanitizePassthroughHeaders(upstreamResponse.headers), + }); + } + if (!upstreamResponse.ok) { + if (options.comboAttempt) { + // No pre-read guard here: `consumeComboFailure` -> `readBoundedResponseBody` reads + // `response.body` itself and already threads the abort signal through its own read, + // and the combo contract is that this body's getter is touched exactly once (pinned by + // "captures passthrough failed usage from its original bounded body exactly once"). + // Attaching a guard would be a second `.body` access and break that contract for no + // gain, since the bounded reader owns settlement on this path. + const failure = await consumeComboFailure(upstreamResponse, options.abortSignal); + options.onConsumedComboFailure?.(failure); + return failure.response; + } + // The bounded reader owns the original body, deadline, abort settlement, and lock. + // Unsafe partial data falls back to #452's non-empty status-only JSON. + const errorText = await readDisplaySafeErrorText(upstreamResponse, upstream.signal, ""); + if (upstreamResponse.status === 413 && clientRequestedStream) { + return streamingContextOverflowResponse( + parsed._responseModelId ?? parsed.modelId, + translatorBudget, + ); + } + return formatPassthroughUpstreamError(upstreamResponse.status, errorText, { + statusText: upstreamResponse.statusText, + headers, + }); + } + + // Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the + // async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun + // native relay, never enters JS Sink.write); branch[1] is consumed in the + // background for terminal-outcome/quota inspection only. + // #314 alternative shape: win32 no-rewrite traffic follows the runtime/config + // gate; darwin no-rewrite traffic joins it only for explicit + // `streamMode: "eager-relay"` opt-in. Darwin `auto` always stays tee. The + // eager shape skips tee and uses one bounded reader with inline inspection + // (src/server/relay-eager.ts; policy: + // devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md). + // The bundled known-bad runtime remains on tee by default on both platforms. + if (isEventStream && upstreamResponse.body) { + // For streamed passthrough, a successful terminal response means non-error upstream status + // before relay starts. Waiting for SSE completion would retain request state across the whole + // stream; a later body failure does not undo that this destination accepted and served the turn. + commitReasoningReplayServingRoute(); + const terminalRepairPolicy = providerModelResponsesTerminalRepair( + route.providerName, + route.provider, + route.modelId, + ); + const passthroughSseBody = terminalRepairPolicy + ? relayResponsesSseWithTerminalRepair( + upstreamResponse.body, + upstream, + terminalRepairPolicy, + translatorBudget, + options.responsesTerminalRepairScheduler, + ) + : upstreamResponse.body; + const repairConfig = route.provider.responsesItemIdRepair; + // Grok Build renders deltas live but reconstructs its durable assistant + // turn from the completed response snapshot. Native Responses streams + // may instead carry the complete items in output_item.done, so the + // explicit Grok compatibility marker enables strict terminal-only repair. + // The provider's broader snapshot/lifecycle repair remains opt-in. + const grokClientSnapshotRepairEnabled = logCtx.surface === "grok"; + const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair); + const githubCopilotRepairEnabled = route.providerName === "github-copilot"; + const responseModelRewrite = parsed._responseModelId !== undefined + && parsed._responseModelId !== parsed.modelId + ? createResponsesModelPayloadRewrite(parsed._responseModelId) + : undefined; + // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). + const payloadRewrites = [ + createImageGenCallRestoreRewrite(imageGenCallAliases), + // #3217: a call whose namespace repeats its own name is unroutable in codex-rs. + createSelfNamedToolCallNamespaceScrubRewrite(selfNamedNamespaceScrubAuthorization), + routedNamespaceToolAliases.size > 0 + ? createRoutedNamespaceCallRestoreRewrite(routedNamespaceToolAliases) + : undefined, + authorizedBareNamespaceToolAliases.size > 0 + ? createRoutedNamespaceCallRestoreRewrite(authorizedBareNamespaceToolAliases) + : undefined, + hasResponsesItemIdRepair(repairConfig) + ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) + : undefined, + responseModelRewrite, + parsed.options.hideThinkingSummary !== true + && routeUsesContentChannelReasoning(route.provider, route.modelId) + ? createReasoningSummaryChannelPayloadRewrite() + : undefined, + ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); + // #893: sparse-snapshot gateways get field backfills AND lifecycle event + // injection at the block level, after payload rewrites. Defaults come + // from the finalized OUTBOUND body — the normalized internal tool shapes + // are not the Responses wire shapes the snapshot must mirror. + const blockRewrites = [ + payloadRewrites.length > 0 + ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites)) + : undefined, + routedCustomToolNames.size > 0 || routedCustomToolRepairNames.size > 0 + ? createRoutedCustomToolRestoreBlockRewrite( + routedCustomToolNames, + translatorBudget, + routedCustomToolRepairNames, + declaredWireToolNames, + ) + : undefined, + routedToolSearchNames.size > 0 + ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget) + : undefined, + githubCopilotRepairEnabled + ? createGithubCopilotResponsesBlockRewrite(translatorBudget) + : undefined, + grokClientSnapshotRepairEnabled + ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget) + : undefined, + snapshotRepairEnabled + ? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget) + : undefined, + createResponsesFieldBackfillBlockRewrite(), + functionRepairSchemas.size > 0 + ? createResponsesFunctionToolRepairBlockRewrite(functionRepairSchemas, translatorBudget) + : undefined, + // Last: every rewrite above can still rename or reshape a call item, so the guard must + // compare the names the client will actually receive against the declared catalog. + undeclaredToolGuardActive + ? createUndeclaredToolCallGuardBlockRewrite( + declaredWireToolNames, + declaredNamelessClientCallTypes, + providerExecutedCallTypes, + ) + : undefined, + ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); + const clientBlockRewrite = blockRewrites.length > 0 + ? composeSseBlockRewrites(...blockRewrites) + : undefined; + const needsClientRewrite = clientBlockRewrite !== undefined; + // #864: win32 rewrite traffic must never enter the tee()+JS-pull chain + // (Bun#32111 JS-sink segfault — text frames pass, the terminal block is + // lost). The eager single reader applies the same rewrites inline. + const win32EagerRewrite = isWin32EagerRewrite(process.platform, needsClientRewrite); + const eagerPath = selectEagerPath( + process.platform, + needsClientRewrite, + config.streamMode ?? "auto", + ); + // A successful Codex WS upgrade is a push source. If it entered tee(), + // the inspection branch could drain continuously while the slow client + // branch retained bytes without a bound. Force the existing bounded, + // single-reader relay before tee; HTTP fallback responses stay unmarked. + const forceCodexWsEagerRelay = isCodexWsUpstreamResponse(upstreamResponse); + const inlineEagerRewrite = needsClientRewrite + && (forceCodexWsEagerRelay || win32EagerRewrite || eagerPath?.useEagerRelay === true); + if (forceCodexWsEagerRelay || eagerPath?.useEagerRelay || win32EagerRewrite) { + const turnAc = new AbortController(); + linkAbortSignal(upstream, turnAc.signal); + registerTurn(turnAc, options.turnAdmissionLease); + const reportNativeTerminal = recordTerminalOutcomes + ? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { + terminalRecorder?.(status, httpStatusOverride); + if (status === "failed" || status === "incomplete") { + const quotaFailureMessage = [httpStatusOverride, logCtx.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { + recordSubagentQuotaFailureForThreadSpawn( + req.headers, + subagentQuotaFailureModel, + quotaFailureMessage, + config, + subagentFallbackAccountId, + ); + } + } + options.onNativePassthroughTerminal?.(status); + } + : undefined; + const inspector = createSseInspector({ + onTerminal: reportNativeTerminal, + logCtx, + onCompletedResponse: rememberPassthroughResponseChecked, + onParsedPayload: noteInspectedPayload, + onFirstOutput: options.onFirstOutput, + pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, + }); + const eagerBody = relaySseEagerBounded(passthroughSseBody, turnAc, { + inspectChunk: chunk => inspector.feed(chunk), + finishInspection: () => inspector.finish(), + disposeInspection: () => inspector.dispose(), + // Stream lifetime follows the protocol terminal even when this request + // has no outcome callback configured (reported() would stay false). + sawTerminal: () => inspector.terminalSeen(), + ...(clientBlockRewrite + ? { rewriteBlocks: clientBlockRewrite } + : {}), + onSynthetic: (kind, reason) => { + if (!reportNativeTerminal) return; + if (kind === "incomplete") { + logCtx.terminalSource = "synthetic"; + reportNativeTerminal("incomplete"); + } else if (reason === "upstream_error") { + logCtx.terminalSource = "synthetic"; + reportNativeTerminal("failed", logCtx.terminalHttpStatus ?? 502); + } else { + logCtx.transportPhase = "mid_stream"; + logCtx.terminalSource = "synthetic"; + if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true; + reportNativeTerminal("failed", 502); + } + }, + onClientCancel: () => options.onNativePassthroughCancel?.(), + onDone: () => unregisterTurn(turnAc), + }, { + clientGoneSignal: options.abortSignal, + ...(inlineEagerRewrite ? { rewriteBudget: translatorBudget } : {}), + ...(logCtx.upstreamError === undefined ? {} : { upstreamError: logCtx.upstreamError }), + }); + // When selected, this relay closes response.completed even if upstream + // keeps the connection alive. Marked Codex WS traffic, Windows + // forced-rewrite traffic, and Darwin explicit eager traffic apply + // client rewrites inline rather than via the tee()+JS-pull chain. + if (!headers.has("content-type")) headers.set("content-type", "text/event-stream"); + return markEagerRelaySseResponse( + markNativePassthroughSseResponse(new Response(eagerBody, { + status: upstreamResponse.status, + headers, + })), + ); + } + const [nativeBody, inspectBody] = passthroughSseBody.tee(); + const turnAc = new AbortController(); + const clientGone = new AbortController(); + linkAbortSignal(upstream, turnAc.signal); + registerTurn(turnAc, options.turnAdmissionLease); + const inspectionConsumerOptions = { + // Request abort can reject the fetch body before the response cancel hook runs. + clientGoneSignal: options.abortSignal + ? AbortSignal.any([clientGone.signal, options.abortSignal]) + : clientGone.signal, + drainBounds: { ms: 15_000, bytes: 32 * 1024 * 1024 }, + upstream, + pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, + onParsedPayload: noteInspectedPayload, + }; + if (recordTerminalOutcomes) { + // A real terminal was parsed from the (teed) inspection stream — record it as the outcome + // even if the client has already disconnected: the turn genuinely reached that terminal, so + // it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure + // client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel. + const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => { + terminalRecorder?.(status, httpStatusOverride); + if (status === "failed" || status === "incomplete") { + const quotaFailureMessage = [httpStatusOverride, logCtx.terminalHttpStatus] + .find(value => value === 429 || value === 402); + if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) { + recordSubagentQuotaFailureForThreadSpawn( + req.headers, + subagentQuotaFailureModel, + quotaFailureMessage, + config, + subagentFallbackAccountId, + ); + } + } + options.onNativePassthroughTerminal?.(status); + }; + consumeForInspection( + inspectBody, + reportNativeTerminal, + turnAc.signal, + () => unregisterTurn(turnAc), + logCtx, + () => options.onNativePassthroughCancel?.(), + rememberPassthroughResponseChecked, + options.onFirstOutput, + inspectionConsumerOptions, + ); + } else { + consumeForResponseLogMetadata( + inspectBody, + logCtx, + turnAc.signal, + () => unregisterTurn(turnAc), + rememberPassthroughResponseChecked, + options.onFirstOutput, + inspectionConsumerOptions, + ); + } + if (!headers.has("content-type")) headers.set("content-type", "text/event-stream"); + // Windows was handled by the eager terminal-aware branch above. Remaining + // tee traffic can use the JS relay to close on a protocol terminal and to + // convert a mid-stream reset into a clean response.failed event. + const rewrittenBody = clientBlockRewrite !== undefined + ? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget) + : nativeBody; + const clientBody = relaySseWithFailedTail( + rewrittenBody, + upstream, + reason => clientGone.abort(reason), + { upstreamError: logCtx.upstreamError }, + ); + return markNativePassthroughSseResponse(new Response(clientBody, { + status: upstreamResponse.status, + headers, + })); + } + if (headers.get("content-type")?.toLowerCase().includes("application/json")) { + // Bounded whole-body read: a non-streaming upstream JSON body is fully materialized + // here (and again by the request-log finalizer and the WebSocket bridge's reframing), + // so an unbounded .text() would let a hostile or stuck upstream grow proxy memory + // without limit. This path is no longer rare — WebSocket turns for models whose + // streaming terminal event is unreliable are deliberately answered with bounded JSON. + // Oversize and stall deadlines both fail closed; a partial body is never parsed. + const bounded = await readBoundedResponseBody(upstreamResponse, UPSTREAM_JSON_BODY_READ_OPTIONS); + if (bounded.oversized) { + return formatErrorResponse(502, "upstream_error", "upstream JSON response exceeded the safe body limit"); + } + if (bounded.truncated) { + return formatErrorResponse(502, "upstream_error", "upstream JSON response stalled before completing"); + } + const text = bounded.text; + inspectResponseLogJson(logCtx, text); + const clientJson = (() => { + const restoredNamespace = restoreRoutedNamespaceCallsInJson( + scrubSelfNamedToolCallNamespaceInJson( + restoreImageGenCallsInJson(text, imageGenCallAliases), + selfNamedNamespaceScrubAuthorization, + ), + routedNamespaceToolAliases, + ); + const restoredAuthorizedBareNamespace = restoreRoutedNamespaceCallsInJson( + restoredNamespace, + authorizedBareNamespaceToolAliases, + ); + const restored = restoreRoutedCustomCallsInJson( + restoredAuthorizedBareNamespace, + routedCustomToolNames, + routedCustomToolRepairNames, + declaredWireToolNames, + ); + const restoredToolSearch = restoreRoutedToolSearchCallsInJson( + restored, + routedToolSearchNames, + ); + const repaired = normalizeFunctionCompletionJson(restoredToolSearch); + const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId + ? rewriteResponsesModelJson(repaired, parsed._responseModelId) + : repaired; + // The bounded-JSON answer bypasses the SSE payload rewrite, so content- + // channel reasoning needs the same normalization here for the plain + // JSON answer and every reframed-SSE variant built from clientJson. + return parsed.options.hideThinkingSummary !== true + && routeUsesContentChannelReasoning(route.provider, route.modelId) + ? rewriteReasoningSummaryInJsonString(modelRewritten) + : modelRewritten; + })(); + // #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and + // the reframed-SSE branch below are built from this body, so one check covers them. This + // runs BEFORE the continuation cache write below: a refused turn must not become state a + // later `previous_response_id` replay can expand from. + if (undeclaredToolGuardActive) { + const undeclared = (() => { + try { + return undeclaredToolCallNameInResponse( + JSON.parse(clientJson), + declaredWireToolNames, + declaredNamelessClientCallTypes, + providerExecutedCallTypes, + ); + } catch { + return undefined; + } + })(); + if (undeclared !== undefined) { + return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); + } + } + commitReasoningReplayServingRoute(); + if (rememberPassthroughResponseChecked) { + try { + rememberPassthroughResponseChecked( + JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown }, + ); + } catch { /* non-JSON despite content-type; recording is best-effort */ } + } + // #875: the transport-neutral reliability policy forced a bounded JSON + // upstream for a client that asked for SSE. Reframe the completed JSON + // as the canonical terminal SSE sequence (created → output_item.done → + // terminal → [DONE]) so Codex commits the turn instead of hanging on a + // stream that never closes. Non-streaming clients keep the plain JSON. + if (clientRequestedStream === true + && options.inboundTransport !== "websocket" + && providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false + && route.provider.adapter === "openai-responses") { + let completed: Record | undefined; + try { + const parsedCompleted = JSON.parse(clientJson) as unknown; + if (!parsedCompleted || typeof parsedCompleted !== "object" || Array.isArray(parsedCompleted)) { + throw new TypeError("bounded Responses JSON is not an object"); + } + let candidate = parsedCompleted as Record; + // The bounded-JSON answer bypasses the SSE relay, so it also bypasses + // the SSE item-id rewrite. Apply the same client-facing normalization + // here or this policy would silently disable id repair for the very + // providers that need it (raw record already happened above). + if (hasResponsesItemIdRepair(route.provider.responsesItemIdRepair)) { + candidate = repairResponsesJsonItemIds(candidate, route.provider.responsesItemIdRepair!, translatorBudget); + } + completed = candidate; + } catch { + // Non-JSON despite content-type: fall through to the plain relay. + } + if (completed) { + let stream: ReadableStream; + try { + stream = responsesJsonToSseStream(completed); + } catch (error) { + if (error instanceof RangeError) { + return formatErrorResponse( + 502, + "upstream_error", + "upstream JSON response exceeded the synthesized SSE item limit", + ); + } + throw error; + } + const sseHeaders = sanitizePassthroughHeaders(headers); + sseHeaders.set("content-type", "text/event-stream"); + sseHeaders.set("cache-control", "no-store"); + return new Response(stream, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: sseHeaders, + }); + } + } + // WS turns reframe this JSON into events in the bridge, which is the + // other relay-free path — normalize ids so both bounded-JSON paths agree. + const outboundJson = options.inboundTransport === "websocket" + && providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false + && hasResponsesItemIdRepair(route.provider.responsesItemIdRepair) + ? (() => { + try { + return JSON.stringify(repairResponsesJsonItemIds( + JSON.parse(clientJson) as Record, + route.provider.responsesItemIdRepair!, + translatorBudget, + )); + } catch { + return clientJson; + } + })() + : clientJson; + return new Response(outboundJson, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers, + }); + } + // An unclassified passthrough body is relayed directly and has no bounded completion observer; + // use the same non-error-status success boundary as SSE instead of retaining per-stream state. + commitReasoningReplayServingRoute(); + const body = relayWithAbort(upstreamResponse.body, upstream); + const turnAc = new AbortController(); + const tracked = body ? trackStreamLifetime(body, turnAc, undefined, options.turnAdmissionLease) : null; + return new Response(tracked, { + status: upstreamResponse.status, + headers, + }); + } finally { + if (hostAdmissionLease) { + releaseUpstreamHostAdmission(hostAdmissionLease); + releaseCodexAuthContextProbeLease(authCtx); + } + } + } + + // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId + // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all + // (schema.ts:106) accepts a tool item whose strict schema failed only for a missing call_id. A + // translating adapter then consumes `toolCallId: string` holding undefined: kiro-wire.ts:32 + // TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends + // "[tool_result without adjacent tool_use: undefined]" upstream (issue #3259). + // + // This CANNOT move into the schema. parseRequest (:2812) runs before the passthrough branch + // (:3719), so a parse-time rejection would also kill forward/key passthrough and routed + // compaction — paths that never read context.messages, build from _rawBody, and already + // degrade an unpaired output to "[tool output for unknown call]" on their own. + // + // Keyed on the adapter, not on position: routedCompaction skips the passthrough branch above + // yet still builds from _rawBody (see the :3703 comment). + if (!("passthrough" in adapter && adapter.passthrough)) { + const unpaired = parsed.context.messages.find( + message => message.role === "toolResult" + && (typeof (message as { toolCallId?: unknown }).toolCallId !== "string" + || (message as { toolCallId: string }).toolCallId.length === 0), + ); + if (unpaired) { + // Never interpolate the tool output: this message reaches the client and the logs. + return formatErrorResponse( + 400, + "invalid_request_error", + "tool result requires a non-empty string call_id", + ); + } + } + + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. + // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but + // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses + // completion instead of the synthetic compaction item Codex expects (#424). + // + // Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending + // Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So: + // - non-runTurn: web-search wins over image when both eligible (documented priority) + // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn + // can proceed for web-search-only turns + const wsPlan = !routedCompaction + ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, { + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, + }) + : undefined; + const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; + const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; + const canRunWebSearch = !!wsPlan && !adapter.runTurn; + const rotateSidecarProviderOn429 = async (retryAfter: string | null): Promise => { + const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { + retryAfter, + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (rotated) { + route.provider = rotated; + } else if ( + // A POSITIVE gate, not an early return. An early `return null` here made every later arm + // unreachable: Anthropic never has a genericFailoverAccountId (isGenericFailoverProvider + // excludes it), so its sidecar 429s died on this guard before the Anthropic arm below + // could ever be considered. + genericFailoverAccountId + && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && isGenericOAuthFailoverEnabled(config, route.providerName) + ) { + const nextAccountId = rotateGenericOAuthAccountOn429( + config, + route.providerName, + genericFailoverAccountId, + retryAfter, + ); + if (!nextAccountId) return null; + try { + const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); + genericFailovers += 1; + if (!await applyFailoverSnapshot(snapshot)) return null; + } catch { + return null; + } + } else if ( + // Anthropic's pool is excluded from generic failover, so without this arm a 429 inside a + // web-search or image-bridge turn was terminal even with the pool fully enabled -- while + // the very same 429 on the main response path rotated. + anthropicPoolAccountId + && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST + ) { + const nextAccountId = rotateAnthropicAccountOn429( + config, + anthropicPoolAccountId, + retryAfter, + anthropicSessionKey, + ); + if (!nextAccountId) return null; + try { + // Deliberately NOT applyFailoverSnapshot: that helper exists to pair per-account routing + // metadata (Copilot origin, Antigravity project, Kiro context) with its bearer. Anthropic + // carries none, and getAnthropicPoolAccessToken is what enforces its fail-closed + // local-cli credential rule. Both existing Anthropic rotation sites apply the token the + // same way. + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId)); + if (!admitted) throw new Error("OAuth selection changed during recovery"); + anthropicPoolAccountId = admitted.accountId; + anthropicPoolFailovers += 1; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); + } catch { + return null; + } + } else { + // No key pool, no generic OAuth roster, no Anthropic pool could produce a replacement + // credential. The 429 is terminal for this sidecar turn. + return null; + } + const rotatedAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: route.provider, + adapterName: rotatedAdapter.name, + }); + return rotatedAdapter; + }; + if ((imgPlan || vidPlan) && canRunWebSearch) { + // Web search takes priority when both are active — the media bridge cannot run + // alongside runWithWebSearch. Surface a runtime signal so the user knows their + // configured video/image bridge was skipped for this turn, rather than silently + // dropping a paid capability. + if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn"); + if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn"); + } + if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) { + // The image bridge detects a hosted image_generation tool and requires streaming. + // The video bridge activates from config and injects a tool — it also needs streaming + // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip + // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic. + if (!parsed.stream) { + if (imgPlan) { + return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true"); + } + // Video-only: skip bridge for non-streaming requests + } else { + // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names. + const priorTools = parsed.context.tools ?? []; + const bridgeTools = [...priorTools.filter(t => { + if (t.imageGeneration) return false; + if (t.videoGeneration) return false; + if (imgPlan && imgPlan.toolNames.has(t.name)) return false; + if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone. + if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false; + return true; + })]; + const existingNames = new Set(bridgeTools.map(t => t.name)); + if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool()); + if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool()); + parsed.context.tools = bridgeTools; + // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name. + // Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting + // image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject. + const tc = parsed.options.toolChoice; + if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) { + const mapped = tc.allowedTools.map(name => + name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false) + ? IMAGE_GEN_TOOL_NAME + : name, + ); + parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] }; + } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" + && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) { + parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME }; + } + const imageProviderFetch = providerFetch( + route.provider, + options.codexWsRuntimeIdentity, + { providerName: route.providerName, modelId: route.modelId }, + ); + const imgResponse = await runWithImageBridge({ + parsed, adapter, + incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, + ...(imgPlan ? { plan: imgPlan } : {}), + ...(vidPlan ? { videoPlan: vidPlan } : {}), + forwardHeaders: selectedForwardHeaders, + onAttemptSend: (recovery?: AttemptRecoveryKind) => + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery), + abortSignal: options.abortSignal, + maxRounds: imgPlan && vidPlan + ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2)) + : imgPlan + ? clampImageMaxRounds(config.images?.maxRounds) + : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), + connectTimeoutMs: config.connectTimeoutMs ?? 200_000, + stallTimeoutSec: config.stallTimeoutSec, + waitForRequestSlot: imageProviderFetch.waitForPacing, + fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch, + fetchForRequest: (request, iterParsed) => { + const fetch = providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request, iterParsed), + providerName: route.providerName, modelId: route.modelId, + }); + return fetch.unpacedFetch ?? fetch; + }, + onRequestBuilt: request => { + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + }, + ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}), + onUsage: usage => { + // Cursor may assign _cursorConversationId inside the image loop's first runTurn; + // backfill so Logs can filter/total that opening request (parity with the normal + // runTurn branch). + if (!logCtx.conversationId && parsed._cursorConversationId) { + logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); + } + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + on429: rotateSidecarProviderOn429, + retryOn429Policy: rateLimitRetryPolicyFor(route.provider), + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}), + onCompletedResponse: (response, providerState) => { + commitReasoningReplayServingRoute(); + rememberKiroDeliveredFinalAnswer(adapter.name, response); + rememberResponseState( + parsed._rawBody, + response, + continuationStateForResponse(providerState), + responseStateOptions(adapterNeedsForcedContinuation(adapter.name)), + ); + }, + }); + if (imgResponse.body) { + const imgTurnAc = new AbortController(); + return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc, undefined, options.turnAdmissionLease), { + status: imgResponse.status, + headers: imgResponse.headers, + }); + } + return imgResponse; + } // end else (streaming bridge) + } + + // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't + // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar + // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path. + // Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch + // through web-search instead of being swallowed. runTurn adapters never enter this branch. + if (canRunWebSearch && wsPlan) { + parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()]; + // Resolve the mutable route at send time: a 429 rotation replaces route.provider, so retaining + // one pre-rotation providerFetch would keep the old credential and transport pin. + const routedProviderFetch = ((input: Parameters[0], init?: RequestInit) => + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + })(input, init)) as typeof globalThis.fetch; + const wsResponse = await runWithWebSearch({ + parsed, adapter, + fetchForRequest: (request, iterParsed) => providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(request, iterParsed), + providerName: route.providerName, modelId: route.modelId, + }), + incomingMeta: { + headers: selectedForwardHeaders, + abortSignal: options.abortSignal, + translatorBudget, + providerFetch: routedProviderFetch, + }, + backend: wsPlan.backend, + forwardProvider: wsPlan.forwardSidecar?.provider, + anthropicSidecar: wsPlan.anthropicSidecar, + xaiSidecar: wsPlan.xaiSidecar, + geminiSidecar: wsPlan.geminiSidecar, + xaiSearchOptions: wsPlan.xaiSearchOptions, + // The exa key never rides the plan: read it from config at unpack time (L9). + ...(wsPlan.exaConfigured ? { exaApiKey: config.webSearchSidecar?.exaApiKey } : {}), + hostedTool: wsPlan.hostedTool, + selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders, + settings: wsPlan.settings, + maxSearches: wsPlan.maxSearches, + forceEmptyResponseId: true, + abortSignal: options.abortSignal, + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + onRequestBuilt: request => { + recordAdapterReasoning(logCtx, request); + recordAdapterTier(logCtx, request); + }, + onAttemptSend: (recovery?: AttemptRecoveryKind) => + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery), + onUsage: usage => { + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome, + connectTimeoutMs: config.connectTimeoutMs ?? 200_000, + routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs, + stallTimeoutSec: wsPlan.stallTimeoutSec, + streamRoutedModelOutput: wsPlan.streamRoutedModelOutput, + on429: rotateSidecarProviderOn429, + retryOn429Policy: rateLimitRetryPolicyFor(route.provider), + onCompletedResponse: commitReasoningReplayServingRoute, + }); + // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts) + // in-flight web-search turns instead of skipping them during graceful shutdown. + if (wsResponse.body) { + const wsTurnAc = new AbortController(); + return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc, undefined, options.turnAdmissionLease), { + status: wsResponse.status, + headers: wsResponse.headers, + }); + } + return wsResponse; + } + + // Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output + // text and no tool call is a failure the client cannot see — it silently records the turn as + // done. The guard holds pre-content adapter events, suppresses the terminal of an empty + // turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is + // empty or fails. This is a top-level config opt-in; OCX_EMPTY_COMPLETION_RETRY=0 is a + // disable-only emergency override. Compaction turns and combo attempts keep their own + // machinery (the combo preflight already handles empty streams). Native Chat-to-Chat + // requests return from handleChatCompletions before entering Responses core, so they are + // intentionally outside this guard and retain their existing one-send wire behavior. + const emptyCompletionGuardEnabled = + emptyCompletionRetryEnabled(config) + && !options.comboAttempt + && !routedCompaction; + + if (adapter.runTurn) { + const runTurnAbort = new AbortController(); + const cleanupRunTurnAbort = linkAbortSignal(runTurnAbort, options.abortSignal); + const queue = createAdapterEventQueue({ + onBacklogExceeded: () => runTurnAbort.abort(), + }); + const refreshRunTurnSelection = async (): Promise => { + if (selectionIsCurrent(adapterBindings.get(runTurnAdapter))) return; + await refreshRunTurnAdapter(parsed); + bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: route.provider, + adapterName: runTurnAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, runTurnAdapter.name, logCtx.accountLogLabel); + }; + // Initial admission must settle before the streaming Response commits HTTP 200. + // Let the outer Responses facade preserve the local retryable-429 contract. + try { + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); + } catch (error) { + cleanupRunTurnAbort(); + queue.close(); + throw error; + } + // One attempt of the runTurn transport, against an explicit queue. The + // empty-completion guard re-invokes the IDENTICAL turn (same parsed request, + // same forwarded headers, same abort signal) through a fresh queue, so the + // attempt body must not capture the first queue. Each attempt consumes its + // own provider pacing slot (#1584): retries are paced like first attempts. + const runTurnAttempt = async ( + targetQueue: AdapterEventQueue, + recovery?: AttemptRecoveryKind, + pacingSlotAcquired = false, + ): Promise => { + try { + if (!pacingSlotAcquired) { + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); + } + await refreshRunTurnSelection(); + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery); + const runTurnProviderFetch = providerFetch( + route.provider, + options.codexWsRuntimeIdentity, + { + providerName: route.providerName, + modelId: route.modelId, + // runTurnAttempt acquired this logical turn's first physical-request slot above. + // Cursor HTTP/1.1 consumes it for RunSSE; every BidiAppend and redial then waits on + // the same provider queue through this stateful wrapper. + pacingSlotAcquired: true, + }, + ); + await runTurnAdapter.runTurn?.( + parsed, + { + headers: selectedForwardHeaders, + abortSignal: runTurnAbort.signal, + translatorBudget, + providerFetch: runTurnProviderFetch, + }, + targetQueue.push, + ); + } catch (err) { + targetQueue.push(err instanceof RequestPacingQueueOverloadError + ? { + type: "error", + status: 429, + errorType: "rate_limit_error", + retryable: true, + message: err.message, + } + : { + type: "error", + message: err instanceof Error ? err.message : String(err), + }); + } finally { + // Cursor assigns a stable conversation id inside runTurn on the first headerless + // turn; backfill so Logs can filter/total that opening request (#330 / #522). + if (!logCtx.conversationId && parsed._cursorConversationId) { + logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); + } + targetQueue.close(); + } + }; + const runTurn = async (): Promise => runTurnAttempt(queue, undefined, true); + const rotateRunTurnAdapterOnPreflight429 = async ( + error: Extract, + ): Promise => { + const status = error.status ?? adapterFailureFromMessage(error.message).httpStatus; + if ( + status !== 429 + || !genericFailoverAccountId + || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + || !isGenericOAuthFailoverEnabled(config, route.providerName) + ) return false; + const nextAccountId = rotateGenericOAuthAccountOn429( + config, + route.providerName, + genericFailoverAccountId, + null, + ); + if (!nextAccountId) return false; + try { + const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); + genericFailovers += 1; + if (!await applyFailoverSnapshot(snapshot)) return false; + // A Cursor conversation/checkpoint is credential-scoped. The failed attempt emitted no + // client-visible bytes, so replay is safe, but carrying its account identity into the next + // account would not be. Let the rotated adapter derive a fresh identity and conversation. + parsed._cursorIdentityScope = undefined; + parsed._cursorConversationId = undefined; + if (parsed._providerContinuation?.cursor) { + const { cursor: _discardedCursor, ...otherProviderState } = parsed._providerContinuation; + parsed._providerContinuation = otherProviderState; + } + const rotatedProvider = resolveWireProtocolOverride( + route.providerName, + route.modelId, + route.provider, + inboundWire, + ); + const rotatedAdapter = resolveSelectionAdapter(rotatedProvider, config.cacheRetention); + if (!rotatedAdapter.runTurn) return false; + runTurnAdapter = rotatedAdapter; + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: rotatedProvider, + adapterName: rotatedAdapter.name, + oauthCredentialSnapshot: { accountId: snapshot.accountId, generation: snapshot.generation }, + codexAuthContext: authCtx, + forwardHeaders: selectedForwardHeaders, + }); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, rotatedAdapter.name); + return true; + } catch { + return false; + } + }; + const preflightRunTurnFailover = async ( + firstSource: AsyncIterable, + ): Promise> => { + let source = firstSource; + while (true) { + const preflight = await preflightAdapterEvents(source); + if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) { + return preflight.stream; + } + const retryQueue = createAdapterEventQueue({ + onBacklogExceeded: () => runTurnAbort.abort(), + }); + void runTurnAttempt(retryQueue, "oauth-account-429"); + source = retryQueue.stream(); + } + }; + // The empty-completion retry re-runs the turn against a fresh queue: the + // first queue is closed once its attempt settles, and pushing into it after + // close is a silent no-op. + const runTurnRetrySource = (): AsyncIterable => { + const retryQueue = createAdapterEventQueue({ + onBacklogExceeded: () => runTurnAbort.abort(), + }); + void runTurnAttempt(retryQueue, "empty-completion"); + return retryQueue.stream(); + }; + + const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + if (parsed.stream) { + void runTurn(); + let eventSource: AsyncIterable = queue.stream(); + if (route.provider.authMode === "oauth" || (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName))) { + // Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be + // replayed transparently; after any output reaches the bridge, a later error stays terminal. + eventSource = await preflightRunTurnFailover(eventSource); + } + if (options.comboAttempt) { + const preflight = await preflightAdapterEvents(eventSource); + if (preflight.error || preflight.empty) { + runTurnAbort.abort(); + queue.close(); + const message = preflight.error?.message ?? "Adapter ended before producing a response"; + return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + } + eventSource = preflight.stream; + } + const guardedSource = emptyCompletionGuardEnabled + ? guardEmptyCompletionEventStream({ + firstEvents: eventSource, + // Identical-turn retry: same parsed request, same headers, same + // signal — run the adapter transport again against a fresh queue. + continuation: runTurnRetrySource, + }) + // Guard off (the default): leave the stream alone, but record that the turn ended + // empty so the user has something to correlate instead of an unexplained blank + // result (#2472). Retrying by default would re-send a turn that may already have had + // billable side effects, so the honest default is observability, not recovery. + : observeEmptyCompletion(eventSource, () => { + console.warn(emptyCompletionNotice(route.providerName, route.modelId)); + }); + const sseStream = bridgeToResponsesSSE( + guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + () => { + runTurnAbort.abort(); + queue.close(); + }, 2_000, + { + translatorBudget, + replayCacheScope: parsed._reasoningReplayScope, + ...(options.forceEmptyResponseId ? { responseId: "" } : {}), + stallTimeoutSec: config.stallTimeoutSec, + hideThinkingSummary: parsed.options.hideThinkingSummary, + declaredToolNames, + toolParameterSchemas, + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + ...(routedCompaction ? { compaction: true } : {}), + // grok-build's strict decoder dies on the typed response.heartbeat frame; its + // eventsource layer tolerates comment keep-alives. Codex needs the opposite. + ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}), + onUsage: usage => { + // Raw adapter usage, pre wire-normalization: the bridged SSE now always carries + // zero-default detail objects, so provenance must come from here (cache_detail_missing). + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + onCompletedResponse: (response: Record, providerState?: OcxProviderContinuationState) => { + commitReasoningReplayServingRoute(); + rememberKiroDeliveredFinalAnswer(adapter.name, response); + if (!routedCompaction) { + rememberResponseState( + parsed._rawBody, + response, + continuationStateForResponse(providerState), + responseStateOptions(adapterNeedsForcedContinuation(adapter.name)), + ); + } + }, + }, + ); + const bridgeTurnAc = new AbortController(); + const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, undefined, options.turnAdmissionLease); + const response = new Response(trackedSse, { + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" }, + }); + runTurnAdapterSseResponses.add(response); + return response; + } + + await runTurn(); + const firstAttemptEvents = await queue.collect(); + let runTurnEvents: AdapterEvent[] = firstAttemptEvents; + if (route.provider.authMode === "oauth" || (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName))) { + runTurnEvents = []; + for await (const event of await preflightRunTurnFailover( + (async function* () { yield* firstAttemptEvents; })(), + )) runTurnEvents.push(event); + } + let events: AdapterEvent[]; + if (emptyCompletionGuardEnabled) { + events = []; + for await (const event of guardEmptyCompletionEventStream({ + firstEvents: (async function* () { yield* runTurnEvents; })(), + continuation: runTurnRetrySource, + })) events.push(event); + } else { + events = runTurnEvents; + } + if (options.comboAttempt) { + const firstMeaningful = events.find(event => event.type !== "heartbeat"); + if (!firstMeaningful || firstMeaningful.type === "error") { + const message = firstMeaningful?.type === "error" + ? firstMeaningful.message + : "Adapter ended before producing a response"; + return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + } + } + let providerState: OcxProviderContinuationState | undefined; + const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { + translatorBudget, + replayCacheScope: parsed._reasoningReplayScope, + hideThinkingSummary: parsed.options.hideThinkingSummary, + toolNsMap, + declaredToolNames, + toolParameterSchemas, + freeformToolNames, + toolSearchToolNames, + ...(routedCompaction ? { compaction: true } : {}), + onProviderState: state => { providerState = state; }, + onUsage: usage => { + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + }); + if (!routedCompaction) { + rememberKiroDeliveredFinalAnswer(adapter.name, json); + rememberResponseState( + parsed._rawBody, + json, + continuationStateForResponse(providerState), + responseStateOptions(adapterNeedsForcedContinuation(adapter.name)), + ); + } + // #1926 gap 2: the buffered path queued its signature persists inside + // buildResponseJSON; bound the durability window before the JSON becomes + // externally visible. + await awaitThoughtSignatureDurability(); + if (adapterResponseReachedServingTerminal(events, json)) { + commitReasoningReplayServingRoute(); + } + return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); + } + + const upstream = new AbortController(); + const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal); + const connectMs = config.connectTimeoutMs ?? 200_000; + // Bridge stall budget (seconds of silence before upstream_stall_timeout); the retry backoff + // heartbeat interval is derived from it so the watchdog is always fed during deliberate waits. + const stallTimeoutMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0 + ? Math.floor(config.stallTimeoutSec * 1000) + : 300_000; + activeAdapter = adapter; + + // One immutable, body-safe outbound request per same-target sequence (URL, serialized body, + // auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the + // builder runs again only after a key/account/adapter rotation, an oauth refresh, or an + // image-tier bias change (transportToken bump). `body` is always a serialized string, so + // reuse is safe, and releaseBodyObservation is idempotent per build. + let initialRequest: AdapterRequest | undefined; + let inputTokenEstimate: number | undefined; + // An adapter may know the turn needs no inference at all — Kiro's replayed history ending in a + // delivered final answer. Answer it locally: no build (so no token estimate), no send (so + // sendCount stays 0), and crucially no empty-completion guard, which treats an outputless + // terminal as a failed turn and re-invokes the identical request. Routing this through the + // ordinary event path would therefore reinstate the loop it exists to end. + const localTerminal = activeAdapter.localTerminal?.(parsed); + if (localTerminal) { + logCtx.localTerminalReason = localTerminal.reason; + // Mark the physical attempt too, not just the parent row. `finishRequestAttempt` finalizes the + // attempt through the same estimated-provider path, so without this the row reads exact while + // its own attempt still claims an estimate — the detailed accounting a maintainer actually + // reads for a zero-send turn. + if (logCtx.activeAttempt) logCtx.activeAttempt.locallyAnswered = true; + cleanupUpstreamAbort(); + upstream.abort(); + const terminalEvents: AdapterEvent[] = [{ + type: "done", + endTurn: true, + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }]; + if (parsed.stream) { + const localSse = bridgeToResponsesSSE( + (async function* () { yield* terminalEvents; })(), + parsed._responseModelId ?? parsed.modelId, + toolBridgeMaps.toolNsMap, + toolBridgeMaps.freeformToolNames, + toolBridgeMaps.toolSearchToolNames, + undefined, + 2_000, + { + translatorBudget, + ...(options.forceEmptyResponseId ? { responseId: "" } : {}), + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + }, + ); + // Same lifetime tracking as every other streaming return in this function: the turn + // admission lease is released when the body finishes or the client disconnects. Returning + // the raw stream would hold a lease for a turn that already has all of its output. + const localTurnAc = new AbortController(); + return new Response( + trackStreamLifetime(localSse, localTurnAc, undefined, options.turnAdmissionLease), + { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + }, + ); + } + return new Response( + JSON.stringify(buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, { + translatorBudget, + })), + { headers: { "Content-Type": "application/json" } }, + ); + } + // One request-scoped transient-retry budget owner, declared here so BOTH the initial send + // and the later recovery refetches (429, key/account rotation, OAuth replay) share it. A + // per-leg budget would let a request that recovers several times multiply upstream load. + let transientSendsUsed = 0; + const noteTransientSends = (used: number): void => { transientSendsUsed += Math.max(0, used); }; + const remainingTransientSendBudget = (budget: number): number => + Math.max(1, budget - transientSendsUsed); + try { + initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); + refreshRoutedNamespaceToolAliases(initialRequest); + recordAdapterReasoning(logCtx, initialRequest); + recordAdapterTier(logCtx, initialRequest); + inputTokenEstimate = typeof initialRequest.usageLog?.inputTokens === "number" + ? initialRequest.usageLog.inputTokens + : undefined; + if (inputTokenEstimate !== undefined) logCtx.usageLogInputTokens = inputTokenEstimate; + } catch (err) { + // A throwing buildRequest never returned a request; if a post-build step threw, release + // the serialized-body observation (idempotent) so the translator budget is not leaked. + // The build runs after linkAbortSignal, so a failure must also tear the link down and + // abort the upstream controller instead of escaping handleResponses unmapped. + initialRequest?.releaseBodyObservation?.(); + cleanupUpstreamAbort(); + upstream.abort(); + if (options.abortSignal?.aborted) return clientCancelledResponse(); + const msg = err instanceof Error ? err.message : String(err); + return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)); + } + // The catch path above always returns, so the request is definitely assigned here. + // Capture it in a const so the fetch callbacks read a narrowed, immutable value + // (TypeScript drops narrowing for a `let` captured by a nested function). + const builtInitialRequest = initialRequest; + sameTargetRequest = builtInitialRequest; + sameTargetParsed = parsed; + sameTargetToken = transportToken; + /** + * Invalidate the same-target request cache. Every credential/adapter/parsed mutation MUST + * go through here: the cache keys on `parsed` REFERENCE identity, so an in-place mutation + * is invisible to it and a missed bump would replay a request built with a stale key. + */ + + let upstreamResponse: Response; + try { + if (activeAdapter.fetchResponse) { + noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate); + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); + upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + stream: parsed.stream, + executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtInitialRequest), + providerName: route.providerName, + modelId: route.modelId, + }), + }); + } else { + // #1851 scope guard: transient-5xx retry on this generic adapter path is opt-in for + // direct Google AI Studio only (Vertex/Antigravity use fetchResponse above). Other + // adapters keep reset-only retry so combo failover still hops on the first 5xx + // instead of burning ~1.2s of same-target retries per hop. + // #2643: an opted-in key-auth openai-chat provider also gets transient-5xx retry. The + // legacy direct-Google exception is preserved exactly; every other adapter still keeps + // reset-only semantics so combo failover hops on the first 5xx. + const transientPolicy = transientRetryPolicyFor(route.provider); + const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; + upstreamResponse = await fetchWithRetryPolicy( + recovery => { + noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery); + return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({ + method: builtInitialRequest.method, + headers: builtInitialRequest.headers, + body: builtInitialRequest.body, + }, recovery), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtInitialRequest), + providerName: route.providerName, + modelId: route.modelId, + })); + }, + { + abortSignal: upstream.signal, + label: safeHostLabel(builtInitialRequest.url), + ...(transientPolicy + ? { attempts: transientPolicy.attempts, onSendsConsumed: noteTransientSends } + : {}), + }, + ); + } + } catch (err) { + cleanupUpstreamAbort(); + upstream.abort(); + if (options.abortSignal?.aborted) return clientCancelledResponse(); + const msg = describeUpstreamConnectFailure(err, connectMs); + return formatErrorResponse(502, "upstream_error", msg); + } finally { + builtInitialRequest.releaseBodyObservation?.(); + } + + // Same-target 429 retry budget is per REQUEST: it lives OUTSIDE the recovery loop (so a 413/401 + // replay that comes back 429 cannot silently re-arm a fresh budget) and is SHARED with the + // terminal-guard continuation below, so the main loop + one continuation can never exceed + // `attempts` same-key replays in total (bounded per request). + const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider); + let rateLimitRetries = 0; + // Shared with the terminal-guard continuation below: an image-tier reduction that let the + // main request clear a 413 must not be forgotten on the very next continuation build. + if (!upstreamResponse.ok) { + // Recovery loop: multi-key 429 failover + at most ONE opaque-state rebuild and ONE + // anthropic 413 tightened retry + // (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves + // both paths so a 429→413 sequence never rebuilds against a stale pre-rotation + // adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a + // 413→429 rotation cannot silently undo the tightening. + let imageRetryAttempted = false; + const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false }; + let oauth401ReplayAttempted = false; + /** + * Rebuild the request from the current parsed input (and any image-tier bias) and refetch + * it once, tagging the attempt with the given recovery kind. Rebuilds are deterministic + * for the same parsed request, so same-target replays stay byte-identical. + */ + const rebuildAndRefetch = async ( + recovery: AttemptRecoveryKind, + ): Promise => { + let retryRequest: AdapterRequest; + if (sameTargetRequest !== undefined && sameTargetParsed === parsed && sameTargetToken === transportToken) { + // Same target (key/adapter/parsed/tier unchanged): replay the exact cached request. + retryRequest = sameTargetRequest; + } else { + try { + retryRequest = await activeAdapter.buildRequest(parsed, { + headers: selectedForwardHeaders, + translatorBudget, + ...(imageTierBias > 0 ? { imageTierBias } : {}), + }); + recordAdapterReasoning(logCtx, retryRequest); + recordAdapterTier(logCtx, retryRequest); + } catch (err) { + // A rotated/rebuilt adapter build failure is a request-shaping error, not an + // upstream connect failure: tear the abort link down and map it as 400 (no 413 + // translator-budget mapping here — that stays with parseRequest/buildToolBridgeMaps). + cleanupUpstreamAbort(); + upstream.abort(); + if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() }; + const msg = err instanceof Error ? err.message : String(err); + return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) }; + } + sameTargetRequest = retryRequest; + sameTargetParsed = parsed; + sameTargetToken = transportToken; + } + refreshRoutedNamespaceToolAliases(retryRequest); + const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number" + ? retryRequest.usageLog.inputTokens + : undefined; + if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate; + logCtx.providerAdapter = activeAdapter.name; + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); + noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery); + try { + try { + if (activeAdapter.fetchResponse) { + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); + return await activeAdapter.fetchResponse(retryRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + stream: parsed.stream, + executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(retryRequest), + providerName: route.providerName, + modelId: route.modelId, + }), + }); + } + // #2643 review: this leg used to call fetchWithHeaderTimeout directly, so an + // opted-in provider's transient-5xx policy applied to the initial send and to + // native chat but was silently bypassed here — a 429 that recovered into a + // retryable 503 got no retry on the Responses path. Route it through the same + // selection, and pass what is LEFT of the request-scoped budget rather than a + // fresh one, so a recovery loop cannot multiply total upstream sends. + const refetchTransientPolicy = transientRetryPolicyFor(route.provider); + const refetchWithPolicy = (route.provider.adapter === "google" || refetchTransientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; + return await refetchWithPolicy( + recoveryKind => fetchWithHeaderTimeout(retryRequest.url, + applyUpstreamRecoveryInit({ + method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, + }, recoveryKind), upstream.signal, connectMs, parsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(retryRequest), + providerName: route.providerName, + modelId: route.modelId, + })), + { + abortSignal: upstream.signal, + label: safeHostLabel(retryRequest.url), + ...(refetchTransientPolicy + ? { + attempts: remainingTransientSendBudget(refetchTransientPolicy.attempts), + onSendsConsumed: noteTransientSends, + } + : {}), + }, + ); + } finally { + retryRequest.releaseBodyObservation?.(); + } + } catch (err) { + cleanupUpstreamAbort(); + upstream.abort(); + if (options.abortSignal?.aborted) { + return { failed: clientCancelledResponse() }; + } + const msg = describeUpstreamConnectFailure(err, connectMs); + return { failed: formatErrorResponse(502, "upstream_error", msg) }; + } + }; + // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above. + recovery: for (;;) { + if ( + upstreamResponse.status === 401 + && isOAuth401ReplayProvider + && sentOAuthSnapshot + && !oauth401ReplayAttempted + ) { + oauth401ReplayAttempted = true; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + let refreshed: OAuthAccessSnapshot; + try { + refreshed = await refreshResolvedOAuthSelection(sentOAuthSnapshot); + } catch (err) { + cleanupUpstreamAbort(); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); + } + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { + cleanupUpstreamAbort(); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); + } + sentOAuthSnapshot = refreshed; + replayOAuthCredentialSnapshot = { + accountId: refreshed.accountId, + generation: refreshed.generation, + }; + if (route.providerName === "kiro") { + parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + } + const refreshedProvider = resolveProviderTransport( + route.providerName, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), + }, + parsed.options.promptCacheKey, + route.providerName === "github-copilot" + ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) + : undefined, + ); + route.provider = refreshedProvider; + invalidateSameTargetRequest(); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: refreshedProvider, + adapterName: activeAdapter.name, + oauthCredentialSnapshot: replayOAuthCredentialSnapshot, + }); + const result = await rebuildAndRefetch("oauth-401"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } + + // Static API-key pools can recover a credential-scoped 401 without abandoning the + // provider: one revoked or mistyped key says nothing about its siblings. OAuth providers + // refresh above and never enter here — `hasKeyPoolFailover` rejects oauth/forward modes. + // Runs after the OAuth replay so a refreshable token is never treated as a dead key. + while (upstreamResponse.status === 401 && hasKeyPoolFailover(route.provider)) { + const rotated = rotateProviderTransportOn401(config, route.providerName, route.provider, { + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (!rotated) break; + // Release the failed response's socket before retrying; unread bodies otherwise linger + // until runtime cleanup (one per rotated key). + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + route.provider = rotated; + invalidateSameTargetRequest(); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: route.provider, + adapterName: activeAdapter.name, + }); + const result = await rebuildAndRefetch("key-401"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } + + // Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries + // 429 itself (it retries 5xx only), and single-key pools cannot use the failover below, + // so wait (Retry-After or the fixed interval) and replay the IDENTICAL request on the + // same key first. Pre-stream only: a 429 arrives before any bytes are relayed, so the + // replay is lossless. Runs before key failover so "primary-first" setups keep the same + // key on rate-limit blips; only after the attempts are exhausted does failover run. + while ( + upstreamResponse.status === 429 + && rateLimitPolicy !== null + && rateLimitRetries < rateLimitPolicy.attempts + ) { + rateLimitRetries += 1; + // Release unread body + deliberate wait via the shared same-target helper. + const retryAfterHeader = upstreamResponse.headers.get("retry-after"); + try { + for await (const _ of prepareSameTarget429Wait({ + body: upstreamResponse.body, + signal: options.abortSignal, + delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()), + })) { + // pre-stream: no stall watchdog to feed + } + } catch { + cleanupUpstreamAbort(); + upstream.abort(); + return clientCancelledResponse(); + } + // Client cancellation wins over any stale timer edge: re-check before dispatching the + // replay so an adapter never starts work for a request the client already abandoned. + if (options.abortSignal?.aborted || upstream.signal.aborted) { + cleanupUpstreamAbort(); + upstream.abort(); + return clientCancelledResponse(); + } + const result = await rebuildAndRefetch("rate-limit-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } + + // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the + // SAME request once per remaining key. OAuth/forward providers and single-key pools + // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts). + while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) { + const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { + retryAfter: upstreamResponse.headers.get("retry-after"), + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (!rotated) break; + // Release the failed response's socket before retrying; unread bodies otherwise linger + // until runtime cleanup (one per rotated key under a rate-limit storm). + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + route.provider = rotated; + invalidateSameTargetRequest(); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: route.provider, + adapterName: activeAdapter.name, + }); + const result = await rebuildAndRefetch("key-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } + + // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry + // with another eligible OAuth account (bounded per request). Disabled by default. + while ( + upstreamResponse.status === 429 + && anthropicPoolAccountId + && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST + ) { + const nextAccountId = rotateAnthropicAccountOn429( + config, + anthropicPoolAccountId, + upstreamResponse.headers.get("retry-after"), + anthropicSessionKey, + ); + if (!nextAccountId) break; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + try { + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId)); + if (!admitted) throw new Error("OAuth selection changed during recovery"); + anthropicPoolAccountId = admitted.accountId; + anthropicPoolFailovers += 1; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + invalidateSameTargetRequest(); + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); + const result = await rebuildAndRefetch("anthropic-oauth-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } catch { + break; + } + } + // Generic OAuth account failover (#2568) for providers with no pool of their own. + // Presence is consent since #2568d: rotation is ON by default once two or more eligible + // accounts are stored for the provider, because a second deliberate login is read as the + // operator asking for it. A single-account install is still a strict no-op, and an + // explicit `oauthAccountFailover.enabled: false` (global or per provider) still wins -- + // see isGenericOAuthFailoverEnabled in src/oauth/generic-account-failover.ts. Codex and + // Anthropic are excluded by isGenericFailoverProvider: their pools own quota scopes, + // probe leases and affinity that this must not reimplement. + while ( + upstreamResponse.status === 429 + && genericFailoverAccountId + && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && isGenericOAuthFailoverEnabled(config, route.providerName) + ) { + const nextAccountId = rotateGenericOAuthAccountOn429( + config, + route.providerName, + genericFailoverAccountId, + upstreamResponse.headers.get("retry-after"), + ); + if (!nextAccountId) break; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + try { + // The FULL snapshot, not just the bearer: Antigravity pairs an account-matched + // projectId with its token and Kiro carries routing metadata, so a token-only swap + // would mix one account's credential with another's routing data. + const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); + genericFailovers += 1; + if (!await applyFailoverSnapshot(snapshot)) break; + invalidateSameTargetRequest(); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); + const result = await rebuildAndRefetch("oauth-account-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } catch { + break; + } + } + // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry, + // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is + // the missing authoritative signal. Rebuild once through the same sanitation path used by a + // known route switch; invalidating is mandatory because `parsed` mutates in place and the + // same-target cache would otherwise replay the rejected bytes verbatim. + const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({ + response: upstreamResponse, + outboundBody: sameTargetRequest?.body, + adapterName: activeAdapter.name, + parsed, + guard: opaqueBlobRecoveryGuard, + signal: upstream.signal, + }, recovery => { + invalidateSameTargetRequest(); + return rebuildAndRefetch(recovery); + }); + if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response; + if (opaqueBlobRecovery.kind === "recovered") { + upstreamResponse = opaqueBlobRecovery.response; + continue recovery; + } + // Anthropic 413 request_too_large: rebuild once with every image one tier lower + // (spiral guard: single attempt). The biased response re-enters the 429 check above. + if (shouldAttemptImageTierRetry({ + status: upstreamResponse.status, + adapterName: activeAdapter.name, + parsed, + alreadyAttempted: imageRetryAttempted, + })) { + imageRetryAttempted = true; + imageTierBias = 1; + invalidateSameTargetRequest(); + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + const result = await rebuildAndRefetch("image-413"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue recovery; + } + break; + } + if (!upstreamResponse.ok) { + if (options.comboAttempt) { + // No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads + // `response.body` itself with the abort signal threaded through, and the combo + // contract is that this body's getter is touched exactly once. A guard here would be + // a second `.body` access for no gain, since the bounded reader owns settlement. + const failure = await consumeComboFailure(upstreamResponse, options.abortSignal) + .finally(cleanupUpstreamAbort); + options.onConsumedComboFailure?.(failure); + return failure.response; + } + let errorText: string; + try { + errorText = await readDisplaySafeErrorText( + upstreamResponse, + upstream.signal, + "unknown error", + ); + } finally { + cleanupUpstreamAbort(); + } + if (upstreamResponse.status === 413 && clientRequestedStream && !options.comboAttempt) { + return streamingContextOverflowResponse( + parsed._responseModelId ?? parsed.modelId, + translatorBudget, + ); + } + if (!isFixedCodexAccount(authCtx)) { + recordSubagentQuotaFailureForThreadSpawn( + req.headers, + subagentQuotaFailureModel, + upstreamResponse.status === 429 || upstreamResponse.status === 402 + ? upstreamResponse.status + : `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`, + config, + subagentFallbackAccountId, + ); + } + // Upstreams occasionally echo request details in error bodies — scrub token-shaped + // material before it reaches the client-facing error surface. + const upstreamRetryAfter = upstreamResponse.headers.get("retry-after"); + const normalized = normalizeUpstreamErrorText(errorText, "unknown error"); + const message = normalized.cyberPolicy + ? normalized.message + ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText) + : enrichOpenCodeZenRateLimitMessage( + `Provider error ${upstreamResponse.status}: ${normalized.safeText}`, + { + status: upstreamResponse.status, + providerName: route.providerName, + baseUrl: route.provider.baseUrl, + adapter: route.provider.adapter, + authMode: route.provider.authMode, + hasApiKey: Boolean(route.provider.apiKey?.trim()), + upstreamRetryAfter, + // This recovery path is the HTTP Responses wire; custom runTurn transports + // never reach enrichOpenCodeZenRateLimitMessage here. + supportsHttpSameKeyRetry: true, + }, + ); + const retryAfter = normalized.cyberPolicy + ? undefined + : resolveClientRetryAfter({ + status: upstreamResponse.status, + message, + upstreamRetryAfter, + }); + return formatErrorResponse( + upstreamResponse.status, + normalized.cyberPolicy ? (normalized.type ?? CYBER_POLICY_ERROR_CODE) : "upstream_error", + message, + { + ...(normalized.cyberPolicy ? { code: CYBER_POLICY_ERROR_CODE } : {}), + ...(retryAfter !== undefined ? { retryAfter } : {}), + }, + ); + } + } + + cancelBodyOnAbort(upstreamResponse.body, upstream.signal); + + // One bounded internal continuation re-ask for clean end_turn turns that announced an edit + // without emitting a tool call. Anthropic gets this by default; openai-chat providers opt in + // per-provider via `terminalContinuationGuard` (the heuristic was tuned on Anthropic turns, + // so it stays off for the shared openai-chat adapter unless a provider enables it). + const terminalGuardEnabled = (activeAdapter.name === "anthropic" + || (activeAdapter.name === "openai-chat" && route.provider.terminalContinuationGuard === true)) + && !options.comboAttempt && !routedCompaction; + /** + * One bounded internal re-ask for Anthropic end_turn-without-tool-call turns. Replays the + * continuation on a 429 with the same-key retry budget (hoisted per request), then falls + * back to key/account failover; a failure becomes an in-stream adapter error so the client + * never sees a second hidden HTTP response or an unbounded retry loop. + */ + const fetchTerminalGuardContinuation = async function* ( + nextParsed: OcxParsedRequest, + initialRecoveryKind?: AttemptRecoveryKind, + ): AsyncGenerator { + let response: Response | undefined; + // One-shot recovery label for the next top-of-loop continuation send after a failover rotation. + let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined = initialRecoveryKind; + /** + * Build and fetch one terminal-guard continuation. `recoveryKind` tags same-target and + * failover sends (`empty-completion`, `rate-limit-429`, `key-429`, + * `anthropic-oauth-429`, `image-413`); the + * adapter rebuild is deterministic for the same parsed request (tests assert byte-identical + * replays). + */ + const fetchContinuation = async (recoveryKind?: AttemptRecoveryKind): Promise => { + let continuationRequest: AdapterRequest | undefined; + if (sameTargetRequest !== undefined && sameTargetParsed === nextParsed && sameTargetToken === transportToken) { + // Same target (key/adapter/parsed/tier unchanged): replay the exact cached request. + continuationRequest = sameTargetRequest; + } else { + try { + continuationRequest = await activeAdapter.buildRequest(nextParsed, { + headers: selectedForwardHeaders, + translatorBudget, + ...(imageTierBias > 0 ? { imageTierBias } : {}), + }); + recordAdapterReasoning(logCtx, continuationRequest); + recordAdapterTier(logCtx, continuationRequest); + } catch (err) { + // The main body is already streaming, so there is no HTTP error surface: release + // any partial body observation and surface the failure as an in-stream error via + // the outer catch (no upstream.abort() — that would kill the live body stream). + continuationRequest?.releaseBodyObservation?.(); + throw err; + } + sameTargetRequest = continuationRequest; + sameTargetParsed = nextParsed; + sameTargetToken = transportToken; + } + // Both branches assign the request (the build catch rethrows), so capture it in a + // const for the fetch callback and finally below — a `let` read inside a nested + // function keeps its undefined half, which would break the byte-identical replay. + const builtContinuationRequest = continuationRequest; + const continuationEstimate = typeof builtContinuationRequest.usageLog?.inputTokens === "number" + ? builtContinuationRequest.usageLog.inputTokens + : undefined; + if (continuationEstimate !== undefined) logCtx.usageLogInputTokens = continuationEstimate; + // Optional recovery label for same-target / failover continuation sends. + const replayKind: AttemptRecoveryKind | undefined = recoveryKind; + try { + if (activeAdapter.fetchResponse) { + noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind); + await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal); + return await activeAdapter.fetchResponse(builtContinuationRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + stream: nextParsed.stream, + executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), + providerName: route.providerName, + modelId: nextParsed.modelId, + }), + }); + } + // Same #1851 scope guard as the initial send: transient-5xx retry only for direct + // Google AI Studio; every other adapter keeps reset-only semantics here. + const continuationTransientPolicy = transientRetryPolicyFor(route.provider); + const fetchContinuationWithRetryPolicy = (route.provider.adapter === "google" || continuationTransientPolicy) + ? fetchWithTransientRetry + : fetchWithResetRetry; + return await fetchContinuationWithRetryPolicy( + recovery => { + noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind); + return fetchWithHeaderTimeout( + builtContinuationRequest.url, + applyUpstreamRecoveryInit({ + method: builtContinuationRequest.method, + headers: builtContinuationRequest.headers, + body: builtContinuationRequest.body, + }, recovery), + upstream.signal, + connectMs, + nextParsed.stream, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + dispatchOverride: oauthDispatch(builtContinuationRequest, nextParsed), + providerName: route.providerName, + modelId: nextParsed.modelId, + }), + ); + }, + { + abortSignal: upstream.signal, + label: safeHostLabel(builtContinuationRequest.url), + // Same request-scoped budget as the initial send and the 429/rotation refetches: + // a terminal-guard continuation is another leg of ONE request, so handing it a + // fresh `attempts` would let one request exceed the configured total-send ceiling. + ...(continuationTransientPolicy + ? { + attempts: remainingTransientSendBudget(continuationTransientPolicy.attempts), + onSendsConsumed: noteTransientSends, + } + : {}), + }, + ); + } finally { + builtContinuationRequest.releaseBodyObservation?.(); + } + }; + while (true) { + try { + const recoveryKind = nextContinuationRecoveryKind; + nextContinuationRecoveryKind = undefined; + response = await fetchContinuation(recoveryKind); + } catch (error) { + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + } else { + yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; + } + return; + } + + // Same-target 429 wait-and-retry (opt-in `retryOn429`) before key/account failover: + // a primary-key rate-limit blip replays on the SAME key, matching the main recovery + // loop; only after the attempts are exhausted does the continuation fail over. + while ( + response.status === 429 + && rateLimitPolicy !== null + && rateLimitRetries < rateLimitPolicy.attempts + ) { + rateLimitRetries += 1; + // Release unread body + heartbeat-fed wait via the shared same-target helper. + const retryAfterHeader = response.headers.get("retry-after"); + try { + yield* prepareSameTarget429Wait({ + body: response.body, + // Listen on the upstream signal: once the SSE body is being streamed, a client + // cancel aborts `upstream` through the bridge, and upstream is also linked from + // options.abortSignal — so this covers both cancellation paths. + signal: upstream.signal, + delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()), + heartbeatIntervalMs: Math.min(10_000, Math.max(250, stallTimeoutMs / 2)), + }); + } catch { + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + } else { + yield { type: "error", message: "Provider continuation failed: retry wait interrupted" }; + } + return; + } + // Client cancellation wins over any stale timer edge: re-check before dispatching the + // replay so the continuation never starts work for a request the client abandoned. + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + return; + } + try { + response = await fetchContinuation("rate-limit-429"); + } catch (error) { + if (options.abortSignal?.aborted || upstream.signal.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + } else { + yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; + } + return; + } + } + + if (response.status === 429 && hasKeyPoolFailover(route.provider)) { + const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { + retryAfter: response.headers.get("retry-after"), + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: nextParsed.options.promptCacheKey, + }); + if (rotated) { + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + route.provider = rotated; + invalidateSameTargetRequest(); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed: nextParsed, + providerName: route.providerName, + provider: route.provider, + adapterName: activeAdapter.name, + }); + // Response persistence closes over the outer parsed request; keep its owner binding in + // sync with the terminal-guard clone that builds the rotated continuation request. + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: route.provider, + adapterName: activeAdapter.name, + }); + nextContinuationRecoveryKind = "key-429"; + continue; + } + } + if ( + response.status === 429 + && anthropicPoolAccountId + && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST + ) { + const nextAccountId = rotateAnthropicAccountOn429( + config, + anthropicPoolAccountId, + response.headers.get("retry-after"), + anthropicSessionKey, + ); + if (nextAccountId) { + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + try { + const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId)); + if (!admitted) throw new Error("OAuth selection changed during recovery"); + anthropicPoolAccountId = admitted.accountId; + anthropicPoolFailovers += 1; + route.provider = { ...route.provider, apiKey: admitted.accessToken }; + invalidateSameTargetRequest(); + logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); + nextContinuationRecoveryKind = "anthropic-oauth-429"; + continue; + } catch { + // fall through to emit continuation error below + } + } + } + // Generic OAuth rotation for the continuation loop. The streaming loop grew this arm with + // #2568 and this one did not, so an xAI/Cursor/Kimi/Copilot/Antigravity/Nous continuation + // 429 stayed terminal even with failover fully active -- the same class of divergence the + // two sidecars already produced once. Request-local state is shared with the other arms so + // the per-request bound cannot be silently re-armed by reaching a different loop. + if ( + response.status === 429 + && genericFailoverAccountId + && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && isGenericOAuthFailoverEnabled(config, route.providerName) + ) { + const nextAccountId = rotateGenericOAuthAccountOn429( + config, + route.providerName, + genericFailoverAccountId, + response.headers.get("retry-after"), + ); + if (nextAccountId) { + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + try { + // The FULL snapshot through the shared helper, never a bare bearer: Antigravity + // pairs an account-matched projectId with its token and Kiro carries routing + // metadata, so a token-only swap would mix one account's credential with another's + // routing data. + const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); + genericFailovers += 1; + if (await applyFailoverSnapshot(snapshot, nextParsed)) { + invalidateSameTargetRequest(); + activeAdapter = resolveSelectionAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name); + nextContinuationRecoveryKind = "oauth-account-429"; + continue; + } + } catch { + // fall through to emit continuation error below + } + } + } + if (shouldAttemptImageTierRetry({ + status: response.status, + adapterName: activeAdapter.name, + parsed: nextParsed, + alreadyAttempted: imageTierBias > 0, + })) { + imageTierBias = 1; + invalidateSameTargetRequest(); + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + nextContinuationRecoveryKind = "image-413"; + continue; + } + break; + } + + if (!response.ok) { + const errorText = await readDisplaySafeErrorText(response, upstream.signal, "unknown error"); + const normalized = normalizeUpstreamErrorText(errorText, "unknown error"); + yield { + type: "error", + status: normalized.cyberPolicy ? 400 : response.status, + message: normalized.cyberPolicy + ? normalized.message + ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText) + : `Provider continuation error ${response.status}: ${normalized.safeText}`, + ...(normalized.cyberPolicy + ? { + errorType: normalized.type ?? CYBER_POLICY_ERROR_CODE, + code: CYBER_POLICY_ERROR_CODE, + retryable: false, + } + : {}), + }; + return; + } + + try { + // Protect the continuation body against a client abort landing between fetch resolution and + // reader attach, exactly as the initial response is guarded above (#390/366e3053). Without + // this, a client cancel during the continuation reopens the Bun fetch-to-reader abort race. + const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal); + try { + if (nextParsed.stream) { + yield* activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata); + } else if (activeAdapter.parseResponse) { + yield* await activeAdapter.parseResponse(response, translatorBudget, logCtx.activeTierMetadata); + } else { + yield { type: "error", message: "Provider continuation does not support response parsing" }; + } + } finally { + detachContinuationBodyGuard(); + } + } catch (error) { + if (options.abortSignal?.aborted) { + yield { type: "error", message: "client closed request during terminal continuation", status: 499 }; + } else { + yield { type: "error", message: `Provider continuation parse failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` }; + } + } + }; + + const fetchGuardedEmptyCompletionRetry = (): AsyncIterable => { + const retryEvents = fetchTerminalGuardContinuation(parsed, "empty-completion"); + return terminalGuardEnabled + ? guardTerminalEventStream({ + parsed, + firstEvents: retryEvents, + adapterName: activeAdapter.name, + maxAutoContinuations: 1, + continuation: fetchTerminalGuardContinuation, + }) + : retryEvents; + }; + + if (parsed.stream) { + const initialEventStream = activeAdapter.parseStream( + upstreamResponse, + translatorBudget, + logCtx.activeTierMetadata, + ); + const eventStream = terminalGuardEnabled + ? guardTerminalEventStream({ + parsed, + firstEvents: initialEventStream, + adapterName: activeAdapter.name, + maxAutoContinuations: 1, + continuation: fetchTerminalGuardContinuation, + }) + : initialEventStream; + // The empty-completion guard sits OUTSIDE the terminal guard: a completed + // turn with no text and no tool call is retried with the IDENTICAL request + // (fetchTerminalGuardContinuation(parsed) replays the cached byte-identical + // request — same body, same headers, same signal). + const guardedEventStream = emptyCompletionGuardEnabled + ? guardEmptyCompletionEventStream({ + firstEvents: eventStream, + continuation: fetchGuardedEmptyCompletionRetry, + }) + : eventStream; + const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const sseStream = bridgeToResponsesSSE( + guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + () => upstream.abort(), 2_000, + { + translatorBudget, + replayCacheScope: parsed._reasoningReplayScope, + ...(options.forceEmptyResponseId ? { responseId: "" } : {}), + stallTimeoutSec: config.stallTimeoutSec, + hideThinkingSummary: parsed.options.hideThinkingSummary, + declaredToolNames, + toolParameterSchemas, + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + ...(routedCompaction ? { compaction: true } : {}), + // Same grok-surface split as the runTurn branch above. + ...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}), + onUsage: usage => { + // Raw adapter usage, pre wire-normalization (see the runTurn branch above). + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + onCompletedResponse: (response: Record, providerState?: OcxProviderContinuationState) => { + commitReasoningReplayServingRoute(); + rememberKiroDeliveredFinalAnswer(activeAdapter.name, response); + // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full + // PRE-compaction history, and a later previous_response_id expansion would rehydrate the + // giant stale chain Codex just replaced. + if (!routedCompaction) { + rememberResponseState( + parsed._rawBody, + response, + continuationStateForResponse(providerState), + responseStateOptions(activeAdapter.name === "kiro"), + ); + } + }, + }, + ); + const bridgeTurnAc = new AbortController(); + const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, cleanupUpstreamAbort, options.turnAdmissionLease); + return new Response(trackedSse, { + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" }, + }); + } + + if (activeAdapter.parseResponse) { + let events: AdapterEvent[]; + try { + const initialEvents = await activeAdapter.parseResponse( + upstreamResponse, + translatorBudget, + logCtx.activeTierMetadata, + ); + let guardedEvents: AdapterEvent[]; + if (terminalGuardEnabled) { + guardedEvents = []; + for await (const event of guardTerminalEventStream({ + parsed, + firstEvents: (async function* () { yield* initialEvents; })(), + adapterName: activeAdapter.name, + maxAutoContinuations: 1, + continuation: fetchTerminalGuardContinuation, + })) guardedEvents.push(event); + } else { + guardedEvents = initialEvents; + } + if (emptyCompletionGuardEnabled) { + events = []; + for await (const event of guardEmptyCompletionEventStream({ + firstEvents: (async function* () { yield* guardedEvents; })(), + continuation: fetchGuardedEmptyCompletionRetry, + })) events.push(event); + } else { + events = guardedEvents; + } + } finally { + cleanupUpstreamAbort(); + } + const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + let providerState: OcxProviderContinuationState | undefined; + const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { + translatorBudget, + replayCacheScope: parsed._reasoningReplayScope, + hideThinkingSummary: parsed.options.hideThinkingSummary, + toolNsMap, + declaredToolNames, + toolParameterSchemas, + freeformToolNames, + toolSearchToolNames, + ...(routedCompaction ? { compaction: true } : {}), + onProviderState: state => { providerState = state; }, + onUsage: usage => { + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + }); + // See the streaming branch: compaction turns skip the continuation cache. + if (!routedCompaction) { + rememberKiroDeliveredFinalAnswer(activeAdapter.name, json); + rememberResponseState( + parsed._rawBody, + json, + continuationStateForResponse(providerState), + responseStateOptions(activeAdapter.name === "kiro"), + ); + } + // #1926 gap 2: same buffered-path durability bound as the primary branch. + await awaitThoughtSignatureDurability(); + if (adapterResponseReachedServingTerminal(events, json)) { + commitReasoningReplayServingRoute(); + } + return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); + } + + return formatErrorResponse(400, "invalid_request_error", "Non-streaming not supported by this adapter"); + } finally { + if (pendingHostAdmissionLease) { + releaseUpstreamHostAdmission(pendingHostAdmissionLease); + releaseCodexAuthContextProbeLease(authCtx); + } + } +} + + + +export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): () => void { + if (!signal) return () => {}; + if (signal.aborted) { + upstream.abort(signal.reason); + return () => {}; + } + const onAbort = () => upstream.abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + return () => signal.removeEventListener("abort", onAbort); +} diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 5a3f09b69c..b6365be4be 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -9,9 +9,6 @@ import type { OcxProviderConfig } from "../../types"; import type { WsData } from "../ws-bridge"; import { waitForProviderRequestSlot } from "../../providers/request-pacing"; import { withUpstreamHttpVersion } from "../../lib/upstream-http-version"; -import { providerTlsFetch } from "../../lib/provider-tls-profile"; -import { testProviderFetch } from "../../lib/test-provider-fetch"; -import { runtimeProviderFetch } from "../../lib/provider-runtime-fetch"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; export { withUpstreamHttpVersion }; @@ -55,25 +52,17 @@ export interface PaceAwareFetch { export type ProviderFetch = typeof globalThis.fetch & PaceAwareFetch; -export class UpstreamRedirectError extends Error { - override readonly name = "UpstreamRedirectError"; - - constructor(readonly status: number) { - super(`upstream returned ${status} redirect; configure the final upstream URL directly`); - } -} - export interface ProviderFetchOptions { providerName?: string; modelId?: string; /** One pacing slot was acquired immediately before this fetch wrapper was created. */ pacingSlotAcquired?: boolean; - /** Explicit test/integration executor; never read from serialized provider config. */ - fetch?: typeof globalThis.fetch; /** Captured selected-account observer, attached before the native WS send. */ onCodexWsQuota?: CodexWsQuotaObserver; /** Synchronous admission at actual credential dispatch, after pacing/backoff. */ beforeDispatch?: (headers: Headers) => void; + /** Revalidate/rebuild a queued request at its physical send boundary, after pacing. */ + dispatchOverride?: (input: Parameters[0], init: RequestInit, execute: typeof globalThis.fetch) => Promise; } export function providerFetch( @@ -81,17 +70,17 @@ export function providerFetch( runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), options: ProviderFetchOptions = {}, ): ProviderFetch { - const base = options.fetch ?? testProviderFetch(provider) ?? runtimeProviderFetch(provider, options.providerName) ?? globalThis.fetch; + const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; - const transport = options.providerName - ? providerTlsFetch(options.providerName, provider, base) - : base; const httpFetch = Object.assign( async (input: Parameters[0], init?: RequestInit) => { options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); - return transport(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }); + const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }; + return options.dispatchOverride + ? options.dispatchOverride(input, dispatchInit, base) + : base(input, dispatchInit); }, { preconnect }, ) as typeof globalThis.fetch; @@ -100,19 +89,12 @@ export function providerFetch( // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details. const unpaced = async (input: Parameters[0], init?: RequestInit) => { const upstreamWebsocket = provider.upstreamWebsocket === true; - const wsOpts = { - // Keep the canonical ChatGPT fast lane independent: upstreamWebsocket opts a - // configured HTTPS /responses endpoint in, but must not silently enable Codex WS. - wsUpstream: provider.wsUpstream, - maxWsFrameBytes: provider.maxWsFrameBytes, - upstreamWebsocket, - }; - if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, wsOpts)) { + if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { // The fallback has to be the same HTTP fetch the non-WS branch would have // used, protocol pin included: a WS turn that falls back is serving the // request over HTTP, and dropping the provider's `upstreamHttpVersion` // there would silently negotiate a transport the operator ruled out. - return codexWsUpstreamFetch(input, init, httpFetch, runtime, wsOpts, options.onCodexWsQuota, options.beforeDispatch); + return codexWsUpstreamFetch(input, init, httpFetch, runtime, options.onCodexWsQuota, options.beforeDispatch); } return httpFetch(input, init); }; @@ -188,7 +170,7 @@ export async function fetchWithHeaderTimeout( timeoutMs: number, preferIdentityEncoding = false, executor: typeof globalThis.fetch = globalThis.fetch, - _manualRedirect = true, + manualRedirect = false, ): Promise { const pacing = executor as ProviderFetch; await pacing.waitForPacing?.(abortSignal); @@ -204,20 +186,16 @@ export async function fetchWithHeaderTimeout( headers.set("accept-encoding", "identity"); } try { - const response = await fetchExecutor(url, { + return await fetchExecutor(url, { ...init, headers, - // Upstream URLs are configuration, not navigation. Refuse every redirect - // so POST bodies and provider headers are never replayed to another hop. - redirect: "manual", + // Credential-bearing sends opt into manual redirects so a 3xx is relayed + // as a Response instead of being followed into a rejection that is + // indistinguishable from a pre-connection failure (#914). + ...(manualRedirect ? { redirect: "manual" as const } : {}), signal: AbortSignal.any([abortSignal, timeout.signal]), timeout: 0, }); - if (response.status >= 300 && response.status < 400) { - try { await response.body?.cancel(); } catch { /* ignore cancellation failures */ } - throw new UpstreamRedirectError(response.status); - } - return response; } finally { clearTimeout(timer); } diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index a441ed967b..87b3767d2b 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -4,8 +4,8 @@ // a measurably faster queue than the plain SSE POST path. Measured 2026-08-12 // KST (same account, same payload, strictly sequential): gpt-5.6-luna TTFT p50 // ~1.0s over WS vs ~3.9s over SSE. Codex CLI itself defaults to the WS -// transport; opencodex keeps HTTP/SSE as its reliable default and allows -// operators to opt into WS when the lower latency is worth the risk. +// transport; opencodex previously always POSTed SSE, which is where its extra +// 2-3s of TTFT came from. // // The wrapper only swaps the transport. It dials wss:// with the same headers, // sends the JSON body as a single `response.create` frame, and re-encodes the @@ -13,16 +13,18 @@ // (passthrough relay, adapter parsers, usage sniffing) is unchanged. import { compareBunVersions } from "../../lib/bun-stream-caps"; +import { resolveProxyRoute } from "../../lib/proxy-env"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request"; import { codexWsExchange } from "./codex-ws-exchange"; import { CodexWsSession } from "./codex-ws-session"; import { codexWsPool, codexWsReuseIdentity } from "./codex-ws-pool"; -import { CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit } from "./codex-ws-wire"; +import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire"; export { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit, isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire"; export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0"; + /** * Dial URL for a request URL. The canonical ChatGPT backend keeps its constant; * an operator-opted OpenAI-compatible upstream swaps https for wss on the same @@ -31,9 +33,9 @@ export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0"; * a provider WS handshake would otherwise send credentials and request data * without transport encryption. */ -export function wsUpstreamUrlFor(httpUrl: string): string { +function wsUpstreamUrlFor(httpUrl: string): string { if (httpUrl === CODEX_RESPONSES_HTTP_URL) return CODEX_RESPONSES_WS_URL; - return httpUrl.replace(/^http(s?):/, "ws\$1:"); + return httpUrl.replace(/^http(s?):/, "ws$1:"); } /** @@ -42,14 +44,15 @@ export function wsUpstreamUrlFor(httpUrl: string): string { * and every downstream consumer (adapter parsers, usage sniffing, SSE relay) * assumes that wire. Other paths (chat completions, images, search) stay HTTP. */ -export function isResponsesWebsocketEligibleUrl(url: string): boolean { +function isResponsesWebsocketEligibleUrl(url: string): boolean { let parsed: URL; try { parsed = new URL(url); } catch { return false; } - return parsed.protocol === "https:" && parsed.pathname.endsWith("/responses"); + return parsed.protocol === "https:" + && parsed.pathname.endsWith("/responses"); } export type BunRuntimeIdentity = { version: string; @@ -58,29 +61,6 @@ export type BunRuntimeIdentity = { export type BunRuntimeGateInput = string | BunRuntimeIdentity; -export interface CodexWsUpstreamOptions { - wsUpstream?: boolean; - maxWsFrameBytes?: number; - upstreamWebsocket?: boolean; -} - -export function isCodexWsUpstreamDisabled(options?: CodexWsUpstreamOptions): boolean { - if (options?.wsUpstream !== undefined) return options.wsUpstream !== true; - const env = process.env.OCX_CODEX_WS_UPSTREAM; - return env !== "true" && env !== "1"; -} - -export function resolveCodexWsMaxFrameBytes(options?: CodexWsUpstreamOptions): number { - if (typeof options?.maxWsFrameBytes === "number" && Number.isFinite(options.maxWsFrameBytes) && options.maxWsFrameBytes > 0) { - return Math.min(options.maxWsFrameBytes, CODEX_WS_CREATE_FRAME_LIMIT_BYTES); - } - const envVal = process.env.OCX_CODEX_WS_MAX_FRAME_BYTES; - if (envVal) { - const parsed = Number.parseInt(envVal, 10); - if (Number.isFinite(parsed) && parsed > 0) return Math.min(parsed, CODEX_WS_CREATE_FRAME_LIMIT_BYTES); - } - return CODEX_WS_CREATE_FRAME_LIMIT_BYTES; -} export function currentBunRuntimeIdentity(): BunRuntimeIdentity { return { version: Bun.version, @@ -121,26 +101,11 @@ export function shouldUseCodexWsUpstream( url: string, init?: RequestInit, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), - options?: CodexWsUpstreamOptions | boolean, + upstreamWebsocketConfigured = false, ): boolean { - // Union: accept boolean legacy (vendor upstreamWebsocketConfigured) and object (fork CodexWsUpstreamOptions). - let opts: CodexWsUpstreamOptions | undefined; - let upstreamWebsocketConfigured = false; - if (typeof options === "boolean") { - upstreamWebsocketConfigured = options; - } else { - opts = options; - upstreamWebsocketConfigured = opts?.upstreamWebsocket === true; - } if (!bunSupportsBoundedCodexWsRelay(runtime)) return false; - if (url === CODEX_RESPONSES_HTTP_URL) { - // A custom-upstream opt-in never enables the canonical ChatGPT fast lane. - if (typeof options !== "boolean" && isCodexWsUpstreamDisabled(opts)) return false; - } else if (upstreamWebsocketConfigured) { - if (!isResponsesWebsocketEligibleUrl(url)) return false; - } else { - return false; - } + if (url !== CODEX_RESPONSES_HTTP_URL && !upstreamWebsocketConfigured) return false; + if (upstreamWebsocketConfigured && !isResponsesWebsocketEligibleUrl(url)) return false; if ((init?.method ?? "GET").toUpperCase() !== "POST") return false; const body = init?.body; if (typeof body !== "string") return false; @@ -162,18 +127,15 @@ export function codexWsUpstreamFetch( init: RequestInit, sseFallback: typeof globalThis.fetch, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), - options?: CodexWsUpstreamOptions | boolean, onQuota?: CodexWsQuotaObserver, beforeDispatch?: (headers: Headers) => void, ): Promise { - const opts = typeof options === "boolean" ? undefined : options; - const customUpstream = options === true || opts?.upstreamWebsocket === true; - if ((!customUpstream && isCodexWsUpstreamDisabled(opts)) || !bunSupportsBoundedCodexWsRelay(runtime)) { - return sseFallback(url, prepareCodexHttpInit(url, init)); - } const prepared = prepareCodexWsRequest(url, init); if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init)); init = prepared.httpInit; + if (!bunSupportsBoundedCodexWsRelay(runtime)) { + return sseFallback(url, init); + } const signal = init.signal ?? undefined; if (signal?.aborted) { return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); @@ -185,11 +147,14 @@ export function codexWsUpstreamFetch( // streaming Response, so the oversized close can only be surfaced as a stream // error — and a resend at that point could double-generate. Measuring the // frame we are about to send keeps the whole failure mode unreachable. - const maxFrameBytes = resolveCodexWsMaxFrameBytes(opts); - if (codexWsCreateFrameExceedsLimit(frameText, maxFrameBytes)) { + if (codexWsCreateFrameExceedsLimit(frameText)) { return sseFallback(url, init); } + const wsUrl = wsUpstreamUrlFor(url); + const proxyRoute = resolveProxyRoute(new URL(wsUrl)); + if (proxyRoute.kind === "fallback") return sseFallback(url, init); + const proxy = proxyRoute.kind === "proxy" ? proxyRoute.proxy : undefined; // A genuine caller `originator` is already in these headers via the forward // set. Never fabricate one here: pool/forward traffic must not impersonate // Codex CLI, per the metadata-integrity contract. (The backend's fast lane @@ -204,9 +169,9 @@ export function codexWsUpstreamFetch( } let session: CodexWsSession; try { - const identity = codexWsReuseIdentity(url, headers, frameText); - session = (identity ? codexWsPool.acquire(identity, wsUpstreamUrlFor(url), headers) : null) - ?? new CodexWsSession(wsUpstreamUrlFor(url), headers); + const identity = codexWsReuseIdentity(url, headers, frameText, proxy); + session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null) + ?? new CodexWsSession(wsUrl, headers, false, undefined, proxy); if (!session.busy && !session.reserve()) { session.dispose(); return sseFallback(url, init); diff --git a/src/server/sse-payload-rewrite.ts b/src/server/sse-payload-rewrite.ts index 3c6d825e6c..f9fb62065d 100644 --- a/src/server/sse-payload-rewrite.ts +++ b/src/server/sse-payload-rewrite.ts @@ -249,7 +249,9 @@ export function relaySseWithBlockRewrite( } catch (error) { releaseBuffer(); disposeRewrite(); - try { await reader.cancel(error); } catch { /* already closed */ } + // Cancelling one tee branch waits for its sibling. Surface the failure + // now so downstream can abort upstream and release the inspection branch. + void reader.cancel(error).catch(() => {}); controller.error(error); } }, diff --git a/src/service.ts b/src/service.ts index 723d8a68a7..8ade0601fe 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1924,7 +1924,7 @@ export function buildWindowsTaskXml( true false PT0S - 7 + 4 PT1M 3 @@ -2923,6 +2923,10 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise const identityUpgradeNeeded = registrationHealthy && preferredSid !== undefined && !windowsTaskHasSessionRecoveryTriggers(triggers, preferredSid); + // Omitted Priority also defaults to 7; background priority can starve health probes under CPU load. + const priorityUpgradeNeeded = registrationHealthy && taskXmlOptionalValueEquals( + taskXmlSection(taskXmlWithoutCommentsAndCdata(registeredXml), "Settings"), "Priority", "7", + ); const refreshableLegacy = windowsTaskRegistrationRefreshableLegacy( registeredXml, deps.schedulerWscript, @@ -2949,7 +2953,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise // Re-register only when the registered XML is actually stale, so the ordinary repair // stays free of `schtasks /create` and its UAC prompt. let startExpectedXml = registeredXml; - if (!registrationHealthy || identityUpgradeNeeded) { + if (!registrationHealthy || identityUpgradeNeeded || priorityUpgradeNeeded) { // The task was stopped above, so a failed replacement must not exit here: `/create /f` // can be rejected, elevation can be cancelled, and staging or verification can fail. // Any of those would leave a previously runnable proxy stopped and the user worse off diff --git a/src/storage/cleanup.ts b/src/storage/cleanup.ts index cdfd103ff6..656541aa5b 100644 --- a/src/storage/cleanup.ts +++ b/src/storage/cleanup.ts @@ -30,11 +30,11 @@ import { writeSync, chmodSync, } from "node:fs"; -import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; import { resolveCodexHomeDir } from "../codex/home"; import { readThreadFieldsFromRollout } from "../codex/history-provider"; -import { renameAtomicFile } from "../config"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; export const ARCHIVED_SESSIONS_DIR = "archived_sessions"; export const TRASH_DIR = ".trash"; @@ -115,9 +115,35 @@ function chmodPrivatePath(path: string, mode: number): void { try { chmodSync(path, mode); } catch { /* best-effort (e.g. Windows ACLs) */ } } -function writePrivateFile(path: string, content: string): void { - writeFileSync(path, content, "utf8"); - chmodPrivatePath(path, 0o600); +/** Publish complete stage metadata without truncating the last recovery record. */ +function writePrivateFile( + path: string, + content: string, + beforeRename?: (temporaryPath: string, targetPath: string) => void, +): void { + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + let descriptor: number | undefined; + let created = false; + try { + descriptor = openSync(temporaryPath, "wx", 0o600); + created = true; + writeFileSync(descriptor, content, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + chmodPrivatePath(temporaryPath, 0o600); + beforeRename?.(temporaryPath, path); + renameAtomicFile(temporaryPath, path, undefined, "storage-cleanup"); + chmodPrivatePath(path, 0o600); + fsyncDirectoryBestEffort(dirname(path)); + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* preserve publication failure */ } + } + if (created) { + try { unlinkSync(temporaryPath); } catch { /* renamed or cleanup unavailable */ } + } + } } function chunkIds(ids: string[], chunkSize: number): string[][] { @@ -812,7 +838,6 @@ interface ReconcileTestHooks { const SATELLITE_BACKUP_FILE = "satellite-backup.json"; /** Marks an incomplete restore so retries can accept dest files and resume metadata. */ const RESTORE_PENDING_FILE = "restore-pending.json"; -let _satelliteBackupSeq = 0; type StagedFile = { from: string; to: string; relPath: string }; @@ -1070,34 +1095,11 @@ function writeSatelliteBackup( if (options?.failWrite) throw new Error("test_fail_satellite_backup_write"); const dest = join(stageDir, SATELLITE_BACKUP_FILE); const replacing = existsSync(dest); - const tmp = join(stageDir, `${SATELLITE_BACKUP_FILE}.${process.pid}.${++_satelliteBackupSeq}.tmp`); - const payload = Buffer.from(JSON.stringify(backup), "utf8"); - const fd = openSync(tmp, "w", 0o600); - try { - let offset = 0; - while (offset < payload.length) { - offset += writeSync(fd, payload, offset, payload.length - offset, null); + writePrivateFile(dest, JSON.stringify(backup), () => { + if (options?.failReplaceBeforeRename && replacing) { + throw new Error("test_fail_satellite_backup_replace"); } - fsyncSync(fd); - } catch (error) { - try { closeSync(fd); } catch { /* */ } - try { unlinkSync(tmp); } catch { /* */ } - throw error; - } - closeSync(fd); - chmodPrivatePath(tmp, 0o600); - if (options?.failReplaceBeforeRename && replacing) { - try { unlinkSync(tmp); } catch { /* */ } - throw new Error("test_fail_satellite_backup_replace"); - } - try { - renameAtomicFile(tmp, dest, undefined, "storage-cleanup"); - } catch (error) { - try { unlinkSync(tmp); } catch { /* */ } - throw error; - } - chmodPrivatePath(dest, 0o600); - fsyncDirectoryBestEffort(stageDir); + }); } function clearSatelliteBackup(stageDir: string): void { @@ -1734,6 +1736,12 @@ export interface ExecuteCleanupOptions { /** Test-only failure injection for atomicity regressions. */ _test?: { failManifestWrite?: boolean; + /** Observe the complete temp and prior destination before publication. Never serialized. */ + beforeManifestReplace?: ( + temporaryPath: string, + targetPath: string, + phase: "staging" | "pre-commit" | "purge-incomplete", + ) => void; failPurgeBasenames?: string[]; failRollbackBasenames?: string[]; blockStageDestBasenames?: string[]; @@ -1752,14 +1760,14 @@ export interface ExecuteCleanupOptions { /** Serializable cleanup test hooks allowed on the management API wire. */ export type CleanupWireTestHooks = Omit< NonNullable, - "afterSatelliteMutations" | "beforeReconcileLock" + "afterSatelliteMutations" | "beforeReconcileLock" | "beforeManifestReplace" >; function isStringArray(v: unknown): v is string[] { return Array.isArray(v) && v.every(e => typeof e === "string"); } -/** Pick only allowlisted serializable hooks; drops function hooks (afterSatelliteMutations, beforeReconcileLock) and unknown keys. */ +/** Pick only allowlisted serializable hooks; drops all function hooks and unknown keys. */ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | undefined { if (!raw || typeof raw !== "object") return undefined; const o = raw as Record; @@ -1911,6 +1919,9 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR entries: manifestEntries, ...extra, }, null, 2), + (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.( + temporaryPath, targetPath, extra.staging ? "staging" : "pre-commit", + ), ); }; @@ -1999,6 +2010,9 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR })) .filter(entry => entry.physicalRelPaths.length > 0), }, null, 2), + (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.( + temporaryPath, targetPath, "purge-incomplete", + ), ); } catch { /* best-effort: the pre-commit manifest is still on disk */ } return { diff --git a/src/types/config.ts b/src/types/config.ts index 3a6c863c46..e53f6b808c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,8 @@ import type { CodexAccount } from "./accounts"; * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. */ export interface OcxClaudeCodeConfig { + /** Opt-in translated Messages admission; unset keeps legacy behavior. Native passthrough is exempt. */ + compatibility?: "shadow" | "enforce"; /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ enabled?: boolean; /** @@ -140,8 +142,6 @@ export interface OcxClaudeCodeConfig { * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer. */ desktopNativeModels?: boolean; - /** Claude ingress compatibility gate. Defaults to enforce. */ - compatibility?: "shadow" | "enforce"; } export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; @@ -440,6 +440,13 @@ export interface OcxConfig { * one key at a time rather than widening a shared union. */ clientIntegrations?: OcxClientIntegrationsConfig; + /** Aside account-backed profile synchronization; individual overrides survive bulk refresh. */ + asideProfileSync?: { + allProfiles?: boolean; + profiles?: Record; + /** Stable provenance for the one legacy root ownership record, or no root owner. */ + legacyProfileId?: number | null; + }; /** * Up to 5 Codex-facing catalog ids to feature first. Values may be bare catalog ids, * exact account-qualified "/" ids, or routed @@ -458,19 +465,19 @@ export interface OcxConfig { /** One-time featured-roster upgrade marker; later user ordering is preserved. */ subagentModelsVersion?: number; /** - * Optional full picker ordering for the Codex model catalog, independent of the - * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of - * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort - * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog - * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and - * account-qualified native rows are not reordered (order native rows via `subagentModels`). - * Listed routed rows appear in array order; rows not listed keep their normal display order. - * `subagentModels`-featured rows keep their top position. When unset or empty, catalog - * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent - * candidate set is derived from each row's natural priority and is provably unaffected, even - * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster). + * Display-only order for the Codex picker, independent of subagentModels. + * Routed-only lists order non-featured routed rows; featured and native rows keep + * their normal positions. Including a bare native id opts into ordering the complete + * picker: listed ids appear first in array order, followed by unlisted rows in their + * natural priority order. Exact catalog ids take precedence over equivalent raw/encoded + * routed ids; empty entries are ignored. The separate natural priority used by + * OpenCodex guidance is preserved. Native Codex's advertised five follow display + * priority and may change; exact-name override eligibility is not restricted by that list. + * Unset or empty leaves catalog priorities unchanged. */ modelPickerOrder?: string[]; + /** Saved preset provenance; snapshots are not recomputed during catalog discovery. */ + modelPickerOrderMode?: "alphabetical" | "provider" | "most-used"; /** * Priority-ordered fallback models for spawned sub-agents. When the requested * model is quota-exhausted or recently failed, opencodex rewrites the child @@ -650,8 +657,10 @@ export interface OcxConfig { * so absence is the only default state this feature has. */ quotaResetNotify?: OcxQuotaResetNotifyConfig; - /** Provider-level Codex-visible context caps. Values only lower known model context windows. */ + /** Active provider context limits; native long windows remain within their supported ceilings. */ providerContextCaps?: Record; + /** Last selected provider caps; retained while a cap is switched off. Not an active limit. */ + providerContextCapValues?: Record; /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */ contextCapValue?: number; /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ @@ -856,10 +865,10 @@ export interface OcxConfig { * provider has 2 or more eligible stored accounts, the same consent rule an `apiKeyPool` of * two keys already applies, and a single account remains a strict no-op. * - * When `enabled` is absent, two or more eligible accounts enable reactive 429 rotation by - * default. An explicit `false` refuses both cross-account replay and the PRE-DISPATCH account - * preference. `providers..oauthAccountFailover` overrides this per provider in either - * direction. + * Proactive avoidance of an exhausted selected account requires `enabled: true`. + * A healthy selected account retains priority; an unknown quota is not exhaustion. + * `providers..oauthAccountFailover` overrides this per provider in either direction. + * Reactive 429 rotation remains presence-driven even when proactive routing is disabled. */ oauthAccountFailover?: { enabled?: boolean; diff --git a/src/types/provider.ts b/src/types/provider.ts index b0b55dce11..1bed3576c5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -9,6 +9,12 @@ import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } export type RefreshPolicy = "proactive" | "lazy-only" | "disabled"; export type ProviderTlsProfile = "antigravity-browser"; +/** Request-owned identity of the configured key, before env/keychain resolution. */ +export interface ProviderApiKeySelection { + entryId?: string; + reference?: string; + revision?: string; +} export interface OpenRouterProviderRouting { /** OpenRouter provider slugs to try first, in priority order. */ @@ -360,6 +366,10 @@ export interface OcxProviderConfig { * `apiKey` seeds a one-entry pool on first management touch. */ apiKeyPool?: Array<{ id: string; key: string; label?: string; addedAt?: number }>; + /** Changes on manual selection (including re-selection) and committed automatic allocation. */ + apiKeySelectionRevision?: string; + /** Runtime only. Never expose in management responses or persist a routed provider. */ + _apiKeyAttempt?: ProviderApiKeySelection; defaultModel?: string; models?: string[]; /** @@ -470,10 +480,11 @@ export interface OcxProviderConfig { /** * Per-provider override for generic OAuth account selection and recovery (#2568, #695). * - * When absent, two or more eligible accounts enable reactive 429 rotation by default. An - * explicit `false` refuses both that replay and the pre-dispatch preference that steers a - * healthy request toward the account with more known headroom. This narrower setting beats - * the global `oauthAccountFailover` in either direction. + * Reactive 429 rotation is presence-driven and cannot be refused here — 2+ logged-in accounts + * activate it, and a 429 with an idle second account is a defect rather than a preference. + * Proactive exhaustion avoidance requires explicit `true`; a healthy selected account + * retains priority. This overrides global `oauthAccountFailover` in either direction. + * Reactive 429 rotation remains available even when proactive routing is disabled. */ oauthAccountFailover?: { enabled?: boolean; diff --git a/src/types/request.ts b/src/types/request.ts index adf1071ff2..d434da3e73 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -74,7 +74,7 @@ export interface OcxParsedRequest { _cursorConversationId?: string; /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ _clientThreadId?: string; - /** True when promptCacheKey is a shared cache cohort rather than a conversation identity. */ + /** True when promptCacheKey identifies a shared cache cohort rather than one conversation. */ _promptCacheKeyIsSharedCohort?: boolean; /** Provider-private Command Code session affinity id, stable across in-process request mutation. */ _commandCodeSessionId?: string; diff --git a/src/usage/log.ts b/src/usage/log.ts index 0c949158dd..6868a66490 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -11,6 +11,24 @@ import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routi import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; import type { AgentKind } from "../server/effort-policy"; import type { TurnProgressTelemetry } from "../types/progress"; +import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; + +export interface PersistedClaudeCompatibilityLog { + decision: "shadow"; + featureCodes: ClaudeFeatureCode[]; + reason?: string; +} + +/** Disk and in-memory callers share a closed-code projection; free-form reasons are discarded. */ +export function normalizeClaudeCompatibilityUsageLog(value: unknown): PersistedClaudeCompatibilityLog | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const row = value as Record; + if (row.decision !== "shadow") return undefined; + const featureCodes = normalizeClaudeFeatureCodes(row.featureCodes); + const reason = claudeCompatibilityReason(featureCodes, true); + if (!reason) return undefined; + return { decision: "shadow", featureCodes, reason }; +} export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; export type UsageAccountLogLabel = "main" | `p${string}` | `o${string}`; @@ -57,9 +75,14 @@ export type AttemptRecoveryKind = | "cursor-overflow-remint" | "cursor-invalid-argument"; +/** Request-time upstream credential class, never a credential or account identifier. */ +export type UsageCredentialSource = "grok-oauth" | "xai-api-key"; + export interface PersistedUsageAttempt { ordinal: number; provider: string; + /** Absent on historic attempts and routes whose subscription attribution is unknown. */ + credentialSource?: UsageCredentialSource; model: string; adapter: string; status: number; @@ -165,6 +188,8 @@ export interface PersistedUsageEntry { * contains prompts, credentials, or hidden reasoning. */ routeDecision?: RouteDecisionTraceV1; + /** Closed Claude protocol codes only; absent on older rows. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } const KNOWN_USAGE_SURFACES = new Set>([ @@ -442,6 +467,10 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { return { ordinal: attempt.ordinal as number, provider: attempt.provider, + ...(attempt.provider === "xai" + && (attempt.credentialSource === "grok-oauth" || attempt.credentialSource === "xai-api-key") + ? { credentialSource: attempt.credentialSource } + : {}), model: attempt.model, adapter: attempt.adapter, status: attempt.status, @@ -581,6 +610,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const callerServiceTier = sanitizeLogMetadataString(entry.callerServiceTier); const responseServiceTier = sanitizeLogMetadataString(entry.responseServiceTier); const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; @@ -662,6 +692,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 8b3eb6362c..3a2c5e99b4 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -3,7 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx import { namespacedToolName, toolChoiceToolPredicate } from "../types"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; -import { bridgeToResponsesSSE, diagnoseAdapterEvent, type BridgeDiagnosticContext } from "../bridge"; +import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor"; @@ -13,7 +13,7 @@ import type { WebSearchBackendId } from "./index"; import { clearableDeadline } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import { fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; import { rateLimitRetryDelayMs } from "../providers/key-failover"; import { isTranslatorBudgetExceededError, @@ -23,7 +23,6 @@ import { import { formatWebSearchResults } from "./format-result"; import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "./progress-stream"; import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; -import { OcxRequestValidationError } from "../lib/errors"; const SSE_HEADERS = { "Content-Type": "text/event-stream", @@ -231,14 +230,8 @@ function forcedAnswerNudge(): OcxMessage { }; } -function jsonError(status: number, message: string, errorType = "upstream_error", code: string | null = null): Response { - return new Response(JSON.stringify({ - error: { - message, - type: errorType, - code, - }, - }), { +function jsonError(status: number, message: string): Response { + return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { status, headers: { "Content-Type": "application/json" }, }); @@ -247,12 +240,7 @@ function jsonError(status: number, message: string, errorType = "upstream_error" /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a * non-200 jsonError; later (already-streaming) iterations surface it as an in-stream error event. */ class LoopError extends Error { - constructor( - readonly status: number, - message: string, - readonly errorType?: string, - readonly code?: string, - ) { + constructor(readonly status: number, message: string) { super(message); this.name = "LoopError"; } @@ -313,8 +301,8 @@ export interface WebSearchLoopDeps { onUsage?: (usage: OcxUsage | undefined) => void; /** Observe the exact adapter request selected for each routed-model iteration. */ onRequestBuilt?: (request: AdapterRequest) => void; - /** Validate the final adapter before every cached replay or request build. */ - validateAdapter?: (parsed: OcxParsedRequest, adapter: ProviderAdapter) => void; + /** Request-scoped executor retains the core's selection binding across loop retries. */ + fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest) => typeof globalThis.fetch; /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */ onAttemptSend?: (recovery?: AttemptRecoveryKind) => void; /** @@ -327,10 +315,6 @@ export interface WebSearchLoopDeps { retryOn429Policy?: Required | null; /** Called only when the final bridged Responses stream reaches completed or incomplete. */ onCompletedResponse?: (response: Record) => void; - /** OAuth account identity forwarded to AdapterFetchContext for provider-local cooldown bookkeeping. */ - accountId?: string; - /** Internal, opt-in structural stream diagnostics shared with the final bridge. */ - diagnostic?: BridgeDiagnosticContext; } /** @@ -440,16 +424,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise => { - deps.validateAdapter?.(iterParsed, requestAdapter); let request: AdapterRequest; if (cachedRequest !== undefined && cachedAdapter === requestAdapter) { request = cachedRequest; } else { request = await requestAdapter.buildRequest(iterParsed, { + ...deps.incomingMeta, headers: selectedForwardHeaders, abortSignal: headerDeadline.signal, translatorBudget, - providerFetch: routedProviderFetch, }); try { deps.onRequestBuilt?.(request); @@ -459,6 +442,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { activeAccountId, accounts[] }` (legacy single-credential values normalize on load; a one-time `auth.json.pre-multiauth` backup guards downgrades). ChatGPT scratch OAuth stays separate from the Codex account store. For multi-slot providers, credentials without `accountId`/email replace the active slot on a normal login; an explicit add-account login preserves the prior slot and appends a distinct one. Single-slot providers such as ChatGPT remain replacement-only. | | `~/.opencodex/codex-accounts.json` | opencodex | Hardened main-plus-added credential store used by `openai` in Pool mode. | | `~/.opencodex/catalog-backup.json` | opencodex | One-time pristine Codex catalog backup for restore; per-catalog copies are hashed variants (see [`03_catalog-and-subagents.md`](03_catalog-and-subagents.md)). | diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 26f501419a..7fb1c00997 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -15,7 +15,7 @@ | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | -| `src/router.ts` | Provider/model selection before adapter dispatch. | +| `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. | diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index bb8ec5630f..9478343d19 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -20,6 +20,24 @@ $CODEX_HOME/.opencodex-native-main-profiles/ Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on the resolved `CODEX_HOME`. +The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate +from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and +`ocx-state` respectively, retaining a read-only root. The image creates owner-only +writable homes for `bun`; existing volume ownership and permissions are not repaired. +The catalog resolver is unchanged; a writable empty home is not a materialized catalog. + +[Decision Log] +- 목적과 의도: Make the container's catalog location persistent and writable without changing native home semantics. +- 기존 구현 및 제약 조건: Compose persisted only the OCX home, leaving Codex state on a read-only root; both products use incompatible auth.json formats. +- 검토한 주요 대안: Merge the homes, nest Codex under an existing volume with a new startup initializer, or persist the existing separate Codex home. +- 선택한 방식: Add a separate codex-state volume and create both owner-only directories in the image. +- 다른 대안 대신 이 방식을 선택한 이유: It preserves existing paths, avoids credential-file collisions, and works when an older ocx-state volume hides the image's seeded directory tree. +- 장점, 단점 및 영향: Two volumes must be backed up, but no automatic credential migration or runtime resolver change is needed. Catalog import/materialization remains an explicit prerequisite. + +`docker compose down` retains both volumes. `docker compose down --volumes` deletes +both `ocx-state` and `codex-state`, including their credentials and catalog/state; +treat it as destructive, not as an upgrade or restart command. + Service install-state ownership uses this same resolver. In WSL, an unset `CODEX_HOME` may resolve to the single discoverable Windows Desktop home; recording Linux `~/.codex` instead would make a later repair or uninstall look foreign even though the service and runtime were started from the @@ -127,6 +145,12 @@ Worker cannot restore unrelated API keys or provider settings from a snapshot re If that metadata write is unavailable after cleanup has already completed, the job retains the cleanup outcome and exposes a bounded persistence error instead of relabeling the run as a Worker failure. +Cleanup manifests and satellite backups share the stage-local atomic publisher: an exclusive +private temporary file is fully written and file-synced before the existing Windows-tolerant +rename replaces the destination. Handled publication failures retain the previous record; +directory syncing remains best-effort. This does not make a partial permanent purge reversible: +restore still fails closed when a recorded logical entry has no surviving file. + Windows secret-file hardening resolves the effective token SID through an absolute, trusted PowerShell path before granting the owner and removing inherited broad ACL entries. The normal path obtains System32 from `GetSystemDirectoryW`. Windows ARM64 Bun builds that cannot execute @@ -249,6 +273,17 @@ to snapshot persistence instead of relying on the progress argument alone. ### OpenCodex home and live process state +`initializePersistedConfigIfMissing` in `src/config.ts` is the create-only path consumed by +`src/cli/init.ts`. It rechecks absence under the existing config-mutation lock and publishes through +`src/config/initialize.ts`: a private descriptor is hardened before secret bytes are written, then +linked without replacing an occupied destination. Existing invalid or unsafe entries are preserved. +The initializer never truncates a staged inode or rolls back by unlinking the destination; cleanup +only removes its own temporary name. Unsupported/denied links and incomplete cleanup fail explicitly, +and publication followed by a later failure can leave a complete config or private residue. Ordinary +`saveConfig` replacement behavior remains unchanged. This protects init-time config bytes, not a +foreign winner's ownership under future uninstall; the existing ownership manifest and global CLI +shim preflight keep their separate contracts. + `src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes the config directory and `config.json` path and retains the existing cache rule: a relative home is resolved once for each distinct raw environment value, so a later working-directory change cannot @@ -260,7 +295,7 @@ identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` rem owner-only state and is validated before a record is returned. `src/config.ts` re-exports the same symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. -Both config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared +Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared process-wide temp sequence, symlink target resolution, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. A caller must not replace it with a local temp-and-rename shortcut. diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index c2617778cd..00b692b823 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -37,6 +37,24 @@ custom catalog remains the native metadata/template authority even when a bundle warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. +Custom Astra and Daybreak rows acquire native reasoning capability only through the existing +canonical `openai` forward destination and explicit capability-source predicate. The shared +custom-row producer bounds their merged effort lists against pinned per-model Codex metadata, +preserves an explicit empty list without a default, and recovers an incompatible nonempty list +to the native default singleton. A default must belong to the projected list. Other custom rows +keep their declaration precedence; a GPT model name, display alias, or arbitrary gateway is not +native provenance. Stored configuration and native capability maps are unchanged. + +The observed-state merge tracks the current invocation's freshly generated custom row objects +after detaching its inputs. Those rows already own their complete reasoning projection, so the +merge does not append `max` again. This also keeps a generic none-only custom row none-only; +ordinary retained provider rows still receive the existing mock-tier policy. A persisted custom +marker alone never grants this exemption. Both gather entry points, retained sync, management +convergence and direct Codex model discovery use the same producer. The legacy runtime effort +union clamp remains separate; it is not a per-model or per-client-version grammar oracle. +Existing thread settings and the reported Desktop 0.153.4 gateway rejection require separate +runtime evidence. Codex's native `ultra` mode is preserved and is not a literal API wire promise. + When account selectors are enabled, the sync path may also observe exact, visible, API-supported OpenAI-family ids from Codex's user-owned catalog/cache. Only rows with native catalog provenance are trusted; unknown ids are carried through startup cache invalidation as hidden observations and @@ -66,6 +84,26 @@ deleting, or editing a provider's shape clears that per-provider cache; a disabl deliberately does not, because a disabled provider is already excluded from the catalog gather instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. +For `liveModels: false`, a static provider publishes the ordered union of `models` and +`retainModels`. When `models` is absent or empty, its configured `defaultModel` seeds that +union before retained ids; a nonempty explicit list does not import a different default. +Without any default or configured/retained ids, the static result stays empty. The existing +forward-auth native path remains separate. Static gathering does not refresh OAuth or call +the provider's model endpoint, and normal selection and visibility filters still apply. + +The provider workspace uses the existing `/api/models` projection for displayed rows, +model identity and inventory counts. Counts cover distinct non-disabled selectors within +each provider, before search or the render cap; they are not selected-model or live-discovery +counts. The full available list and discovery provenance remain separate inputs. + +Deleting a custom definition uses its stable record id and does not also hide the underlying +model. Native or discovered metadata can therefore reappear without changing the inventory +count. Hide uses the represented row's native/routed identity and changes visibility only. +The Models page can restore existing hidden rows; adding a definition does not implicitly +clear a previous hide or provider allowlist. Actions wait for current row and custom-ownership +observations, and mutations reconcile those observations instead of retaining browser-only +removal markers. These presentation operations do not grant routing or account entitlement. + ### Windows request-path catalog-state discovery [Decision Log] @@ -243,11 +281,23 @@ advertises) but `expose_spawn_agent_model_overrides` on V2 (default `true`; when is omitted *and* the `model`/`reasoning_effort` schema fields are removed). And V2's `hide_spawn_agent_metadata` defaults true, which removes `service_tier`. -`modelPickerOrder` (#1649) deliberately does **not** feed this window: it rewrites only the -Codex-visible `priority` while `SPAWN_PRIORITY_FIELD` preserves the natural priority the roster -sorts by, so a display reorder can never change candidate membership. That divergence from -upstream's own ordering is the feature's purpose, not a defect — -`tests/codex-integration/codex-catalog-model-picker-order.test.ts` pins it. +`modelPickerOrder` (#1649) separates **OpenCodex guidance** from native advertisement. +`SPAWN_PRIORITY_FIELD` preserves the natural priority used by `effectiveSubagentRoster`, so +OpenCodex's preferred/guidance candidate calculation stays independent of display order. +Native Codex ignores that private field: its advertised five on V1 and exposed V2 follow the +native `priority` and may change when the picker is reordered. Exact-name override lookup is +not restricted to those five advertised rows. V1 receives no OpenCodex preferred-roster +injection; V2 can additionally receive natural-priority guidance when its catalog state permits. +The helper tests pin guidance behavior, not native tool-description equivalence. + +A nonblank bare id in `modelPickerOrder` opts into complete-picker display ordering. Exact +ids take precedence over raw/encoded equivalents; routed-only and empty lists keep the legacy +ordering behavior. This does not change the separate `opencodex_spawn_priority` contract. +Retained rows recompute their natural ranks from the current featured roster and account-selector +stride before display order is applied, so a discovery outage cannot preserve an obsolete +featured or picker rank. Canonical `opencode-go` rows retain their configured reasoning ladder +both when generated and when merged from retained catalog state; synthetic max/ultra choices +are not added to that provider's declared ladder. Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent_v2_and_history_perf/013_five_cap_v1_vs_v2.md`. @@ -434,3 +484,18 @@ behaviors. prunes them without provider discovery, and catalog failure falls back to unmarked definitions so startup remains available. A later dashboard save or `ocx claude` launch restores missing context markers after a transient failure. + + +### Saved picker presets + +The Models page saves routed snapshots in `modelPickerOrder` and records their origin in +`modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`). Mode is UI provenance, not a +catalog sorting policy: catalog writers consume the saved array. Routed-only featured/native +bands and complete-picker natural-rank preservation remain as described above. Public +`buildCatalogEntries` accepts the order as its final argument and applies the complete-order +pass after building. On-disk convergence retains its existing post-merge final pass. + +Claude ModelInfo ordering receives optional `{ modelPickerOrder, featured }` after `fastRows`. +It orders routed output groups after alias deduplication, preserving the collision winner and +base/1M/Fast siblings. Native groups and explicit Desktop profile ownership are unchanged. +Native Codex advertisements still follow display priority; private guidance ranks do not freeze them. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 34603a49ad..8867450d64 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -402,6 +402,25 @@ Native passthrough SSE has TWO shapes, selected per request in inspection side-effect set (shared `createSseInspector` factory in `relay.ts`) including the #44 late-terminal semantics. +Both client readers also retain a bounded, redacted message from a bare upstream +`error` event. If EOF arrives without a real Responses terminal, they synthesize +one `response.failed` with that message instead of replacing it with `adapter_eof`. +The delivering reader owns this evidence; an asynchronous tee inspection branch +cannot reliably supply it before EOF. Inspection independently applies the same +bare-error rule when EOF arrives, so account health records failure instead of +clearing avoidance as if the turn had succeeded. Existing real terminals and +caller cancellation retain precedence on both branches. Native recovery preflight +also preserves a rejected body reader and its bounded prefix for the normal +mid-stream failure path; it does not turn that rejection into a decrypt retry. + +Native Responses may rebuild once when encrypted function/custom-tool output or +agent-message content receives the exact known decrypt rejection before output +commits. Recovery replaces only encrypted parts with an omission marker, preserves +the raw request object used by continuation persistence guards, and uses the same +adapter and cancellation path. A missing Content-Type is allowed only under the +existing successful streaming condition. Default combo preflight classification +is unchanged; only the native recovery caller supplies the exact error predicate. + Both shapes carry the inbound caller-abort signal separately from the turn/shutdown controller. A caller-driven read rejection is 499/client_cancel without pool penalty; a genuine upstream reset remains synthetic 502. An already received terminal, including @@ -444,7 +463,7 @@ These are transport-fidelity guarantees, not a provider-billing guarantee. Eligible complete-input creates can retain a canonical upstream socket within one selected account, credential, thread and turn. Model/tier and immutable -handshake headers must also match. Turn-state and turn-metadata headers are +handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are projected into their same-name per-frame metadata slots; explicit body values win. The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and retires a socket after five minutes or 32 successful exchanges (after active work @@ -668,7 +687,11 @@ the upgrade with 426 so Codex falls back to HTTP cleanly. That setting controls the client-facing upgrade only. The transparent upstream ChatGPT WS optimization described above is selected independently and still -returns the same downstream SSE contract. +returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the +first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not +route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the +existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket +egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY. The endpoint handles `response.create`, ignores `response.processed`, supports warmup `generate: false`, and feeds the same request pipeline as HTTP/SSE. @@ -862,6 +885,26 @@ with seam heartbeats between bounded units. None of these clocks is a total gene ## Reasoning and tool-result compatibility +Kiro groups only consecutive original-message tool results whose raw call ID exactly matches +the originating call. Its wire-ID map retains the original ID privately so replacement or +truncation collisions cannot join unrelated results. Every non-tool message ends the group, +including a reasoning-only assistant omitted from the Kiro turns. Group finalization preserves +single-result normalization, ordered meaningful raw text and whitespace in multi-result output, +failure text, image order and sticky error status. Empty hints are applied once for an entirely +text-empty group, not once per chunk; local grouping state never enters the wire payload. + +`src/responses/task-input.ts` recognizes complete external Codex task-input envelopes +before translated Responses adapters: `function_call_output`, no `call_id` property, +nonblank `id`/`name`/`namespace`, and fully representable nonempty text/image output. +`parser.ts` emits a user turn, clears pending reasoning and includes that turn in the +existing continuation conversation-boundary calculation. The metadata is structural, +not authentication. Unknown/opaque/malformed parts reject the entire conversion; +ordinary missing/empty tool call ids retain the existing translated-route 400 guard. +Native passthrough and compaction retain raw-body handling. The leaf reuses the input +content converter after validation and imports no optional subsystem. +Stateful developer-guidance injection reuses that validator for its raw insertion +boundary, so parsed messages and stored raw history retain the same task/guidance order. + Native OpenAI passthrough sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. @@ -1167,6 +1210,35 @@ Grounded in the open-sourced official client (xai-org/grok-build); unit + eviden `fetchWithHeaderTimeout` takes an executor so provider fetch wrappers stay inside the timeout race. +The generated Grok client marker also enables a client-facing sparse-terminal repair for native +Responses streams. Grok Build renders text deltas immediately but derives its durable assistant +turn from `response.completed.response.output`; an OpenAI-compatible stream may instead place the +complete items in `response.output_item.done` and finish with an explicit empty output array. For +that marked client only, OpenCodex uses a terminal-only tracker: it retains bounded, contiguous, +unique and semantically valid raw completed items, then backfills a missing or empty terminal +snapshot. It never promotes locally synthesized or merely repaired items. Unmarked callers continue +to treat an explicit empty array as authoritative. Within this marked client-facing repair, +malformed, gapped, oversized, contradictory, failed, or incomplete streams stay fail-closed. + +[Decision Log] +- 목적과 의도: Prevent Grok Build from classifying a visibly streamed answer as empty and replaying + the same billable turn when the terminal snapshot is sparse. +- 기존 구현 및 제약 조건: OpenCodex already reconstructed missing terminal output for provider + opt-ins, but preserved explicit empty arrays; Grok Build discarded ordinary completed-item events + when constructing its final conversation response. +- 검토한 주요 대안: Change every caller's empty-array semantics; accept a turn merely because a + text delta was visible; reuse the provider's broader lifecycle synthesis; add a strict repair at + the generated Grok client boundary. +- 선택한 방식: Use the existing generated client marker to opt Grok into a terminal-only repair and + backfill only from unique, contiguous, bounded real done items whose raw semantics are valid. +- 다른 대안 대신 이 방식을 선택한 이유: A global rewrite would alter valid provider semantics, + while accepting deltas without durable items would leave persistence and continuation empty. The + marker is already the client-specific compatibility boundary; keeping the provider repair separate + also prevents synthesized or permissively normalized items from overriding an explicit empty terminal. +- 장점, 단점 및 영향: Grok receives one durable completed answer without a paid retry; ordinary + clients remain byte-semantics compatible. The proxy retains bounded item state for marked streams + and intentionally refuses ambiguous reconstruction. + ## Kiro client parallel-tool hint Kiro's wire remains serialized even when an OpenAI Responses client sends @@ -1265,6 +1337,46 @@ messages are redacted before either JSON or SSE reaches the client. The native p request-attempt logging, reset retry, same-key 429 replay, key rotation, usage extraction, and request-signal cancellation contracts as routed Responses transport. +## Chat streaming client with a JSON upstream result + +The translated inbound path in `src/server/chat-completions.ts` may receive a complete JSON +Responses result even when the Chat client requested SSE. Its synthetic stream reuses +`responsesJsonToChatCompletion` as the semantic authority: converted text, reasoning, available +refusal content, tool calls, finish reason, and usage must survive this final delivery conversion. +Tool calls gain their array-order stream `index`; the stream retains one assistant-role frame, +one terminal choice, and one `[DONE]`. Both native and translated JSON fallbacks share +`jsonCompletionSse`; its temporary frame strings and final body ownership are charged to the +existing translator budget. Known incomplete limits take precedence over tool finish reasons; +unmapped incomplete boundaries remain errors. The existing response-body lifecycle owns translation-budget +release on consumption or cancellation. Actual upstream SSE and native Chat bypass this fallback. + +[Decision Log] +- 목적과 의도: Keep tool execution and incomplete-response detection working when a streaming client receives a JSON upstream result. +- 기존 구현 및 제약 조건: The existing fallback copied only text and forced `stop`, despite the JSON converter already retaining tool calls, reasoning, and incomplete status. +- 검토한 주요 대안: Duplicate Responses parsing in the emitter; perform another inference request; preserve the already-converted Chat completion. +- 선택한 방식: Copy supported converted message fields into one delta, assign tool-call stream indexes, and retain the converted finish reason. +- 다른 대안 대신 이 방식을 선택한 이유: One conversion authority prevents the streaming fallback from drifting from non-streaming semantics without changing routing or retry behavior. +- 장점, 단점 및 영향: No additional upstream request or dependency; this remains buffered delivery, not token-by-token upstream streaming. Handler regressions cover tools, reasoning, length, ordinary and empty completions, and budget release. + +### Chat refusal projection + +`src/chat/outbound.ts` keeps Responses refusal parts separate from ordinary content. JSON output +and the stream collector expose nullable `message.refusal`; `jsonCompletionSse` preserves it as +`delta.refusal`, while the native SSE relay remains opaque. The translated live stream keys refusal +state by raw `output_index` / `content_index`, validates present item IDs as correlation constraints, +and emits buffered parts in that order only at a valid completed/incomplete terminal. Deltas append; +equal, empty, absent, and shorter-prefix snapshots preserve existing text; extending snapshots add +only new text. Non-string or contradictory snapshots fail with a content-free typed error. + +The existing turn budget accounts for refusal text and map metadata, including empty entries, and +releases that state on terminal, failure, or cancellation. Pending role/tool/refusal/finish/`[DONE]` +frames form one terminal batch: all serialized strings and encoded frames must be admitted before +any batch frame is enqueued. Admission failure releases the batch and refusal state, cancels upstream, +and emits only the bounded overflow error. Collector processing failures cancel their reader before +releasing its lock, so upstream translation cannot continue after failed JSON collection. The outer +response finalizer continues to own retained response bytes. These are projection rules, not new +refusal policy or changes to ordinary content/tool semantics. + ## Parallel tool calls (default-on for chat providers) The openai-chat adapter buffers ALL streamed `tool_calls` deltas (keyed by `index`, falling back to @@ -1531,7 +1643,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | -| API-key pools | `src/providers/key-failover.ts` | A 429 rotates the active key and records a cooldown; `provider.apiKey` keeps mirroring the active entry so routing stays single-key. | +| API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A 429 rotates the active key and records a cooldown; `provider.apiKey` keeps mirroring the active entry so routing stays single-key. | | OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, and a per-provider `enabled` beats the global default in either direction. | | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. | @@ -1550,6 +1662,42 @@ shares the 12-image active cap. Bounded source labels are emitted in active user root pruning cannot erase attachment provenance; the same text participates in token estimation. Native Composer/MCP behavior and text-only historical replay remain unchanged. +## Chat streamed tool-call identity + +`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer +index as an alias when the call started by ID. Every present, non-null index must +be a number in that range: strings (including numeric and empty strings), booleans, +objects, arrays, negative numbers, fractions and unsafe integers terminate the stream +before any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; +larger integers are rejected because distinct wire literals can parse to the same number. +The invalid-index error releases all pending call reservations without emitting +those calls or a successful completion; invalid indexes are never treated as absent. +Only missing and null indexes are absent-index placeholders. Repeated ID, name and +argument string-field tolerance retains its existing rules. + +For valid indexes, lookup preserves direct-key precedence, then index alias, then +ID fallback. The initial key continues to own all translator budget reservations +and release; learning an alias creates no additional owner. Unassociated index-only +fragments are not guessed onto pending ID-only calls. +`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, +parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum +safe-integer boundary, invalid index types, missing/null continuations and UTF-8 +byte-limit boundaries. + +## Cursor executable tool schema ownership + +`src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization +schemas; `tool-definitions.ts` remains the public facade and protobuf encoder. +Advertisement and normalization intentionally differ for shell bridges: Cursor may +emit `cmd`, while the declared Responses contract decides whether it becomes +`command`. Both paths preserve execution-control fields. Freeform tools use one +required string `input` in a closed object, retaining that tool's string-valued +input description from the parser (including patch-envelope guidance). Other input +constraints cannot widen the canonical shape. Bare shell bridge names are rejected +on the freeform path. +Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in +`tests/providers/cursor/cursor-tool-definitions.test.ts`. + ## Sidecars Web search and vision sidecars run only when the main request needs that capability and a usable @@ -1570,3 +1718,56 @@ On the OpenAI path there is one deterministic `openai` sidecar candidate and its owns credential selection; API-key OpenAI is not a ChatGPT forward sidecar candidate. Sidecar failures must degrade to text markers or skipped capability, not abort the main request. + +### Grok snapshot module ownership + +The client-specific tracker lives in `grok-responses-snapshot-repair.ts`; the +provider-opt-in tracker remains in `responses-snapshot-repair.ts`. Their unchanged +object guard, JSON block encoder and retained-item shape live in the dependency- +free `responses-snapshot-codec.ts`. Core imports each tracker directly. No existing +snapshot export moves, and neither tracker imports the core dispatcher. The Grok +marker selects compatibility behavior and conveys no authenticated client identity. + +Manual and automatic OAuth/API-key selection commit through their shared selection owners before +dispatch. Selection revisions fence stale retries and reselection; request identity includes the +actual committed account/key. Generic proactive selection is opt-in and preserves a healthy active +account, while reactive429 recovery remains enabled even with the pool off. Post-commit selection +events immediately invalidate dashboard roster state; see`05_gui-and-management-api.md`. + + +### Incomplete quota terminals + +A native forward response that ends with quota or rate-limit evidence in an +`incomplete` terminal records account quota failure and spawn-fallback health. +Structured `incomplete_details.reason` and error codes are accepted without a +message; ordinary output-limit, filtering, steering and stall incompletes do not +cool an account. Cyber-policy classification retains precedence. The terminal is +not replayed after output, and fixed-account request selection remains fixed. + +Remote compact requests release the server request-idle timeout only after a complete +JSON object with a valid model has been read. Partial or invalid uploads retain +the listener guard; admitted compaction then uses the upstream operation's own +deadlines and client cancellation. + +Buffered routed compaction treats nonempty text and reasoning deltas as progress +without exposing partial summary text. Comments, empty deltas and gateway +keepalives do not reset the adapter-event stall watchdog. The default stall +timeout stays 300 seconds; encrypted compaction content is preserved unchanged. + +Native compact response buffering also enforces a body-byte inactivity deadline +using `stallTimeoutSec` (300 seconds by default). Nonempty chunks reset that +deadline; a stalled body returns HTTP 504, client cancellation retains HTTP 499, +and cleanup does not wait for a stuck upstream cancellation promise. The 32 MiB +response ceiling and the original body bytes are preserved. + +A canonical upstream WebSocket refused-create error can become an HTTP 4xx only +before the response is committed and after stream correlation checks. Permitted +quota headers are bounded and rebuilt without upstream framing headers; the JSON +response is not cacheable. Post-commit and 5xx errors keep the no-resend path. + +When encrypted agent-task recovery refuses a routed task, its existing 400 error +can include a bounded `recovery_reason`: `unsupported_envelope`, +`admission_denied`, `recovery_unavailable`, `caller_cancelled`, or `input_changed`. +The field is omitted when no classified recovery result exists. +`recovery_unavailable` includes cache/singleflight capacity and does not prove an +upstream request was attempted. No retry or broader envelope acceptance is enabled. diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 9b8b3fee5e..a151a34d42 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -126,8 +126,8 @@ this document owns is which module holds which area and what invariant that area | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | -| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the Codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, the logical maximum thread count, experimental `v2NativeParentOverride`, and default-off scalar `v2RoutedDelegationBridge`. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT accepts `enabled`, `multiAgentMode`, `keepNativeChatGptOnV1`, `maxConcurrentThreadsPerSession`, the complete override object, and/or bridge boolean; contradictory mode/flag pairs and invalid override targets are rejected before writes. Override- or bridge-only writes persist without catalog restamping. Every multi-agent transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | -| Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | +| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | +| Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | @@ -155,50 +155,6 @@ Provider writes must not round-trip masked API keys as real secrets. Dashboard a model visibility or subagent selection should trigger catalog/cache sync behavior through the server path that owns it. -## V2 native parent override invariant - -`v2NativeParentOverride` is deliberately separate from `keepNativeChatGptOnV1` and -`agentTaskRecovery`: the first preserves a ChatGPT-native parent on the v1 surface; the second -preserves a native V2 parent and uses an extra ChatGPT recovery request for encrypted child content; -the override preserves the V2 surface but replaces an eligible native root with one configured -routed provider before execution. It is default-off, requires explicit V2 plus the upstream V2 flag, -and cannot be active with Keep ChatGPT on v1. - -The dashboard and `/api/v2` expose the configured target and derived `active` state. The runtime -reads the target per eligible request only while `active` is true, resolves it through normal -routing, and fails closed on a missing, disabled, unroutable, or canonical target; it never silently -falls back to ChatGPT. Changing mode, the upstream V2 flag, or Keep ChatGPT on v1 makes subsequent -requests skip the override while preserving the stored target and enabled selection. The requested -model may remain visible in Codex while the resolved provider receives prompts, repository context, -history, and tool results. Native children are preserved, so a native child can still create an -encrypted routed grandchild. There is no automatic target choice, protocol decryption, nested -override, per-thread pin, or CLI surface. Public behavior details live in the -[Sub-agent Surface guide](/guides/sub-agent-surface/) and [Agent Configuration reference](/reference/configuration/agents/). - -## Routed V2 delegation bridge invariant - -`v2RoutedDelegationBridge` is default-off and independent of native GPT-to-GPT collaboration, -`v2NativeParentOverride`, and `agentTaskRecovery`. It is a scalar `/api/v2` setting: the dashboard -must not optimistically change it, sends only that scalar, and refreshes the server state after a -write. The switch remains usable outside explicit V2 so operators can arm it; it is active only for -eligible native V2 root and thread-spawn child turns and disabling is immediate for later requests. - -The bridge moves `spawn_agent`, `send_message`, and `followup_task` from native collaboration to its -plaintext mirror after parent-override routing; native `wait_agent`, `interrupt_agent`, and `list_agents` -remain unchanged. Thus all uses of those three delegation-message operations are plaintext while the -experiment is active, including native-to-native delegation. The native Codex UI may retain the original model while -routed prompts, repository context, and tool results follow the selected provider's availability, -context, behavior, billing, and privacy boundaries. The rewrite also applies to genuine spawned-child -turns that settle on the canonical native provider and retain a V2 collaboration catalog; routed -fallbacks, depth-limited leaves, and non-spawn maintenance turns remain excluded. - -Continuation durability is automatic rather than another public setting. Bridge-derived or recognized -plaintext delegation history is sealed per entry with AES-256-GCM and an installation key from the OS -credential store. Credential-store failure degrades only those rows to memory-only replay; it must never -fall back to plaintext disk persistence. Legacy v1/v2 response-state snapshots and their spills are retired -as a unit on first load. The bridge prevents new ciphertext only inside its eligibility boundary, while -`agentTaskRecovery` remains the fallback outside it. Routed providers are inside the plaintext trust boundary. - The UI must show one provider card and one Models group for Codex-login OpenAI, describe Pool and Direct accurately, and keep the main account inside Pool. Public model state keeps virtual Pro ids even though transport logs may additionally report the resolved base model. Detailed rules live in @@ -209,6 +165,22 @@ User aliases are display metadata only. Codex pool aliases live on `CodexAccount identity, active selection, and routing never consult these fields. The matching CLI is `ocx account alias ` (`rename` is accepted as a synonym). +OAuth manual and automatic selection share `commitOAuthAccountSelection` in the auth store. +The caller resolves a usable credential, commits its matching selection, then dispatches it; +request-local token replacement must not leave a different dashboard account selected. +Opaque selection revisions protect manual reselection and A→B→A changes from older requests. +Credential-only refresh preserves the revision. Generic proactive routing is opt-in and retains +a healthy selected account; reactive 429 recovery remains available even when the pool is off. +API-key manual selection and failover similarly share `commitProviderApiKeySelection`, carrying +stable entry identity and selection revision instead of comparing a resolved secret with an env reference. + +The authenticated `GET /api/accounts/events` stream invalidates account/key selection after +successful persistence. Events contain provider/kind/revision only. The dashboard immediately +reconciles the cheap local roster and preserves its quota rows; no upstream quota probe is caused +by an event. One screen-owned stream has disconnect cleanup and bounded server subscribers; +reconnection and the existing shared scheduler provide recovery. Codex retains its own established +selection controller. These events cannot change credentials or select an account. + Selection order is the opposite case and must not be folded into the alias route. `codexAccountPriorities` is routing metadata that Pool selection consults, it lives in config rather than on `CodexAccount` so the `__main__` Desktop login can carry one, and the alias route's rejection of `__main__` would be wrong for @@ -406,6 +378,23 @@ include credits-only and measured-zero readings, unsupported, unobserved, explic unavailable-with-last-good. Forced account/key enrichment settles before its control reports a completed check, and provider-report waiters are bound to the exact refresh epoch. +Main-account WHAM refresh diagnostics are an ephemeral `quotaRefresh` outcome carried +from `fetchMainAccountInfoWhileOwned` to the generation-checked account DTO and the +opt-in CLI quota JSON. They are not persisted or consumed by admission/rotation. +A private per-dispatch identity generation fences the diagnostic independently of ordinary +quota metadata. Both snapshot and account DTO publication omit externally invalidated +attempts; the generation itself is never serialized or stored in the quota cache. +The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP +status, so an unexpected management response cannot add raw upstream material. + +[Decision Log] +- 목적과 의도: Explain missing main-account quota without confusing a working login with a successful WHAM read. +- 기존 구현 및 제약 조건: HTTP failures and body/transport exceptions returned identical null metadata; existing authentication and freshness policy must remain unchanged. +- 검토한 주요 대안: Copy raw errors, infer plan/quota, reuse stale evidence, or add a bounded diagnostic outcome. +- 선택한 방식: Carry a non-persisted fixed category and optional numeric HTTP status through the existing management and CLI read paths. +- 다른 대안 대신 이 방식을 선택한 이유: It gives reporters actionable evidence without disclosing payloads, changing permissions, or introducing another cache. +- 장점, 단점 및 영향: Main-account failures become distinguishable; root-cause repair and pool diagnostics remain separate work, and clients must tolerate an absent field. + `src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`. An opt-in shadow-call rewrite persists the bounded, redacted original helper model as `shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing @@ -487,3 +476,27 @@ use the `[ocx::]` prefix, go to the proxy terminal, and are buff ## Remote credentials and bounded sessions Data keys authorize only the data matrix and authenticated catalog. Admin credentials authorize ordinary management and key rotation but cannot mint, exchange, or refresh a `gui-session`. Pairing grants are digest-only, origin-bound, one-use, capped at 128 live grants, burned after five grant failures, and source-limited after ten failures in ten minutes with at most 1,024 source buckets. `POST /api/session/logout` invalidates only the current origin/CSRF-authorized browser session. + + +### Model picker ordering settings + +`GET /api/subagent-models` retains `chosen`, `available`, and `catalogState`, and adds routed-only +`pickerAvailable`, saved `pickerOrder`, and nullable `pickerOrderMode`. `available` still includes +saved disabled/missing roster choices; it is not the eligible-picker set. Bare aliases are excluded +from the routed preset surface because a bare id activates complete Codex-picker ordering. + +PUT accepts `models` and/or `pickerOrder`; `pickerOrderMode` requires `pickerOrder` and accepts +`alphabetical`, `provider`, `most-used`, or null. Roster arrays keep their existing exact string +values and five-slot cap. Picker arrays reject blank, duplicate or ineligible ids. Null/empty +order clears order and mode; a nonempty order without a mode clears only the mode. Validation +finishes before a synchronous live mutation/save. An unsupported future deletion-provenance format +returns 409 for picker writes instead of losing clear intent. Deletion intent is staged separately and +materialized as existing config rebase provenance, so failed persistence restores the touched +fields without contaminating the live object's pending-deletion state. Absent fields are not +copied back from a snapshot taken before discovery, preserving concurrent roster changes. + +Only roster writes sync Claude agent definitions/auto-apply Desktop profiles. Picker writes +converge the Codex catalog once and return its disposition. The Models UI owns a separate bounded +picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through +the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode +survives availability drift, while complete/native custom orders await explicit replacement. diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index 653fde08eb..8c6149802b 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -79,11 +79,9 @@ Those controls still have no owner, so there is no image-publish workflow or off | Workflow | Trigger | Purpose | | --- | --- | --- | -| `.github/workflows/ci.yml` | `pull_request`, merge-queue `merge_group`, `push` to `main`/`preview`/`dev`, or manual dispatch | Cross-platform quality gate. Concurrency identities supersede stale PR/push runs without canceling immutable merge-queue or manual evidence, merge-group paths compare explicit queue base/head SHAs, and the stable `ci` aggregate fails closed over every producer. Windows always uses ephemeral GitHub-hosted runners. | -| `.github/workflows/release-pr.yml` | Manual dispatch only (main branch) | Maintains a reviewable Release Please version PR without creating a GitHub release or publishing a package during the candidate-artifact rollout. | -| `.github/workflows/release-candidate.yml` | Successful `Cross-platform CI` push run on `main`, or exact-SHA manual dispatch | Builds one npm tarball, records canonical source/tree/input/package provenance, and uploads it without publish credentials. | -| `.github/workflows/fork-auto-release.yml` | Successful `Build release candidate` completion on `main` | Resolves exactly one unexpired artifact from the trusted candidate run and dispatches its run/artifact IDs. | -| `.github/workflows/release.yml` | Audited stable repository dispatch or transitional manual dispatch | Stable automation re-verifies and publishes the exact candidate tarball without lifecycle scripts or repacking. Existing manual dev/preview callers retain their prior path until rollout is proven and cutover is authorized. | +| `.github/workflows/ci.yml` | Any `pull_request`; runtime/package `push` to `main`/`preview`/`dev`; manual dispatch | Linux runs four suite shards plus `gates`; macOS runs two shards. Windows runs six shards only on manual dispatch with `lane=all` (or empty), not on push events. Aggregate `ci` accepts an intentional Windows skip, so release evidence must inspect all six actual job results on the exact publish SHA. `npm-global-smoke` remains GitHub-hosted because it mutates the global package prefix. | +| `.github/workflows/dev-version-bump.yml` | Manual dispatch with an intended version and `pre-move` or `repair` mode | Opens the reviewed pull request that moves `dev` past a release target. The default `pre-move` mode runs before promotion and publication; explicit `repair` mode retains the post-publish catch-up path. It is neither called by `release.yml` nor triggered by publication. | +| `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires successful Cross-platform CI for the exact `GITHUB_SHA`, requires `dev` to outrank the target, then checks the target against the freshly fetched global tag set before publish or dry-run. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | | `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, labeled, unlabeled, ready_for_review, synchronize) plus default-branch `status` events filtered to successful `CodeRabbit` statuses | The `enforce-target` gate: rejects pull requests whose head ancestry sits on the `main` tip while far behind `dev`, rejects empty or malformed descriptions, requires a GUI screenshot when the title/body mentions `gui` (immediately waivable with the maintainer-controlled `gui-screenshot-waived` label; legacy maintainer comments remain compatibility evidence on later PR events), keeps contributor PRs in draft until a four-box readiness checklist is complete, verifies the CI / latest-dev / Codex+CodeRabbit-findings claims (review threads plus current-head CodeRabbit review-body findings outside the diff range), and adds a `review-ready` status label at the ready moment. CodeRabbit status SHAs must resolve to exactly one open current-head PR before writes. Stacked child PRs targeting another open PR's head skip the wrong-base gate. | @@ -98,11 +96,14 @@ Those controls still have no owner, so there is no image-publish workflow or off branch, not from `dev`. Landing a change to one of them on `dev` does not change live behavior until it is promoted, so those files follow the promotion model rather than ordinary integration. -Every Windows CI lane uses `windows-latest`. Pull-request workflows execute proposed workflow code, -so an event-name selector inside the same file cannot safely protect a persistent self-hosted host. -Do not register a repository-level self-hosted runner for this public user-owned repository. If a -future organization migration introduces isolated ephemeral runner groups, access must be restricted -outside candidate-controlled YAML and security-reviewed before any workflow starts using them. +The Windows selector is an operational stability control, not a security boundary. A pull request +controls the `pull_request` workflow body and can rewrite an event-name check, repository variable, +or selector output. Because this is a public user-owned repository and runner groups are unavailable, +the repository setting **Fork pull request workflows from outside collaborators: Require approval +for all outside collaborators** (`all_external_contributors`) must remain enabled before any self- +hosted runner is registered. Maintainers must inspect workflow changes before approving an external +run. If that setting cannot be verified, unset `OCX_SELF_HOSTED_WINDOWS` and deregister the runner; +the workflow then fails back to `windows-latest` rather than exposing a persistent maintainer host. Docs-only changes intentionally route through the docs workflow instead of the runtime CI gate. If a docs change also edits runtime/package/release files, run the relevant local runtime checks before @@ -148,19 +149,17 @@ exists so the repository-shape source of truth does not omit the shape of its ow published outcome — the fix, its regression test, the release note, the advisory once public — reaches the repository. -After a successful real stable release from `main`, promotion automation advances `dev` to the -next stable patch in a package.json-only commit based on that released main commit. Re-running the -completion event is idempotent when `dev` already carries that successor; any other branch or -version state fails closed. Upstream sync applies the package recipe without ever decreasing the -current valid version. - ## Maintenance governance `MAINTAINERS.md` is the source of truth for current project roles and the review and merge policy. `.github/CODEOWNERS` declares default reviewers and repeats ownership for authentication, repository automation, release, and governance paths where an explicit security review is required. GitHub repository settings remain the source of truth for actual account permissions and protected-branch -enforcement. +enforcement. For `dev`, a current maintainer with live `maintain` or `admin` access can +explicitly integrate a PR without a second maintainer approval. The optional merge-review +helper validates this actor/base exception separately from its default contributor-approval +path; it does not certify CI or security review. The PR-only bypass leaves direct pushes, +force-pushes and deletion blocked. `main` and `preview` retain their existing review rules. [Decision Log] - 목적과 의도: Make project ownership and review authority discoverable without exposing credentials or treating a documentation file as an access-control mechanism. @@ -194,14 +193,18 @@ Invariants: ## Release workflow -Package release is npm-focused. Stable automation follows one provenance chain: -`Cross-platform CI` → `Build release candidate` → `Fork auto-release` → `Release`. The candidate -workflow packages once; the publisher verifies the run, artifact, manifest, source tree, inputs, -version, and tarball digests before publishing that same file. `scripts/release.ts` suppresses the -competing automatic dispatcher for helper-created stable commits, waits for that same immutable -candidate, and passes its run and artifact IDs to the publisher. Dev/preview manual dispatch remains -a transition path until a real stable candidate release proves the chain and maintainers authorize -retirement. Docs publishing is separate from npm release publishing. +Package release is npm-focused. `package.json` exposes `opencodex` and `ocx`, `prepublishOnly` runs +typecheck and GUI build. `scripts/release.ts` accepts either an explicit version or +`--bump patch|minor|major`; the stable and preview channels use separate resolvers in +`scripts/version-line.ts`. It runs local typecheck, `bun test --isolate tests`, and +`bun run privacy:scan` before the version bump, commit/push, Cross-platform CI wait, and GitHub +Release workflow dispatch. Docs publishing is separate from npm release publishing. + +Opening a release starts with the `dev` pre-move. Dispatch +`.github/workflows/dev-version-bump.yml` with the intended version, merge the pull request it opens, +then promote and release. A no-op is valid when `dev` already outranks the target. `release.yml` +independently enforces that readiness condition and refuses publication if the pre-move is missing. +The design and repair history live in `devlog/_plan/260904_release_version_line/`. Opening a preview for the next core ends the current patch line. After `vX.Y.0-preview.*` is tagged, a fix ships as part of `X.Y.0`, not as @@ -245,7 +248,7 @@ Every npm release version must map cleanly across four surfaces: | Surface | Required state | | --- | --- | | `package.json` | `version` equals the release workflow `version` input. | -| npm registry | `@yansigit/opencodex@` does not exist before publish, then exists after publish with the requested dist-tag. | +| npm registry | `@bitkyc08/opencodex@` does not exist before publish, then exists after publish with the requested dist-tag. | | Git tag | `v` does not exist before publish, then points at the exact release commit. | | GitHub Release | `v` does not exist before publish, then is created from the exact release commit. | @@ -253,8 +256,10 @@ The release must fail before `npm publish` if npm, the Git tag, or the GitHub Re requested version. This prevents partial releases where npm is published but GitHub Release creation fails afterward. -After a fresh tag fetch, the release target must outrank the global release-tag set. An exact tag at -the exact `GITHUB_SHA` is accepted only for a provenance-verified resume after partial publication. +Two ordering checks run before publication. The version on `origin/dev` must strictly outrank the +release target, proving the pre-move has landed. After a fresh tag fetch, the release target must also +outrank the global release-tag set. The only equality exception is a dry run whose existing tag points +at the exact `GITHUB_SHA`; a real publish never receives that exception. Do not force-move public version tags by default. If release metadata is already inconsistent, treat the version as consumed and publish the next unused patch version instead. Only rewrite a public tag @@ -263,7 +268,7 @@ after an explicit human decision that the public history rewrite is acceptable. Manual preflight checks when debugging a release: ```bash -npm view @yansigit/opencodex@ version +npm view @bitkyc08/opencodex@ version git ls-remote origin refs/tags/v gh release view v ``` @@ -276,8 +281,12 @@ preview has closed that stable patch line. ## Cross-platform CI `.github/workflows/ci.yml` is the ordinary quality gate for runtime/package changes. Linux runs -the suite in four shards with a separate `gates` job, macOS runs it whole, and Windows runs whole -but only at the shipping boundary (`push` to `main`/`preview`, or manual dispatch). Each lane runs: +the suite in four shards with a separate `gates` job, and macOS runs it in two shards. Windows +runs the full suite in six shards only on manual `workflow_dispatch` with `lane=all` (or an +empty lane). Pushes to `dev`, `main` and `preview` do not activate that Windows matrix. A +release that requires Windows proof must dispatch it for the exact publish SHA and inspect +all six successful jobs; an aggregate green `ci` check can include a deliberate Windows skip. +Across the jobs, the workflow runs: ```bash bun install --frozen-lockfile @@ -295,17 +304,18 @@ and the Node-only global-install smoke path: npm install npm run build:gui npm pack --json > pack.json -npm install -g ./yansigit-opencodex-*.tgz +npm install -g ./bitkyc08-opencodex-*.tgz ocx help ``` The CI intentionally does not build docs, run coverage, or perform remote Ubuntu/RDP smoke tests. Those stay outside the default gate until a concrete regression justifies the extra runtime. -The Release workflow remains publish-focused. Before any dry-run or publish step, it checks that the -exact release commit (`GITHUB_SHA`) already has a successful Cross-platform CI run and that the target -passes the fresh global tag-ordering gate. Stable automation additionally requires the immutable, -provenance-verified candidate tarball produced for that exact commit. +The Release workflow remains manual and publish-focused. Before any dry-run or publish step, it +checks that the exact release commit (`GITHUB_SHA`) already has a successful Cross-platform CI run, +that `dev` already outranks the target, and that the target passes the fresh global tag-ordering gate. +This keeps release runs short and makes release a deployment of a verified commit after the required +`dev` pre-move rather than a second CI pipeline. ## Remote Hub locale and release gate diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index 2c2e42b419..70ad5f1ea9 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -36,6 +36,26 @@ Status and mutation must use the same classifier. A special case added only to a would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. +TOML temporal scalars cannot survive the JSON-cloned merge representation with their types +intact. The common parser refuses documents containing them before either status or mutation +proceeds, including nested arrays and inline tables. Quoted date strings remain supported. + +## Catalog visibility + +Management export and CLI export apply the canonical routed catalog visibility filter before +serialization: provider selections, disabled models, and pending initial selection all constrain +the client roster. The full management list remains available for selection. Native rows retain +their existing visibility rules. + +## Owned catalog convergence + +Visibility, selected-model and preset writes refresh already-owned Pi/Aside contributions after +persisting the selection. Explicit sync refreshes MCode, Pi and Aside. The shared catalog-refresh +fan-out loads the filtered roster lazily once, leaves unowned clients alone, and reports each +refusal independently. Existing coordinated writers retain all no-clobber and ownership checks. +Implicit refresh operations use distinct flight keys: overlapping desired catalogs return busy +rather than joining a write of a different catalog and reporting false success. + ## Fast model selectors The serving proxy resolves `fastRowAvailable` on every management model row, including its @@ -127,4 +147,78 @@ fingerprint-only tests are supplementary; they cannot prove the status and write ## Remote connection lifecycle -Remote clients journal and restore native integrations locally while model traffic travels directly to the hub. Catalog writes occur only after protocol negotiation and full remote schema validation. The management relay is launcher-scoped and fixed to the connection's management origin. Claude/Codex launch behavior remains integration-scoped. Key rotation uses `pendingOperation` plus `.prev`; disconnect restores locally without hub-side revocation or usage mirroring. +Remote clients journal and restore native integrations locally while model traffic travels directly to the hub. Catalog writes occur only after protocol negotiation and full remote schema validation. The management relay is launcher-scoped and fixed to the connection's management origin. Claude/Codex launch behavior remains integration-scoped. Key rotation and recovery align both the local connection credential and the connection-owned Desktop profile before reporting completion. Disconnect restores owned Desktop settings and native integrations locally without automatic hub-key revocation or usage mirroring. Interrupted cleanup remains recoverable for the same connection; conflicts prevent a full-cleanup claim. + +## Connected Claude Desktop profiles + +Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin +and exact hub-issued IDs to the local Desktop configuration. Static/hybrid embed the entries; +discovery-only keeps discovery on the hub. The hub owns family assignments and defaults; local +show/edit/import/export operations do not manage that profile. After hub changes or historical +client-only aliases, apply again and reselect the model. Connected `import --apply` is explicitly +unsupported and refuses before saving the import. + +`src/claude/desktop-discovery-inputs.ts` owns the shared Desktop discovery projection used by +startup registry initialization and server discovery. `src/server/index.ts` exposes the explicit +`GET /v1/models?ids=desktop&format=desktop-config` snapshot, shaped as `{version:1,models:[...]}` +and sent with `Cache-Control: no-store`. `src/client/hub-client.ts` downloads it with the existing +data credential; `src/cli/claude-desktop.ts` selects connected apply, and `src/claude/desktop-3p.ts` +writes the resulting local Desktop configuration. No admin token, hub-profile upload or local +alias regeneration is part of this flow. Unsupported old hubs, invalid snapshots and unavailable +Desktop models fail apply without a local-catalog or loopback fallback. + +Date-shaped Desktop IDs can overlap genuine native model IDs. When available discovery and +mapping evidence cannot resolve one, Messages and count-tokens return HTTP 503 with the fixed +`desktop_model_mapping_unavailable` error rather than classifying it as invalid. Unknown legacy hash aliases +remain HTTP 400; neither case reaches date-stripping or fallback routing. Known/registered IDs, +exact operator mappings and recognized native IDs keep their existing handling. Discovery refresh +or reapplying the connected hub profile may supply the missing mapping; retry alone does not +guarantee resolution. + +The remote-alias slice does not change thinking/redacted-thinking replay or prompt-cache +behavior. Those remain the separate request tracked in #3719; proxy admission alone does not +establish native Anthropic passthrough or imply that translated Anthropic caching is disabled. + +### Desktop ownership across the connection lifecycle + +`src/claude/desktop-remote-store.ts` owns the first protected restoration baseline and the +connection-owned Desktop fields. `src/cli/claude-desktop.ts` handles connected apply, while +`src/client/connect.ts` coordinates key rotation/recovery and disconnect. Reapply and rotation retain the original +baseline. Restoration merges into current user fields, preserves unrelated profiles, and restores +the previous selection only while the managed profile is still selected. A later valid user +selection is not changed. A newly created profile with user additions is retained in readable +standard mode instead of deleting those additions. + +A proven legacy current-hub/recognized-key profile without an original baseline can be adopted +by apply, rotation/recovery or direct disconnect without a new flag or prerequisite reapply. +Its explicit standard-fallback outcome is distinct from original restoration: only owned gateway +settings are removed, with user fields and independent valid selection preserved. Unknown keys, +changed managed fields or damaged restoration records remain conflicts, not permission to capture +new originals or overwrite user data. + +Rotation changes credentials without changing model IDs, family/default choices or selecting the +managed profile again. The CLI reports `rotation: "committed"` only for the new active generation; +`rotation: "rolled_back"` means the previous generation was retained/restored and must not claim +revocation of that previous key. Incomplete recovery keeps the operation unresolved. Disconnect +restores Desktop even with `--keep-catalog`; retries preserve the original catalog choice and must +not clear a newer connection. Authorized uninstall completes or resumes owned Desktop cleanup +before removing OpenCodex state, and preserves recovery state when cleanup conflicts or fails. + +These guarantees concern files on disk. Fully quitting and reopening Desktop is required after +apply, rotation/recovery or restoration; there is no automatic process restart or guarantee that +a running app discarded a key. Local disconnect does not revoke the hub key or remove arbitrary +external copies. Model-list snapshot version 1 remains a read-only contract, not a new lifecycle +or profile-upload API. Thinking replay and prompt caching remain separate in #3719. + +## Aside profile ownership + +Aside discovery projects only registered numeric account IDs, labels and current status. Catalog +paths derive from the configured root/u/id, never from browser profilePath. Guarded filesystem +identity and IO apply to status and writes; internal resolved path pairs survive async freezing. + +`asideProfileSync` owns desired all-profile defaults and per-profile overrides. The legacy +connection defaults all profiles on; explicit per-profile changes materialize that default and +pin one legacy root owner before changing it. Sibling stores remain independent. Policy saves +precede coordinated writes under one scoped flight, and actual file state/refusals remain +separate. Restore reconciles target intent from validated snapshot ownership without changing +sibling policy. Profile journal views retain source-store provenance for older legacy entries. diff --git a/structure/11_compatibility-contracts.md b/structure/11_compatibility-contracts.md index 27a446eb2a..c503cc0991 100644 --- a/structure/11_compatibility-contracts.md +++ b/structure/11_compatibility-contracts.md @@ -67,3 +67,26 @@ them independently. - 선택한 방식: Add a passive versioned schema and one exact `openai`/canonical Codex URL/forward/`gpt-5.6-sol` manifest whose claims reference assertion-level fixtures executed against the production adapter. - 다른 대안 대신 이 방식을 선택한 이유: Registry flags do not capture transformations such as local continuation expansion or orphan-output degradation. A broad first matrix would turn unverified assumptions into public promises. - 장점, 단점 및 영향: The first contract is small but trustworthy and can feed future CLI/GUI surfaces. Coverage expands only as fixtures are added; no request behavior changes in this slice. + +## Routed code-mode patch completion + +Native Responses custom exec and function helper aliases apply the same complete-envelope +resolver at input.done, output_item.done and terminal snapshots. Potential raw/wrapped patch +previews are withheld before compilation; ordinary native custom payloads retain their raw +grammar. A string merely containing patch markers remains executable caller input and is +never rewritten. Completion and disposal release retained preview buffers. + +## Native ordinary function completion + +The native Responses lane captures ordinary function schemas from the current caller-owned +catalog before provider lowering; historical replay catalogs cannot add repair authority. +Completion events, JSON responses and stored continuation output share schema-aware argument +repair. Preview deltas retain the existing bridge contract; authoritative completed arguments +carry representation fixes. Custom tool wrappers and native forward traffic are excluded. + +Namespace restoration and the undeclared-name guard share one dotted-alias collision inventory, +including bare declarations inside the reserved functions group. Canonical authorization happens +before dotted aliases are added. A conflicting explicit namespace is never overwritten. Namespace +restoration retains the existing lowered-kind handling because custom tools are lowered to +functions before the adapter constructs its alias map; ordinary argument repair independently +checks the original declaration kind. diff --git a/tests/adapters/anthropic/anthropic-account-pool.test.ts b/tests/adapters/anthropic/anthropic-account-pool.test.ts index 235bffd043..3aade3081b 100644 --- a/tests/adapters/anthropic/anthropic-account-pool.test.ts +++ b/tests/adapters/anthropic/anthropic-account-pool.test.ts @@ -16,8 +16,12 @@ import { resolveAnthropicAccountForSession, resetAnthropicRoutingForManualSelection, rotateAnthropicAccountOn429, + getAnthropicPoolAccessSnapshot, + promoteAnthropicActiveAccount, + anthropicSessionAffinitySizeForTests, } from "../../../src/oauth/anthropic-routing"; -import { getAccountSet, saveCredential, setActiveAccount } from "../../../src/oauth/store"; +import { captureOAuthAccountSelection, getAccountSet, markAccountNeedsReauth, saveCredential, saveAccountCredential, setActiveAccount } from "../../../src/oauth/store"; +import { subscribeAccountSelections } from "../../../src/lib/account-selection-events"; import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../../../src/providers/quota"; import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../../../src/types"; import { flushConfigDirHardeningForTests } from "../../../src/config/paths"; @@ -28,6 +32,18 @@ const originalHome = process.env.OPENCODEX_HOME; let home: string; const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +async function admitAnthropic(sessionKey: string, config: OcxConfig) { + const expected = captureOAuthAccountSelection("anthropic"); + const choice = resolveAnthropicAccountForSession(sessionKey, config); + if (choice.accountId) { + const snapshot = await getAnthropicPoolAccessSnapshot(choice.accountId); + expect(await promoteAnthropicActiveAccount(choice.accountId, expected, { + config, sessionKey, reason: choice.reason, expectedCredentialGeneration: snapshot.generation, + })).not.toBeNull(); + } + return choice; +} + beforeEach(() => { // Account routing is the subject here; real ACL process behavior is covered // separately. Keep this credential-heavy suite from loading unrelated shards. @@ -73,6 +89,9 @@ async function seedTwoAccounts() { const a = set.accounts.find(acc => acc.credential.accountId === "uuid-aaaa")!; const b = set.accounts.find(acc => acc.credential.accountId === "uuid-bbbb")!; await setActiveAccount("anthropic", a.id); + // Ordinary policy cases start after the initial stored selection has been admitted. + // Restart cases explicitly clear runtime state below to exercise first admission again. + await admitAnthropic("", cfg(false)); return { aId: a.id, bId: b.id }; } @@ -127,10 +146,122 @@ async function seedThreeAccounts() { const b = set.accounts.find(acc => acc.credential.accountId === "uuid-bbbb")!; const c = set.accounts.find(acc => acc.credential.accountId === "uuid-cccc")!; await setActiveAccount("anthropic", a.id); + await admitAnthropic("", cfg(false)); return { aId: a.id, bId: b.id, cId: c.id }; } describe("anthropic account pool", () => { + test.each(["round-robin", "quota"] as const)("persisted manual choice survives restart before the first %s dispatch", async strategy => { + const { aId, bId } = await seedTwoAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 11 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 30 }); + await setActiveAccount("anthropic", bId); + resetAnthropicRoutingForManualSelection(bId); + const persisted = captureOAuthAccountSelection("anthropic"); + // A process restart loses both local preference and the shared RR cursor. + clearAnthropicAccountPoolState(); + clearPoolRotationState(); + const config = cfg(true, 20, { strategy, stickyLimit: 1 }); + expect(resolveAnthropicAccountForSession("restart-first", config).accountId).toBe(bId); + expect(resolveAnthropicAccountForSession("restart-proposal", config).accountId).toBe(bId); + expect(captureOAuthAccountSelection("anthropic")).toEqual(persisted); + expect((await admitAnthropic("restart-first", config)).accountId).toBe(bId); + // Once the authoritative first selection commits, the ordinary algorithm resumes. + expect((await admitAnthropic("restart-next", config)).accountId).toBe(aId); + expect(resolveAnthropicAccountForSession("restart-first", config).accountId).toBe(bId); + }); + + test("pool-off 429 recovery commits the replacement and notifies before dispatch", async () => { + const { aId, bId } = await seedTwoAccounts(); + const config = cfg(false); + const expected = captureOAuthAccountSelection("anthropic"); + const next = rotateAnthropicAccountOn429(config, aId, "30", "off-retry"); + expect(next).toBe(bId); + const snapshot = await getAnthropicPoolAccessSnapshot(bId); + let notifications = 0; + const unsubscribe = subscribeAccountSelections(event => { + if (event.provider === "anthropic") { + notifications++; + expect(captureOAuthAccountSelection("anthropic")?.accountId).toBe(bId); + } + }); + try { + expect(await promoteAnthropicActiveAccount(bId, expected, { + config, sessionKey: "off-retry", expectedCredentialGeneration: snapshot.generation, + })).not.toBeNull(); + expect(captureOAuthAccountSelection("anthropic")?.accountId).toBe(bId); + expect(notifications).toBe(1); + expect(anthropicSessionAffinitySizeForTests()).toBe(0); + } finally { unsubscribe(); } + }); + + test.each([false, true])("stale promotion cannot replace a newer manual choice (ABA=%s)", async aba => { + const { aId, bId } = await seedTwoAccounts(); + const expected = captureOAuthAccountSelection("anthropic"); + const snapshot = await getAnthropicPoolAccessSnapshot(bId); + await setActiveAccount("anthropic", bId); + if (aba) await setActiveAccount("anthropic", aId); + const manual = captureOAuthAccountSelection("anthropic"); + resetAnthropicRoutingForManualSelection(manual!.accountId); + let notifications = 0; + const unsubscribe = subscribeAccountSelections(() => { notifications++; }); + try { + expect(await promoteAnthropicActiveAccount(bId, expected, { + config: cfg(true), sessionKey: "stale", expectedCredentialGeneration: snapshot.generation, + })).toBeNull(); + expect(captureOAuthAccountSelection("anthropic")).toEqual(manual); + expect(anthropicSessionAffinitySizeForTests()).toBe(0); + expect(notifications).toBe(0); + expect(resolveAnthropicAccountForSession("next", cfg(true)).accountId).toBe(manual!.accountId); + } finally { unsubscribe(); } + }); + + test("token rejection does not consume the manual preference or install affinity", async () => { + const { aId, bId } = await seedTwoAccounts(); + await setActiveAccount("anthropic", aId); + resetAnthropicRoutingForManualSelection(aId); + const expected = captureOAuthAccountSelection("anthropic"); + const snapshot = await getAnthropicPoolAccessSnapshot(aId); + await markAccountNeedsReauth("anthropic", aId, true); + expect(await promoteAnthropicActiveAccount(aId, expected, { + config: cfg(true), sessionKey: "rejected", expectedCredentialGeneration: snapshot.generation, + })).toBeNull(); + expect(anthropicSessionAffinitySizeForTests()).toBe(0); + await markAccountNeedsReauth("anthropic", aId, false); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 30 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 11 }); + expect(resolveAnthropicAccountForSession("recovered", cfg(true, 20)).accountId).toBe(aId); + }); + + test("account snapshot refuses expired background local-CLI credentials without refreshing", async () => { + const { aId, bId } = await seedTwoAccounts(); + const account = getAccountSet("anthropic")!.accounts.find(account => account.id === bId)!; + await saveAccountCredential("anthropic", bId, { ...account.credential, source: "local-cli", expires: 1 }); + await expect(getAnthropicPoolAccessSnapshot(bId)).rejects.toThrow("background local-cli token expired"); + expect(captureOAuthAccountSelection("anthropic")?.accountId).toBe(aId); + }); + + test("manual choice wins the next healthy quota dispatch above the automatic threshold", async () => { + const { aId, bId } = await seedTwoAccounts(); + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 30 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 11 }); + await setActiveAccount("anthropic", aId); + resetAnthropicRoutingForManualSelection(aId); + expect(resolveAnthropicAccountForSession("manual-quota", cfg(true, 20)).accountId).toBe(aId); + }); + + test("uncommitted proposals neither bind affinity nor advance round-robin", async () => { + const { aId, bId } = await seedTwoAccounts(); + const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 1 }); + const first = resolveAnthropicAccountForSession("uncommitted", config); + expect(resolveAnthropicAccountForSession("another-uncommitted", config).accountId).toBe(first.accountId); + // A failed candidate must not capture the task's affinity before its selection commits. + setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 90 }); + setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 5 }); + const quota = cfg(true); + expect(resolveAnthropicAccountForSession("uncommitted", quota).accountId).toBe(bId); + }); + test("default off always returns the active account", async () => { const { aId, bId } = await seedTwoAccounts(); expect(isAnthropicAccountPoolEnabled(cfg(false))).toBe(false); @@ -145,7 +276,7 @@ describe("anthropic account pool", () => { // Force lowest-usage toward B for a cold start with high active usage. setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 95 }); setCachedProviderAccountQuotaForTests("anthropic", bId, { fiveHourPercent: 10 }); - const first = resolveAnthropicAccountForSession("sess-sticky", cfg(true)); + const first = await admitAnthropic("sess-sticky", cfg(true)); expect(first.accountId).toBe(bId); // Even if A becomes "better", affinity keeps B. setCachedProviderAccountQuotaForTests("anthropic", aId, { fiveHourPercent: 1 }); @@ -223,9 +354,9 @@ describe("anthropic account pool", () => { const config = cfg(true, 80, { strategy: "round-robin" }); const picks = [ - resolveAnthropicAccountForSession("sess-1", config).accountId, - resolveAnthropicAccountForSession("sess-2", config).accountId, - resolveAnthropicAccountForSession("sess-3", config).accountId, + (await admitAnthropic("sess-1", config)).accountId, + (await admitAnthropic("sess-2", config)).accountId, + (await admitAnthropic("sess-3", config)).accountId, ]; expect(new Set(picks).size).toBe(3); }); @@ -249,7 +380,7 @@ describe("anthropic account pool", () => { setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); const config = cfg(true, 80, { strategy: "round-robin" }); - const first = resolveAnthropicAccountForSession("T", config); + const first = await admitAnthropic("T", config); expect(first.accountId).toBeTruthy(); const pinned = first.accountId!; await setActiveAccount("anthropic", pinned === aId ? bId : aId); @@ -303,11 +434,11 @@ describe("anthropic account pool", () => { setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 3 }); - const first = resolveAnthropicAccountForSession("s1", config).accountId; + const first = (await admitAnthropic("s1", config)).accountId; expect(first).toBeTruthy(); - expect(resolveAnthropicAccountForSession("s2", config).accountId).toBe(first); - expect(resolveAnthropicAccountForSession("s3", config).accountId).toBe(first); - const fourth = resolveAnthropicAccountForSession("s4", config).accountId; + expect((await admitAnthropic("s2", config)).accountId).toBe(first); + expect((await admitAnthropic("s3", config)).accountId).toBe(first); + const fourth = (await admitAnthropic("s4", config)).accountId; expect(fourth).not.toBe(first); }); @@ -318,17 +449,17 @@ describe("anthropic account pool", () => { setCachedProviderAccountQuotaForTests("anthropic", cId, { fiveHourPercent: 10 }); const config = cfg(true, 80, { strategy: "round-robin", stickyLimit: 10 }); - const sticky = resolveAnthropicAccountForSession("sticky-1", config).accountId!; + const sticky = (await admitAnthropic("sticky-1", config)).accountId!; expect(resolveAnthropicAccountForSession("sticky-2", config).accountId).toBe(sticky); notePoolRotationFailure(POOL_KEY_ANTHROPIC, sticky); - const afterClear = resolveAnthropicAccountForSession("sticky-3", config).accountId; + const afterClear = (await admitAnthropic("sticky-3", config)).accountId; expect(afterClear).toBeTruthy(); expect(afterClear).not.toBe(sticky); // Re-establish sticky, then 429-cool the sticky account — failover + ring must leave it. clearPoolRotationState(); - const again = resolveAnthropicAccountForSession("again-1", config).accountId!; + const again = (await admitAnthropic("again-1", config)).accountId!; expect(resolveAnthropicAccountForSession("again-2", config).accountId).toBe(again); const failover = rotateAnthropicAccountOn429(config, again, "30"); expect(failover).toBeTruthy(); @@ -379,7 +510,7 @@ describe("anthropic account pool", () => { const before = getAccountSet("anthropic")!.activeAccountId; const picks = Array.from({ length: 3 }, (_, i) => resolveAnthropicAccountForSession(`promo-${i}`, config)); - expect(new Set(picks.map(p => p.accountId)).size).toBe(3); + expect(new Set(picks.map(p => p.accountId)).size).toBe(1); expect(getAccountSet("anthropic")!.activeAccountId).toBe(before); }); diff --git a/tests/adapters/key-failover.test.ts b/tests/adapters/key-failover.test.ts index f5a9d6feca..05a33d2e5f 100644 --- a/tests/adapters/key-failover.test.ts +++ b/tests/adapters/key-failover.test.ts @@ -21,7 +21,11 @@ import { } from "../../src/providers/key-failover"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; -import { routeModel } from "../../src/router"; +import { routeModel, routedProviderConfig } from "../../src/router"; +import { setProviderKeychainEntryFactoryForTests } from "../../src/providers/key-store"; +import { setActiveProviderApiKey } from "../../src/providers/api-keys"; +import { subscribeAccountSelections } from "../../src/lib/account-selection-events"; +import { providerManagementConfigError, safeConfigDTO } from "../../src/server/auth-cors"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -67,6 +71,17 @@ afterEach(() => { }); describe("hasKeyPoolFailover", () => { + test("request key identity is rejected by management and stripped from the public config", () => { + const config = makeConfig({ apiKey: "synthetic-first", apiKeyPool: [{ id: "first", key: "synthetic-first" }] }); + const routed = routedProviderConfig("p", config.providers.p); + expect(providerManagementConfigError("p", routed)).toContain("runtime field"); + const dto = JSON.stringify(safeConfigDTO({ ...config, providers: { p: { + ...routed, apiKeySelectionRevision: "internal-revision", + } } })); + expect(dto).not.toContain("_apiKeyAttempt"); + expect(dto).not.toContain("apiKeySelectionRevision"); + expect(dto).not.toContain("synthetic-first"); + }); test("true only for key-auth providers with 2+ pool entries", () => { expect(hasKeyPoolFailover({ adapter: "openai-chat", baseUrl: "x", apiKeyPool: pool3() } as OcxProviderConfig)).toBe(true); expect(hasKeyPoolFailover({ adapter: "openai-chat", baseUrl: "x", apiKeyPool: [pool3()![0]] } as OcxProviderConfig)).toBe(false); @@ -83,6 +98,70 @@ describe("hasKeyPoolFailover", () => { }); describe("rotateKeyOn429", () => { + test("an old attempt cannot overwrite a newer manual key selection or its ABA revision", () => { + const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3() }); + const routed = routedProviderConfig("p", config.providers.p); + const events: string[] = []; + const unsubscribe = subscribeAccountSelections(event => { + if (event.provider === "p") events.push(loadConfig().providers.p.apiKey!); + }); + try { + expect(setActiveProviderApiKey(config, "p", "k2")).toBe(true); + expect(rotateProviderTransportOn429(config, "p", routed, { attemptedKey: routed.apiKey })?.apiKey) + .toBe("key-beta-444555666777"); + expect(events).toEqual(["key-beta-444555666777"]); + expect(setActiveProviderApiKey(config, "p", "k1")).toBe(true); + expect(rotateProviderTransportOn429(config, "p", routed, { attemptedKey: routed.apiKey })).toBeNull(); + expect(loadConfig().providers.p.apiKey).toBe("key-alpha-000111222333"); + expect(getKeyCooldownUntil("p", "k1")).toBeNull(); + expect(events).toEqual(["key-beta-444555666777", "key-alpha-000111222333"]); + } finally { unsubscribe(); } + }); + + test("manual and automatic selection events observe committed disk state", () => { + const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3() }); + const events: string[] = []; + const unsubscribe = subscribeAccountSelections(event => { + if (event.provider === "p") { + expect(event.kind).toBe("api-key"); + expect(Object.keys(event).sort()).toEqual(["kind", "provider", "revision"]); + events.push(loadConfig().providers.p.apiKey!); + } + }); + try { + const routed = routedProviderConfig("p", config.providers.p); + expect(rotateProviderTransportOn429(config, "p", routed)?.apiKey).toBe("key-beta-444555666777"); + expect(events).toEqual(["key-beta-444555666777"]); + unlinkSync(getConfigPath()); + expect(rotateKeyOn429(config, "p", null)).toBeNull(); + expect(events).toHaveLength(1); + } finally { unsubscribe(); } + }); + + test.each(["env", "keychain"])("rotates a rejected %s reference instead of reusing its resolved credential", kind => { + const reference = kind === "env" ? "${OCX_SELECTION_TEST_KEY}" : "keychain:p/k1"; + process.env.OCX_SELECTION_TEST_KEY = "synthetic-resolved-first"; + setProviderKeychainEntryFactoryForTests(() => ({ + getPassword: () => "synthetic-resolved-first", + setPassword: () => {}, + deletePassword: () => true, + })); + try { + const config = makeConfig({ apiKey: reference, apiKeyPool: [ + { id: "k1", key: reference }, { id: "k2", key: "synthetic-second" }, + ] }); + const routed = routedProviderConfig("p", config.providers.p); + expect(routed.apiKey).toBe("synthetic-resolved-first"); + const rotated = rotateProviderTransportOn429(config, "p", routed, { attemptedKey: routed.apiKey }); + expect(rotated?.apiKey).toBe("synthetic-second"); + expect(loadConfig().providers.p.apiKey).toBe("synthetic-second"); + expect(getKeyCooldownUntil("p", "k1")).not.toBeNull(); + } finally { + delete process.env.OCX_SELECTION_TEST_KEY; + setProviderKeychainEntryFactoryForTests(null); + } + }); + test("rotates to the next key and cools down the exhausted one", () => { const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3() }); const now = 1_000_000; diff --git a/tests/adapters/openai/openai-chat-parallel-stream.test.ts b/tests/adapters/openai/openai-chat-parallel-stream.test.ts index 238faa9082..9e47b3a742 100644 --- a/tests/adapters/openai/openai-chat-parallel-stream.test.ts +++ b/tests/adapters/openai/openai-chat-parallel-stream.test.ts @@ -1,16 +1,17 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; +import type { TranslatorBudget } from "../../../src/lib/translator-budget"; import type { AdapterEvent } from "../../../src/types"; -import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; const createOpenAIChatAdapter = (...args: Parameters) => withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "key" }; -async function collect(body: string): Promise { +async function collect(body: string, budget?: TranslatorBudget): Promise { const out: AdapterEvent[] = []; - for await (const e of createOpenAIChatAdapter(provider).parseStream(new Response(body))) out.push(e); + for await (const e of createOpenAIChatAdapter(provider).parseStream(new Response(body), budget)) out.push(e); return out; } @@ -211,12 +212,251 @@ describe("openai-chat parallel tool call stream assembly", () => { expect(assembled(events)).toEqual([{ id: "call_a", name: "shell", args: "{\"cmd\":\"ls\"}" }]); }); - test("T9b: id-only first chunk followed by index+id continuation stays ONE call", async () => { + test("T9b: id-only call retains a later index for index-only continuation", async () => { + const budget = createTestTranslatorBudget(); const events = await collect(sse([ chunkOf([{ id: "call_b", function: { name: "read", arguments: "{\"p\"" } }]), - chunkOf([{ index: 0, id: "call_b", function: { arguments: ":\"x\"}" } }]), + chunkOf([{ index: 0, id: "call_b", function: { arguments: ":\"x\"" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), chunkOf([], "tool_calls"), - ])); + ]), budget); expect(assembled(events)).toEqual([{ id: "call_b", name: "read", args: "{\"p\":\"x\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("late indexes keep interleaved calls separate without adding budget owners", async () => { + const budget = createTestTranslatorBudget(); + const response = new Response(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: "{\"p\":" } }, + { id: "call_b", function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 9, id: "call_b", function: { arguments: "\"b\"" } }]), + chunkOf([{ index: 4, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 9, function: { arguments: "}" } }]), + chunkOf([{ index: 4, function: { arguments: "}" } }]), + chunkOf([{ id: "call_a", function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + const events: AdapterEvent[] = []; + let maxActiveCalls = 0; + for await (const event of createOpenAIChatAdapter(provider).parseStream(response, budget)) { + events.push(event); + maxActiveCalls = Math.max(maxActiveCalls, budget.snapshot().activeCalls); + } + expect(assembled(events)).toEqual([ + { id: "call_a", name: "read", args: "{\"p\":\"a\"} " }, + { id: "call_b", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + expect(maxActiveCalls).toBe(2); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("index-only fragments do not guess an association between unindexed calls", async () => { + const events = await collect(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: "{\"p\":" } }, + { id: "call_b", function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 0, function: { arguments: "\"a\"}" } }]), + chunkOf([{ index: 1, function: { arguments: "\"b\"}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test.each([ + ["negative, no ID", -1, undefined], + ["negative, matching ID", -1, "call_a"], + ["fractional, no ID", 0.5, undefined], + ["fractional, matching ID", 0.5, "call_a"], + ["numeric string, no ID", "0", undefined], + ["numeric string, matching ID", "0", "call_a"], + ["empty string", "", undefined], + ["true", true, undefined], + ["false", false, undefined], + ["object", {}, undefined], + ["array", [], undefined], + ] as const)("invalid index (%s) aborts without reassigning pending calls", async (_label, index, id) => { + const budget = createTestTranslatorBudget(); + const response = new Response(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: '{"p":"a"}' } }, + { id: "call_b", function: { name: "write", arguments: '{"p":"b"}' } }, + ]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "" } }]), + // Whitespace keeps either complete JSON argument valid if the invalid index is + // mistakenly ignored and this fragment falls back to its ID or the last call. + chunkOf([{ index, id, function: { arguments: " " } }]), + chunkOf([{ index: 0, function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + const events: AdapterEvent[] = []; + let sawBothPendingReservations = false; + for await (const event of createOpenAIChatAdapter(provider).parseStream(response, budget)) { + events.push(event); + const snapshot = budget.snapshot(); + // Each ASCII JSON argument is nine bytes; the valid alias heartbeat observes + // both retained reservations before the malformed continuation arrives. + sawBothPendingReservations ||= snapshot.activeCalls === 2 && snapshot.currentBytes === 18; + if (event.type === "error") { + expect(snapshot).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + } + } + expect(sawBothPendingReservations).toBe(true); + expect(events.filter(event => event.type === "error")).toEqual([expect.objectContaining({ + type: "error", + status: 502, + errorType: "upstream_error", + message: "upstream response contained invalid tool calls (invalid index)", + })]); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "done")).toBe(false); + expect(events.some(event => event.type === "tool_call_start" + || event.type === "tool_call_delta" || event.type === "tool_call_end")).toBe(false); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test.each([ + ["missing", undefined], + ["null", null], + ] as const)("%s index placeholders preserve continuation through a later valid alias", async (_label, index) => { + const budget = createTestTranslatorBudget(); + const events = await collect(sse([ + chunkOf([{ index, id: "call_a", function: { name: "read", arguments: '{"p":' } }]), + chunkOf([{ index, id: "call_a", function: { arguments: '"x"' } }]), + chunkOf([{ index: 7, id: "call_a", function: { arguments: "}" } }]), + chunkOf([{ index, function: { arguments: " " } }]), + chunkOf([{ index: 7, function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ]), budget); + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: '{"p":"x"} ' }]); + expect(events.some(event => event.type === "error")).toBe(false); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("rejects distinct unsafe raw JSON indexes before they collapse into one call", async () => { + const budget = createTestTranslatorBudget(); + // Keep both index literals on the wire: constructing JS numbers before JSON.stringify + // would already round 9007199254740993 to 9007199254740992. Without rejection, + // both whitespace fragments would silently join call_a's valid JSON despite call_b's ID/name. + const response = new Response(String.raw`data: {"choices":[{"delta":{"tool_calls":[{"id":"call_a","function":{"name":"read","arguments":"{}"}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[{"id":"call_a","function":{"arguments":""}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[{"index":9007199254740992,"id":"call_a","function":{"name":"read","arguments":" "}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[{"index":9007199254740993,"id":"call_b","function":{"name":"write","arguments":" "}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[]},"finish_reason":"tool_calls"}]} + +data: [DONE] + +`); + const events: AdapterEvent[] = []; + let sawPendingReservation = false; + for await (const event of createOpenAIChatAdapter(provider).parseStream(response, budget)) { + events.push(event); + const snapshot = budget.snapshot(); + sawPendingReservation ||= snapshot.activeCalls === 1 && snapshot.currentBytes === 2; + if (event.type === "error") { + expect(snapshot).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + } + } + expect(sawPendingReservation).toBe(true); + // The first unsafe index terminates before either unsafe fragment emits a heartbeat, + // a tool call, or done; the buffered reservation is released at the error itself. + expect(events.map(event => event.type)).toEqual(["heartbeat", "heartbeat", "error"]); + expect(events.at(-1)).toMatchObject({ + type: "error", + status: 502, + errorType: "upstream_error", + message: "upstream response contained invalid tool calls (invalid index)", + }); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("retains a late MAX_SAFE_INTEGER alias for index-only continuation", async () => { + const budget = createTestTranslatorBudget(); + const events = await collect(sse([ + chunkOf([{ id: "call_boundary", function: { name: "read", arguments: '{"p":' } }]), + chunkOf([{ index: Number.MAX_SAFE_INTEGER, id: "call_boundary", function: { arguments: '"x"' } }]), + chunkOf([{ index: Number.MAX_SAFE_INTEGER, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ]), budget); + expect(assembled(events)).toEqual([{ id: "call_boundary", name: "read", args: '{"p":"x"}' }]); + expect(events.some(event => event.type === "error")).toBe(false); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("an observed index wins over a conflicting ID without rebinding either call", async () => { + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 1, id: "call_b", function: { name: "write", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "call_a", function: { arguments: "\"b\"}" } }]), + chunkOf([{ index: 0, id: "call_b", function: { arguments: "}" } }]), + chunkOf([{ index: 0, function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([ + { id: "call_a", name: "read", args: "{\"p\":\"a\"} " }, + { id: "call_b", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("duplicate IDs on established indexed calls keep first-match ID fallback", async () => { + const events = await collect(sse([ + chunkOf([ + { index: 0, function: { name: "read", arguments: "{\"p\":" } }, + { index: 1, function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 0, id: "shared", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "shared", function: { arguments: "\"b\"" } }]), + chunkOf([{ id: "shared", function: { arguments: "}" } }]), + chunkOf([{ index: 1, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([ + { id: "shared", name: "read", args: "{\"p\":\"a\"}" }, + { id: "shared", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("a repeated ID on a different index does not replace the first observed alias", async () => { + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "call_a", function: { arguments: "" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: "{\"p\":\"a\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test.each([9, 10])("late index preserves a %i-byte argument limit across all fragments", async limit => { + const budget = createTestTranslatorBudget({ maxCallArgumentBytes: limit }); + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"é\"" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ]), budget); + if (limit === 9) { + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + } else { + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: "{\"p\":\"é\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + } + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: limit === 9 ? 1 : 0 }); }); }); diff --git a/tests/ci-workflows/assert-mergeable-review.test.ts b/tests/ci-workflows/assert-mergeable-review.test.ts index aba7f14fb9..1f2ca6231e 100644 --- a/tests/ci-workflows/assert-mergeable-review.test.ts +++ b/tests/ci-workflows/assert-mergeable-review.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,6 +15,7 @@ const fakeGh = `#!/usr/bin/env bash set -euo pipefail case_name="$CASE_NAME" +printf '%s\n' "$*" >> "$CASE_STATE_DIR/gh-calls" review() { local login="$1" @@ -36,6 +37,7 @@ if [ "$1" = "pr" ] && [ "$2" = "view" ]; then decision_api_failure) exit 71 ;; decision_not_approved) printf '%s\n' 'REVIEW_REQUIRED'; exit 0 ;; decision_missing) printf '%s\n' ''; exit 0 ;; + mi_*) printf '%s\n' 'REVIEW_REQUIRED'; exit 0 ;; *) printf '%s\n' 'APPROVED'; exit 0 ;; esac fi @@ -53,14 +55,125 @@ if [ "$1" = "pr" ] && [ "$2" = "view" ]; then printf '%s\n' '{"headRefOid":"OLDSHA","author":{"login":"author"},"title":"fixture"}' fi ;; + mi_noflag_strict) + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture"}' + ;; + mi_author_self) + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"lidge-jun"},"title":"fixture","baseRefName":"dev"}' + ;; + mi_base_main) + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"main"}' + ;; + mi_base_preview) + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"preview"}' + ;; + mi_base_stack) + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"codex/parent-pr"}' + ;; + mi_final_head_drift) + if [ -f "$CASE_STATE_DIR/meta-read" ]; then + printf '%s\n' '{"headRefOid":"NEWSHA","author":{"login":"author"},"title":"fixture","baseRefName":"dev"}' + else + : > "$CASE_STATE_DIR/meta-read" + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"dev"}' + fi + ;; + mi_final_base_drift) + if [ -f "$CASE_STATE_DIR/meta-read" ]; then + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"main"}' + else + : > "$CASE_STATE_DIR/meta-read" + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"dev"}' + fi + ;; + mi_final_author_drift) + if [ -f "$CASE_STATE_DIR/meta-read" ]; then + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"other-author"},"title":"fixture","baseRefName":"dev"}' + else + : > "$CASE_STATE_DIR/meta-read" + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"dev"}' + fi + ;; + mi_*) + printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture","baseRefName":"dev"}' + ;; *) printf '%s\n' '{"headRefOid":"HEADSHA","author":{"login":"author"},"title":"fixture"}' ;; esac exit 0 fi if [ "$1" = "api" ]; then + if [ "$2" = "user" ] || [ "$2" = "/user" ]; then + case "$case_name" in + mi_noflag_strict|mi_unknown_flag|mi_extra_args|mi_missing_pr) + printf 'unexpected gh invocation: %s\n' "$*" >&2 + exit 75 + ;; + mi_failed_actor) exit 76 ;; + mi_malformed_actor) printf '%s\n' 'not-json' ;; + mi_missing_actor) printf '%s\n' '{}' ;; + mi_bot_actor) printf '%s\n' '{"login":"lidge-jun","type":"Bot"}' ;; + mi_maintain_no_review) printf '%s\n' '{"login":"Ingwannu","type":"User"}' ;; + mi_outsider) printf '%s\n' '{"login":"outsider","type":"User"}' ;; + mi_final_actor_drift) + if [ -f "$CASE_STATE_DIR/user-read" ]; then + printf '%s\n' '{"login":"Ingwannu","type":"User"}' + else + : > "$CASE_STATE_DIR/user-read" + printf '%s\n' '{"login":"lidge-jun","type":"User"}' + fi + ;; + mi_*) + printf '%s\n' '{"login":"lidge-jun","type":"User"}' + ;; + *) + printf 'unexpected gh invocation: %s\n' "$*" >&2 + exit 75 + ;; + esac + exit 0 + fi + + if printf '%s\n' "$2" | grep -q '/collaborators/.*/permission'; then + case "$case_name" in + mi_noflag_strict|mi_unknown_flag|mi_extra_args|mi_missing_pr) + printf 'unexpected gh invocation: %s\n' "$*" >&2 + exit 75 + ;; + mi_failed_permission) exit 77 ;; + mi_malformed_permission) printf '%s\n' 'not-json' ;; + mi_write_role) printf '%s\n' '{"role_name":"write"}' ;; + mi_maintain_no_review) printf '%s\n' '{"role_name":"maintain"}' ;; + mi_final_permission_drift) + if [ -f "$CASE_STATE_DIR/perm-read" ]; then + printf '%s\n' '{"role_name":"write"}' + else + : > "$CASE_STATE_DIR/perm-read" + printf '%s\n' '{"role_name":"admin"}' + fi + ;; + mi_*) + printf '%s\n' '{"role_name":"admin"}' + ;; + *) + printf 'unexpected gh invocation: %s\n' "$*" >&2 + exit 75 + ;; + esac + exit 0 + fi + if printf '%s\n' "$2" | grep -q '/contents/MAINTAINERS.md'; then - if [ "$case_name" = "roster_api_failure" ]; then + case "$case_name" in + mi_noflag_strict) ;; + mi_*) + case "$2" in + *'/contents/MAINTAINERS.md?ref=dev') ;; + *) echo 'integration roster must be bound to dev' >&2; exit 78 ;; + esac + ;; + esac + if [ "$case_name" = "roster_api_failure" ] || [ "$case_name" = "mi_failed_roster" ]; then exit 73 fi if [ "$case_name" = "empty_roster" ]; then @@ -75,6 +188,19 @@ if [ "$1" = "api" ]; then printf '%b' '# Maintainers\n\n## Current maintainers\n\n| [@LIDGE-JUN](x) | owner |\n| [@Ingwannu](x) | maintainer |\n\n## Former maintainers\n' | base64 exit 0 fi + if [ "$case_name" = "mi_malformed_roster" ]; then + printf '%s\n' 'not-base64' + exit 0 + fi + if [ "$case_name" = "mi_final_roster_drift" ]; then + if [ -f "$CASE_STATE_DIR/roster-read" ]; then + printf '%b' '# Maintainers\n\n## Current maintainers\n\n## Former maintainers\n' | base64 + else + : > "$CASE_STATE_DIR/roster-read" + printf '%b' '# Maintainers\n\n## Current maintainers\n\n| [@lidge-jun](x) | owner |\n| [@Ingwannu](x) | maintainer |\n\n## Former maintainers\n' | base64 + fi + exit 0 + fi printf '%b' '# Maintainers\n\n## Current maintainers\n\n| [@lidge-jun](x) | owner |\n| [@Ingwannu](x) | maintainer |\n\n## Former maintainers\n' | base64 exit 0 fi @@ -142,6 +268,9 @@ if [ "$1" = "api" ]; then decision_not_approved|decision_missing) printf '[['; review Ingwannu APPROVED HEADSHA 2026-08-29T00:00:00Z 1; printf ']]\n' ;; + mi_outstanding_objection) + printf '[['; review Ingwannu CHANGES_REQUESTED HEADSHA 2026-08-29T00:00:00Z 1; printf ']]\n' + ;; *) printf '%s\n' '[[]]' ;; @@ -219,4 +348,73 @@ describe.skipIf(process.platform === "win32")("assert-mergeable-review", () => { } }); } + + const maintainerIntegrationCases = [ + ["mi_admin_no_review", "PASS", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_maintain_no_review", "PASS", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_author_self", "PASS", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_noflag_strict", "FAIL", ["999", "fixture/repo"]], + ["mi_write_role", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_outsider", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_bot_actor", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_malformed_actor", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_failed_actor", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_missing_actor", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_malformed_permission", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_failed_permission", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_failed_roster", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_malformed_roster", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_base_main", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_base_preview", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_base_stack", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_outstanding_objection", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_final_head_drift", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_final_base_drift", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_final_author_drift", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_final_actor_drift", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_final_permission_drift", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_final_roster_drift", "FAIL", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_option_flag_first", "PASS", ["--maintainer-integration", "999", "fixture/repo"]], + ["mi_option_flag_middle", "PASS", ["999", "--maintainer-integration", "fixture/repo"]], + ["mi_option_flag_last", "PASS", ["999", "fixture/repo", "--maintainer-integration"]], + ["mi_option_optional_repo", "PASS", ["--maintainer-integration", "999"]], + ["mi_unknown_flag", "FAIL", ["--maintainer-integration", "999", "fixture/repo", "--unknown"]], + ["mi_extra_args", "FAIL", ["--maintainer-integration", "999", "fixture/repo", "extra"]], + ["mi_missing_pr", "FAIL", ["--maintainer-integration"]], + ] as const; + + for (const [name, expected, args] of maintainerIntegrationCases) { + test(name, () => { + const caseStateDir = join(fixtureRoot, name); + mkdirSync(caseStateDir); + const result = Bun.spawnSync(["bash", gate, ...args], { + cwd: repoRoot, + env: { + ...process.env, + CASE_NAME: name, + CASE_STATE_DIR: caseStateDir, + PATH: `${mockBin}${delimiter}${process.env.PATH ?? ""}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const output = `${new TextDecoder().decode(result.stdout)}${new TextDecoder().decode(result.stderr)}`; + + if (expected === "PASS") { + expect(result.exitCode, output).toBe(0); + expect(output).toContain("validation snapshot for #999 into dev at head HEADSHA"); + expect(output).toContain("head matching does not pin the base"); + expect(output).not.toContain("gh pr merge"); + const calls = readFileSync(join(caseStateDir, "gh-calls"), "utf8").trim().split("\n"); + expect(calls.filter(call => call === "api user")).toHaveLength(2); + expect(calls.filter(call => call.includes("/contents/MAINTAINERS.md?ref=dev"))).toHaveLength(2); + const actor = name === "mi_maintain_no_review" ? "ingwannu" : "lidge-jun"; + expect(calls.filter(call => call.includes(`/collaborators/${actor}/permission`))).toHaveLength(2); + expect(calls.some(call => call.includes("reviewDecision"))).toBe(false); + expect(calls.at(-1)).toContain("--json headRefOid,baseRefName,author"); + } else { + expect(result.exitCode, output).not.toBe(0); + } + }); + } }); diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 4b83906de4..ec2c919c45 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -87,30 +87,10 @@ function expectSecureLinuxKeyringBootstrap(workflow: string): void { } describe("GitHub Actions hardening", () => { - test("repository-owned root tests always use the isolated wrapper", async () => { - const paths = [ - ".github/workflows/ci.yml", - ".github/workflows/nightly-macos.yml", - "scripts/ci/run-bun-test-batches.sh", - "scripts/release.ts", - "scripts/openai-provider-option-final-gates.ts", - ]; - const rawRootTest = /(?:^|[;&| \t])(?:bun|\$BUN_BIN) test(?:\s|$)/; - for (const path of paths) { - const text = await readText(path); - for (const line of text.split(/\r?\n/)) { - if (/^\s*(?:#|\/\/|\*|\/\*)/.test(line)) continue; - if (line.includes("cd gui") || line.includes("replit-gateway")) continue; - expect(`${path}:${line}`).not.toMatch(rawRootTest); - } - } - }); - test("cross-platform CI keeps bounded jobs and immutable action references", async () => { const workflow = await readText(".github/workflows/ci.yml"); const ci = Bun.YAML.parse(workflow) as { permissions?: Record; - concurrency?: { group?: string; "cancel-in-progress"?: string }; jobs?: Record; on?: { workflow_dispatch?: { @@ -126,38 +106,27 @@ describe("GitHub Actions hardening", () => { }; }; - expect(ci.concurrency?.group).toBe( - "cross-platform-ci-${{ github.event_name }}-${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && github.run_id || github.run_id }}", - ); - expect(ci.concurrency?.["cancel-in-progress"]).toBe( - "${{ github.event_name == 'pull_request' || github.event_name == 'push' }}", - ); - // Job-scoped: a global count still passes if values are swapped between jobs. // Pin ownership explicitly. The Windows leg is sharded like the Linux ones // since 8034cd7c0 — a single leg reached 30m on a green suite and was killed // in cleanup, so each shard now holds the same 15m a Linux shard holds. A // shard that needs longer is wedged, not slow. - expect(ci.jobs?.["select-windows-runner"]).toBeUndefined(); + expect(ci.jobs?.["select-windows-runner"]?.["timeout-minutes"]).toBe(2); expect(ci.jobs?.test?.["timeout-minutes"]).toBe(15); expect(ci.jobs?.gates?.["timeout-minutes"]).toBe(15); - expect(ci.jobs?.["platform-macos"]?.["timeout-minutes"]).toBe(30); - expect(ci.jobs?.["macos-control"]).toBeUndefined(); + expect(ci.jobs?.["platform-macos"]?.["timeout-minutes"]).toBe(20); + expect(ci.jobs?.["macos-control"]?.["timeout-minutes"]).toBe(30); // Higher than the Linux shards on purpose: at 15 the Windows leg cancelled a // shard mid-suite, which reports as neither pass nor fail (#2152). - expect(ci.jobs?.["platform-windows"]?.["timeout-minutes"]).toBe(25); + expect(ci.jobs?.["platform-windows"]?.["timeout-minutes"]).toBe(30); expect(ci.jobs?.["keyring-smoke"]?.["timeout-minutes"]).toBe(8); - // Eight minutes repeatedly cancelled the Windows global-install smoke during - // dependency installation. Retain upstream's corrected budget while keeping - // the fork's package-name-independent install command. + // Same lesson as the Windows shards above, one job later: at 8 the Windows leg + // spent ~7 minutes installing dependencies and was cancelled at the wall before + // it could pack and install. A cancellation is neither a pass nor a fail, and it + // landed on whichever step happened to be running — four times on the global + // install and once on a one-second asset check — which read as a flaky download + // rather than a budget one OS cannot meet (#3441). expect(ci.jobs?.["npm-global-smoke"]?.["timeout-minutes"]).toBe(20); - // The packed tarball name follows package.json (`yansigit-opencodex-*.tgz` on - // this fork). Installing a hardcoded upstream filename makes the smoke fail - // after `npm pack` with ENOENT. - expect(workflow).toContain( - `npm install -g "./$(node -p "require('./pack.json')[0].filename")"`, - ); - expect(workflow).not.toContain("bitkyc08-opencodex-*.tgz"); expect(ci.jobs?.ci?.["timeout-minutes"]).toBe(5); expect(ci.permissions).toEqual({ contents: "read" }); @@ -193,7 +162,7 @@ describe("GitHub Actions hardening", () => { expect(await readText(".github/actions/setup-project-bun/action.yml")) .toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"); - expect(workflow).toContain("bun scripts/test.ts --isolate"); + expect(workflow).toContain("bun test --isolate tests"); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); // Sharding is only safe while the shards tile the suite exactly. If the @@ -215,29 +184,26 @@ describe("GitHub Actions hardening", () => { // how the first cut of that test shipped, so pin the flag rather than trusting a // comment. Asserted per job so a future edit cannot drop it from one leg while // the other still carries it. - for (const jobName of ["test", "platform-macos", "platform-windows"]) { + for (const jobName of ["test", "platform-macos", "macos-control", "platform-windows"]) { const steps = (ci.jobs?.[jobName] as { steps?: Array<{ uses?: string; with?: Record }> })?.steps ?? []; const checkout = steps.find(step => typeof step.uses === "string" && step.uses.includes("actions/checkout")); expect(`${jobName}:${String(checkout?.with?.["fetch-tags"])}`).toBe(`${jobName}:true`); } - // Windows uses more, smaller shards because its process-heavy suite and NTFS - // filesystem work repeatedly exhausted four hosted runners under load. Keep - // its matrix contiguous and bind the lane selector divisor to that matrix so - // load reduction cannot silently drop part of the suite. + // Windows shards more finely than Linux: the same suite takes 17-25 minutes per + // quarter on windows-latest, which is the leg's own 25-minute ceiling (run + // 33934756997 cancelled a green 3/4 at 25m12s). The invariant that matters is the + // one above — the matrix and the divisor tile the suite exactly — so pin the + // Windows matrix to its own divisor rather than to Linux's, and pin it to be + // contiguous from 1 so a dropped entry cannot leave a slice of the suite unrun. const windowsShards = (ci.jobs?.["platform-windows"] as { strategy?: { matrix?: { shard?: number[] } }; })?.strategy?.matrix?.shard ?? []; expect(windowsShards).toEqual([1, 2, 3, 4, 5, 6]); - expect(windowsShards).toEqual(windowsShards.map((_, index) => index + 1)); - const windowsSteps = (ci.jobs?.["platform-windows"] as { - steps?: Array<{ run?: string }>; - })?.steps ?? []; - expect(windowsSteps.some(step => - step.run?.includes(`--shard \${{ matrix.shard }}/${windowsShards.length}`), - )).toBe(true); - expect(ci.jobs?.["platform-windows"]?.name) - .toBe(`windows \${{ matrix.shard }}/${windowsShards.length}`); + expect(windowsShards).toEqual(windowsShards.map((_, i) => i + 1)); + const windowsSteps = (ci.jobs?.["platform-windows"] as { steps?: Array<{ run?: string }> })?.steps ?? []; + expect(windowsSteps.some(step => step.run?.includes(`--shard=\${{ matrix.shard }}/${windowsShards.length}`))).toBe(true); + expect(ci.jobs?.["platform-windows"]?.name).toBe(`windows \${{ matrix.shard }}/${windowsShards.length}`); // The aggregate gate is the check a human trusts. Three ways to break it // silently: drop `if: always()` so it skips (and a skipped job reports @@ -249,9 +215,6 @@ describe("GitHub Actions hardening", () => { const ungated = new Set(["ci"]); expect([...(gate?.needs ?? [])].sort()) .toEqual(Object.keys(ci.jobs ?? {}).filter(name => !ungated.has(name)).sort()); - expect(workflow).toContain("WINDOWS_REQUIRED: ${{ (github.event_name != 'pull_request' && github.event_name != 'merge_group') || needs.changes.outputs.ci == 'true' }}"); - expect(workflow).toContain("WINDOWS_RESULT: ${{ needs.platform-windows.result }}"); - expect(workflow).toContain('if [ "$WINDOWS_REQUIRED" = "true" ] && [ "$WINDOWS_RESULT" != "success" ]; then'); // The focused doctor contract config is ADDITIVE evidence. It must never // replace the repository-wide strict typecheck: doing so made the aggregate @@ -275,49 +238,23 @@ describe("GitHub Actions hardening", () => { expect(hasExactShellCommand(gatesGuiRun, "cd gui && bun test --isolate tests")).toBe(true); expect(hasExactShellCommand(gatesGuiRun, "cd gui && bun test tests")).toBe(false); - // macOS is focused on dev and relevant PRs; main/preview keep full control. - const macosSteps = (ci.jobs?.["platform-macos"] as { - steps?: { name?: string; if?: string; run?: string }[]; - })?.steps ?? []; + // macOS shards cover every CI-relevant change. They may skip + // only when the shared path filter says the entire expensive suite is out of + // scope (for example a docs-site-only PR). + const macosSteps = (ci.jobs?.["platform-macos"] as { steps?: { name?: string; env?: Record; run?: string }[] })?.steps ?? []; // The 60s per-test ceiling is part of the pinned shape: dropping it silently // restores the timing-flake class this lane kept surfacing. - expect(macosSteps.some(step => step.run?.includes("run-bun-with-crash-retry.sh"))).toBe(true); - expect(macosSteps.some(step => step.run?.includes("--shard"))).toBe(false); - const focusedMacos = macosSteps.find(step => step.name === "Focused Darwin/process lifecycle tests"); - expect(focusedMacos?.run).toContain("tests/codex-integration/codex-prompt-text-probe.test.ts"); - const fullMacos = macosSteps.find(step => step.name === "Full macOS suite"); - expect(fullMacos?.if).toContain("github.event_name == 'pull_request' && github.base_ref == 'main'"); - const pathPolicy = Bun.YAML.parse(await readText(".github/policies/ci-paths.yml")) as { - macos?: string[]; - swift?: string[]; - }; - expect(pathPolicy.macos).toEqual([ - ".github/workflows/**", - ".github/actions/**", - ".github/policies/**", - "scripts/ci/**", - "scripts/test.ts", - "src/adapters/cursor/native-exec-shell.ts", - "src/lib/machine-test-lock.ts", - "src/oauth/**", - "src/service.ts", - "integrations/aistudio-daemon/**", - "tests/adapters/google/aistudio-native-webkit.test.ts", - "tests/codex-integration/codex-app-server-processes.test.ts", - "tests/codex-integration/codex-prompt-text-probe.test.ts", - "tests/providers/cursor/cursor-native-exec-shell.test.ts", - "tests/preload.ts", - "tests/service/process-state.test.ts", - "tests/lib/process-control*.test.ts", - "tests/service/service*.test.ts", - "tests/storage/storage-worker*.test.ts", - "tests/ci-workflows/test-runner.test.ts", - ]); - expect(pathPolicy.swift).toEqual([ - "integrations/aistudio-daemon/**", - "tests/adapters/google/aistudio-native-webkit.test.ts", - "src/oauth/aistudio-native-daemon.ts", - ]); + const macosTestStep = macosSteps.find(step => step.name === "Test"); + expect(macosTestStep?.env?.MACOS_TEST_SHARD).toBe("${{ matrix.shard }}"); + expect(hasShellCommandHead(macosTestStep?.run, 'bun test --isolate --timeout 60000 "$@"')).toBe(true); + expect(hasExactShellCommand(macosTestStep?.run, 'run_macos_suite tests "--shard=$MACOS_TEST_SHARD/2" "${ignore_args[@]}"')).toBe(true); + expect(hasExactShellCommand(macosTestStep?.run, 'run_macos_suite --parallel=1 "./tests/$file"')).toBe(true); + expect(macosTestStep?.run).toContain('import { SERIAL_FULL_SUITE_FILES } from "./scripts/test.ts"'); + const macosShards = (ci.jobs?.["platform-macos"] as { + strategy?: { "fail-fast"?: boolean; matrix?: { shard?: number[] } }; + })?.strategy; + expect(macosShards?.["fail-fast"]).toBe(false); + expect(macosShards?.matrix?.shard).toEqual([1, 2]); // The macOS leg retries ONLY a Bun runtime crash, and only once. Bun 1.3.14 // segfaults reclaiming a Worker at an `--isolate` file boundary with @@ -326,46 +263,84 @@ describe("GitHub Actions hardening", () => { // `scripts/ci/run-bun-test-batches.sh`. Two ways to break this silently: // drop the crash-signature guard so an assertion failure gets retried into // green, or let the retry loop swallow a repeated crash. Pin both. - const macosTestRun = macosSteps.find(step => step.run?.includes("run-bun-with-crash-retry.sh"))?.run ?? ""; - expect(macosTestRun).toContain("bun scripts/test.ts --isolate --timeout 60000"); - const crashRetry = await readText("scripts/ci/run-bun-with-crash-retry.sh"); - expect(crashRetry).toContain("for attempt in 1 2"); - expect(crashRetry).toContain("assertion failures are not retried"); - expect(crashRetry).toContain("failing after one retry"); - expect(crashRetry).not.toContain("while true"); + const macosTestRun = macosTestStep?.run ?? ""; + // Actions invokes multiline `run:` blocks with `bash -e`. The retry loop + // must disable errexit before the crash-prone command or exit 133 aborts + // the step before PIPESTATUS can be inspected and the retry can run. + expect(hasExactShellCommand(macosTestRun, "set +e")).toBe(true); + expect(macosTestRun).toContain("Segmentation fault at address"); + expect(macosTestRun).toContain("oh no: Bun has crashed"); + expect(macosTestRun).toContain("assertion failures are not retried"); + expect(macosTestRun).toContain("failing after one retry"); + // `for attempt in 1 2` — one retry, never an unbounded loop. + expect(macosTestRun).toContain("for attempt in 1 2"); + expect(macosTestRun).not.toContain("while true"); expect((ci.jobs?.["platform-macos"] as { needs?: string; if?: string })?.needs).toBe("changes"); expect((ci.jobs?.["platform-macos"] as { if?: string })?.if) - .toBe("(github.event_name != 'pull_request' && github.event_name != 'merge_group') || (github.event_name == 'pull_request' && github.base_ref == 'main') || (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/main') || needs.changes.outputs.macos == 'true'"); - - // Windows is required for every integration push and CI-relevant PR. The - // changes dependency keeps documentation-only PRs cheap without letting a - // code change or shipping-boundary push silently lose Windows coverage. - const windowsJob = ci.jobs?.["platform-windows"] as { if?: string; needs?: string[]; "runs-on"?: string } | undefined; - expect(windowsJob?.needs).toEqual(["changes"]); - expect(windowsJob?.["runs-on"]).toBe("windows-latest"); - expect(windowsJob?.if) - .toBe("(github.event_name != 'pull_request' && github.event_name != 'merge_group') || needs.changes.outputs.ci == 'true'"); - - // Windows runs the same suite, sharded like the Linux legs, on an ephemeral - // GitHub-hosted runner. Public pull-request code must never reach a - // persistent self-hosted host. - const winSteps = (ci.jobs?.["platform-windows"] as { - steps?: { name?: string; if?: string; run?: string; env?: Record }[]; - })?.steps ?? []; - const windowsSmokeIndex = winSteps.findIndex(step => step.name === "CLI help smoke"); - const windowsTestIndex = winSteps.findIndex(step => step.name === "Test"); - expect(windowsSmokeIndex).toBeGreaterThanOrEqual(0); - expect(windowsTestIndex).toBeGreaterThan(windowsSmokeIndex); - expect(winSteps[windowsSmokeIndex]?.if).toBeUndefined(); - expect(hasExactShellCommand( - winSteps[windowsSmokeIndex]?.run, - "bun run src/cli/index.ts help", - )).toBe(true); + .toBe("github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"); + + // Whole-pool control lives on dispatch so every push does not pay the + // unsharded macOS critical path. Keep the unsharded bun test line and the + // 30-minute budget; do not sneak a shard divisor into this job. + const macosControlJob = ci.jobs?.["macos-control"] as { + name?: string; + needs?: string; + if?: string; + "runs-on"?: string; + "timeout-minutes"?: number; + strategy?: unknown; + steps?: { run?: string }[]; + } | undefined; + expect(macosControlJob?.name).toBe("macos control"); + expect(macosControlJob?.needs).toBe("changes"); + expect(macosControlJob?.if).toBe("github.event_name == 'workflow_dispatch'"); + expect(macosControlJob?.["runs-on"]).toBe("macos-latest"); + expect(macosControlJob?.strategy).toBeUndefined(); + const macosControlSteps = macosControlJob?.steps ?? []; + expect(macosControlSteps.some(step => step.run?.includes("bun test --isolate --timeout 60000 tests"))).toBe(true); + expect(macosControlSteps.some(step => step.run?.includes("--shard"))).toBe(false); + const macosControlTestRun = macosControlSteps.find(step => step.run?.includes("bun test --isolate --timeout 60000 tests"))?.run ?? ""; + expect(hasExactShellCommand(macosControlTestRun, "set +e")).toBe(true); + expect(macosControlTestRun).toContain("for attempt in 1 2"); + expect(macosControlTestRun).not.toContain("while true"); + expect(macosControlTestRun).toContain("assertion failures are not retried"); + expect(macosControlTestRun).toContain("failing after one retry"); + + // Windows is dispatch-only: it gates nothing, not even the shipping + // boundary. The sharded promotion run surfaced ~207 Windows-only failures + // that pre-date every released version, so the leg became a measurement + // tool a maintainer runs by hand, not a gate. Assert the positive + // condition and the absence of every automatic trigger — a stray + // `|| github.ref == ...` would restore a red leg to the release path. + const windowsIf = String((ci.jobs?.["platform-windows"] as { if?: string })?.if ?? ""); + expect(windowsIf).toBe( + "github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')", + ); + expect(windowsIf).not.toContain("refs/heads/main"); + expect(windowsIf).not.toContain("refs/heads/preview"); + expect(windowsIf).not.toContain("refs/heads/dev"); + expect(windowsIf).not.toContain("pull_request"); + + // A lane=macos-control dispatch must skip Windows so a red Windows burn-down + // cannot fail the unsharded macOS control run. A plain dispatch still runs + // everything, including Windows, which is what empty-or-all encodes. + expect(ci.on?.workflow_dispatch?.inputs?.lane).toEqual({ + description: "all (default) or macos-control", + type: "choice", + default: "all", + options: ["all", "macos-control"], + }); + + // Windows runs the same suite, sharded like the Linux legs, and keeps the + // self-hosted workspace wipe. Without the wipe a deleted file survives on + // the runner's disk and the suite passes against a tree that no longer + // exists in git. + const winSteps = (ci.jobs?.["platform-windows"] as { steps?: { if?: string; run?: string }[] })?.steps ?? []; // --timeout is part of the contract, not incidental: this leg ran on Bun's 5s default // while Linux and macOS both pass 60000, and it is the slowest hardware on the board. // Three composed-acceptance failures were that default firing on tests still working // at 41s. Pin the flag so the leg cannot silently drift back to the default. - const windowsTestCommand = 'bun scripts/test.ts --isolate --timeout 60000 "${general_files[@]}"'; + const windowsTestCommand = `bun test --isolate --timeout 60000 tests --shard=\${{ matrix.shard }}/${windowsShards.length}`; expect(hasShellCommandHead(`echo ${windowsTestCommand}`, windowsTestCommand)).toBe(false); // Binding the assertion to an executable line is only half the guarantee: a // step carrying the exact command still runs nothing under `if: false`, and @@ -374,25 +349,8 @@ describe("GitHub Actions hardening", () => { const windowsTestSteps = winSteps.filter(step => hasShellCommandHead(step.run, windowsTestCommand)); expect(windowsTestSteps.length).toBeGreaterThan(0); expect(windowsTestSteps.every(step => step.if === undefined)).toBe(true); - // The hosted Windows shard owns an ephemeral VM and invokes every wrapper - // sequentially in this one shell step. Requiring the local multi-worktree - // queue there adds a pre-test PowerShell SID/known-folder dependency without - // preventing any possible overlap; run 34000228308 lost the entire shard when - // that bounded identity lookup timed out before Bun started a test. - expect(windowsTestSteps.every(step => step.env?.OCX_TEST_NO_QUEUE === "1")).toBe(true); - const windowsTestRun = windowsTestSteps[0]?.run ?? ""; - expect(windowsTestRun).toContain('scripts/ci/test-lanes.ts --lane general'); - expect(windowsTestRun).toContain('scripts/ci/test-lanes.ts --lane serial'); - expect(windowsTestRun).toContain('Windows lane selection returned an empty shard'); - expect(windowsTestRun).not.toContain('mapfile -t general_files < <('); - expect(windowsTestRun).toContain('for serial_file in "${serial_files[@]}"'); - expect(windowsTestRun).toContain('serial_timeout=60000'); - expect(windowsTestRun).toContain('serial_timeout=300000'); - expect(windowsTestRun).toContain('[[ "$serial_file" == "tests/codex-integration/codex-composed-acceptance.test.ts" ]]'); - expect(windowsTestRun).toContain('--parallel=1 --timeout "$serial_timeout" "$serial_file"'); - expect(windowsTestRun).not.toContain('--parallel=1 --timeout 60000 "${serial_files[@]}"'); - expect(windowsTestRun).not.toContain(`tests --shard=\${{ matrix.shard }}/${windowsShards.length}`); - expect(workflow).not.toContain("ocx-home"); + expect(winSteps.some(step => step.if === "runner.environment == 'self-hosted'" + && step.run?.includes("git clean -xffd"))).toBe(true); // The three crash-signature lists must stay identical, and they must not key on // `panic(thread`. @@ -410,15 +368,17 @@ describe("GitHub Actions hardening", () => { "Illegal instruction", "Bus error", ]; + const windowsTestRun = windowsTestSteps[0]?.run ?? ""; const batchScript = await readText("scripts/ci/run-bun-test-batches.sh"); - const crashRetryScript = await readText("scripts/ci/run-bun-with-crash-retry.sh"); for (const signature of crashSignatures) { - expect(`macos:${signature}:${crashRetryScript.includes(signature)}`).toBe(`macos:${signature}:true`); + expect(`macos:${signature}:${macosTestRun.includes(signature)}`).toBe(`macos:${signature}:true`); + expect(`macos-control:${signature}:${macosControlTestRun.includes(signature)}`).toBe(`macos-control:${signature}:true`); expect(`windows:${signature}:${windowsTestRun.includes(signature)}`).toBe(`windows:${signature}:true`); expect(`script:${signature}:${batchScript.includes(signature)}`).toBe(`script:${signature}:true`); } // The thread-numbered form must not be the anchor anywhere. - expect(crashRetryScript).not.toContain("panic\\(thread"); + expect(macosTestRun).not.toContain("panic\\(thread"); + expect(macosControlTestRun).not.toContain("panic\\(thread"); expect(windowsTestRun).not.toContain("panic\\(thread"); expect(batchScript).not.toContain("panic\\(thread"); @@ -436,14 +396,16 @@ describe("GitHub Actions hardening", () => { // accident, because the same job also ran the GUI build — splitting the suite // away from the gates removed that coincidence, and the shards went red on a // pull request before this pin existed. - for (const jobName of ["test", "platform-macos", "platform-windows"]) { + for (const jobName of ["test", "platform-macos", "macos-control", "platform-windows"]) { const steps = (ci.jobs?.[jobName] as { steps?: { if?: string; run?: string }[] })?.steps ?? []; const build = steps.find(step => step.run?.includes("bun run build")); expect(`${jobName}:${build === undefined}`).toBe(`${jobName}:false`); expect(`${jobName}:${build?.if ?? "unconditional"}`).toBe(`${jobName}:unconditional`); } - // No job in this workflow pushes, so a persisted token is avoidable residue. + // No job in this workflow pushes, and the self-hosted runner keeps its + // checkout between jobs, so a persisted token is avoidable residue. The + // other workflows in this repository already set this; ci.yml was the gap. const checkouts = Object.values(ci.jobs ?? {}) .flatMap(job => (job as { steps?: { uses?: string; with?: Record }[] })?.steps ?? []) .filter(step => step.uses?.startsWith("actions/checkout@")); @@ -452,6 +414,13 @@ describe("GitHub Actions hardening", () => { expect(`checkout[${index}]:${step.with?.["persist-credentials"]}`).toBe(`checkout[${index}]:false`); } + // The self-hosted workspace wipe must not swallow its own failure. A clean + // that fails on permissions leaves deleted files on disk, and the checkout + // after it then validates a tree that no longer exists in git. + const wipe = ((ci.jobs?.["platform-windows"] as { steps?: { if?: string; run?: string }[] })?.steps ?? []) + .find(step => step.run?.includes("git clean -xffd")); + expect(wipe?.run).not.toContain("|| true"); + expect(wipe?.run).toContain("git rev-parse --is-inside-work-tree"); }); test("PR checks reach every branch the target gate accepts", async () => { @@ -466,12 +435,10 @@ describe("GitHub Actions hardening", () => { // `ci.yml` therefore carries no base filter at all. `service-lifecycle.yml` // keeps its list: it gates the release service path, not review. const gate = await readText(".github/workflows/enforce-pr-target.yml"); - const allowed = gate.match( - /const ALLOWED_BASES\s*=\s*context\.repo\.owner === "lidge-jun"\s*\? \["dev"\]\s*:\s*\["dev", "main"\]/, - ); + const allowed = gate.match(/const ALLOWED_BASES = \[([^\]]*)\];/); expect(allowed).not.toBeNull(); - expect(allowed?.[0]).toContain('["dev"]'); - expect(allowed?.[0]).toContain('["dev", "main"]'); + const bases = [...(allowed?.[1] ?? "").matchAll(/"([^"]+)"/g)].map(m => m[1]); + expect(bases).toEqual(["dev"]); // The gate itself must stay unfiltered by base, or the stacked exemption it // implements would never be evaluated for the branches it exempts. @@ -505,20 +472,18 @@ describe("GitHub Actions hardening", () => { } } - // Every integration head needs exact push-CI evidence. In particular, a - // tree-preserving main backmerge changes no path but promotion still gates - // on a successful Cross-platform CI push run for that exact dev SHA. + // The push trigger stays pinned to the release-relevant lines: release.yml + // gates on main and preview, so widening this one would pull an unrelated + // branch into that path. const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { on?: { push?: { branches?: string[]; paths?: string[] }; pull_request?: { branches?: string[]; paths?: string[] }; - merge_group?: { types?: string[]; branches?: string[]; paths?: string[] }; }; jobs?: Record | undefined>; }; expect([...(ci.on?.push?.branches ?? [])].sort()) .toEqual(["dev", "main", "preview"]); - expect(ci.on?.push?.paths).toBeUndefined(); // The PR trigger must carry NO base-branch filter, and the two triggers // differ on purpose. GitHub matches `branches:` against the BASE ref, so @@ -536,16 +501,16 @@ describe("GitHub Actions hardening", () => { // filter: every head needs an aggregate `ci` check. expect(ci.on?.pull_request?.branches).toBeUndefined(); expect(ci.on?.pull_request?.paths).toBeUndefined(); - expect(ci.on?.merge_group).toEqual({ types: ["checks_requested"] }); - // The `changes` job owns the one expensive-CI allowlist for both events. - // Every head gets the workflow and aggregate check; this list decides - // whether the costly jobs run. + // The push trigger and pull-request `changes` job share one expensive-CI + // allowlist. PRs always create the workflow and aggregate check; this list + // decides whether the costly jobs run. Pin the entire list on both paths. const ciPaths = [ ".gitattributes", - ".github/actions/**", - ".github/policies/**", - ".github/workflows/**", + ".github/workflows/ci.yml", + ".github/workflows/enforce-pr-target.yml", + ".github/workflows/release.yml", + ".github/workflows/stale-needs-info.yml", ".npmignore", "LICENSE", "README.md", @@ -553,26 +518,21 @@ describe("GitHub Actions hardening", () => { "bin/**", "bun.lock", "gui/**", - "integrations/replit-gateway/**", "package.json", "scripts/**", "src/**", "tests/**", "tsconfig.json", ]; + expect([...(ci.on?.push?.paths ?? [])].sort()).toEqual(ciPaths); + const filterStep = (ci.jobs?.changes as { steps?: { with?: Record }[]; })?.steps?.find(step => step.with?.filters); - expect(filterStep?.with?.filters).toBe(".github/policies/ci-paths.yml"); - const areaFilters = Bun.YAML.parse(await readText(".github/policies/ci-paths.yml")) as { + const areaFilters = Bun.YAML.parse(String(filterStep?.with?.filters ?? "")) as { ci?: string[]; - dependencies?: string[]; }; expect([...(areaFilters.ci ?? [])].sort()).toEqual(ciPaths); - expect([...(areaFilters.dependencies ?? [])].sort()).toEqual([ - "bun.lock", - "gui/bun.lock", - ]); const changesJob = ci.jobs?.changes as { outputs?: Record; @@ -589,96 +549,28 @@ describe("GitHub Actions hardening", () => { step => step.name === "Assert the scope output is usable", ); expect(changesJob?.outputs?.ci).toBe("${{ steps.scope.outputs.ci }}"); - expect(changesJob?.outputs?.dependencies).toBe( - "${{ steps.scope.outputs.dependencies }}", - ); - expect(changesJob?.outputs?.reuse_dependency_audit).toBe( - "${{ steps.promotion-audit.outputs.reuse }}", - ); expect(scopeStep?.id).toBe("scope"); expect(scopeStep?.shell).toBe("bash"); - expect(scopeStep?.env?.EVENT_NAME).toBe("${{ github.event_name }}"); - expect(scopeStep?.env?.NORMAL_CI_SCOPE).toBe("${{ steps.filter.outputs.ci }}"); - expect(scopeStep?.env?.NORMAL_DEPENDENCIES_SCOPE).toBe( - "${{ steps.filter.outputs.dependencies }}", - ); - expect(scopeStep?.env?.MERGE_GROUP_CI_SCOPE).toBe( - "${{ steps.merge-group-filter.outputs.ci }}", - ); - expect(scopeStep?.env?.MERGE_GROUP_DEPENDENCIES_SCOPE).toBe( - "${{ steps.merge-group-filter.outputs.dependencies }}", - ); + expect(scopeStep?.env?.CI_SCOPE).toBe("${{ steps.filter.outputs.ci }}"); expect(scopeStep?.run).not.toContain("${{"); - expect(scopeStep?.run).toContain('case "$value" in'); + expect(scopeStep?.run).toContain('case "$CI_SCOPE" in'); expect(scopeStep?.run).toContain("true|false)"); - expect(scopeStep?.run).toContain('[ "$EVENT_NAME" = merge_group ] && prefix=MERGE_GROUP'); - expect(scopeStep?.run).toContain('value="${!name-}"'); - expect(scopeStep?.run).toContain('printf \'%s=%s\\n\' "$scope" "$value" >> "$GITHUB_OUTPUT"'); + expect(scopeStep?.run).toContain(`printf 'ci=%s\\n' "$CI_SCOPE" >> "$GITHUB_OUTPUT"`); expect(scopeStep?.run).toContain("exit 1"); const filterIndex = changesJob?.steps?.findIndex(step => step.id === "filter") ?? -1; const scopeIndex = changesJob?.steps?.findIndex(step => step.id === "scope") ?? -1; expect(filterIndex).toBeGreaterThanOrEqual(0); expect(scopeIndex).toBeGreaterThan(filterIndex); - const promotionAuditStep = changesJob?.steps?.find( - step => step.name === "Verify reusable promotion audit evidence", - ); - expect(promotionAuditStep?.id).toBe("promotion-audit"); - expect(promotionAuditStep?.env?.BEFORE_SHA).toBe("${{ github.event.before }}"); - expect(promotionAuditStep?.env?.DEPENDENCIES_CHANGED).toBe( - "${{ steps.scope.outputs.dependencies }}", - ); - expect(promotionAuditStep?.run).toBe( - "node .github/scripts/promotion-audit-reuse.cjs", - ); - expect(promotionAuditStep?.if).toBe( - "github.event_name == 'push' && github.ref == 'refs/heads/main'", - ); - expect(promotionAuditStep?.env?.GITHUB_TOKEN).toBe("${{ github.token }}"); - - expect((ci.jobs?.changes as { permissions?: Record })?.permissions) - .toEqual({ actions: "read", contents: "read", "pull-requests": "read" }); - - const gatesJob = ci.jobs?.gates as { - steps?: Array<{ name?: string; if?: string; run?: string }>; - } | undefined; - const auditStep = gatesJob?.steps?.find( - step => step.name === "Dependency audit (high severity)", - ); - expect((auditStep as { id?: string } | undefined)?.id).toBe("dependency-audit"); - expect(auditStep?.if).toBe( - "needs.changes.outputs.dependencies == 'true' && needs.changes.outputs.reuse_dependency_audit != 'true'", - ); - expect(auditStep?.run).toBe("bun run audit:high"); - const auditProofStep = gatesJob?.steps?.find( - step => step.name === "Create dependency audit proof", - ); - expect((auditProofStep as { id?: string } | undefined)?.id).toBe("dependency-audit-proof"); - expect(auditProofStep?.if).toBe( - "github.event_name == 'pull_request' && steps.dependency-audit.outcome == 'success'", - ); - expect(auditProofStep?.run).toContain("git rev-parse 'HEAD^{tree}'"); - expect(auditProofStep?.run).toContain("dependency-audit-pr-${PR_NUMBER}-base-${BASE_SHA}-head-${HEAD_SHA}-tree-${audited_tree}"); - const publishProofStep = gatesJob?.steps?.find( - step => step.name === "Publish dependency audit proof", - ) as { "continue-on-error"?: boolean; if?: string; uses?: string; with?: Record } | undefined; - expect(publishProofStep?.if).toBe("steps.dependency-audit-proof.outcome == 'success'"); - expect(publishProofStep?.["continue-on-error"]).toBe(true); - expect(publishProofStep?.uses).toBe( - "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", - ); - expect(publishProofStep?.with?.name).toBe("${{ steps.dependency-audit-proof.outputs.name }}"); - expect(publishProofStep?.with?.["if-no-files-found"]).toBe("error"); - - const scopedCondition = "(github.event_name != 'pull_request' && github.event_name != 'merge_group') || needs.changes.outputs.ci == 'true'"; - for (const jobName of ["test", "storage-policy", "gates", "keyring-smoke"]) { + const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; + for (const jobName of ["test", "storage-policy", "gates", "platform-macos", "keyring-smoke"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); } - const macosJob = ci.jobs?.["platform-macos"] as { needs?: string; if?: string } | undefined; - expect(`platform-macos:${macosJob?.needs}`).toBe("platform-macos:changes"); - expect(`platform-macos:${macosJob?.if}`).toBe("platform-macos:(github.event_name != 'pull_request' && github.event_name != 'merge_group') || (github.event_name == 'pull_request' && github.base_ref == 'main') || (github.event_name == 'merge_group' && github.event.merge_group.base_ref == 'refs/heads/main') || needs.changes.outputs.macos == 'true'"); + const macosControlIf = ci.jobs?.["macos-control"] as { needs?: string; if?: string } | undefined; + expect(macosControlIf?.needs).toBe("changes"); + expect(macosControlIf?.if).toBe("github.event_name == 'workflow_dispatch'"); }); test("cross-platform CI keeps the GUI lint and build gates", async () => { @@ -716,29 +608,21 @@ describe("GitHub Actions hardening", () => { // promotion still reads as changed and the scoped jobs run anyway — the // filter would look correct, stay green, and save nothing. expect(filterStep?.with?.base).toBe("${{ github.ref }}"); - expect(filterStep?.with?.ref).toBeUndefined(); - const mergeFilterStep = (ci.jobs?.changes as { - steps?: { id?: string; if?: string; with?: Record }[]; - })?.steps?.find(step => step.id === "merge-group-filter"); - expect(mergeFilterStep?.if).toBe("github.event_name == 'merge_group'"); - expect(mergeFilterStep?.with?.base).toBe("${{ github.event.merge_group.base_sha }}"); - expect(mergeFilterStep?.with?.ref).toBe("${{ github.event.merge_group.head_sha }}"); - expect(mergeFilterStep?.with?.filters).toBe(".github/policies/ci-paths.yml"); // paths-filter cannot read a PR's file list without this, and a filter that // errors produces empty outputs — which every `== 'true'` condition reads as // "skip". The scoped jobs would silently stop running. expect((ci.jobs?.changes as { permissions?: Record })?.permissions) - .toEqual({ actions: "read", contents: "read", "pull-requests": "read" }); + .toEqual({ contents: "read", "pull-requests": "read" }); // Whole-list comparison, not samples. Every entry is an input to the // published tarball; dropping one silently stops packaging verification for // that surface. `src/**` is the load-bearing one: it keeps a source-only PR // running the Windows smoke jobs (keyring, npm-global) now that the full // Windows suite runs only on manual dispatch. - const filters = await readText(".github/policies/ci-paths.yml"); - const parsedFilters = Bun.YAML.parse(filters) as Record; - const packaging = [...(parsedFilters.packaging ?? [])].sort(); + const filters = String(filterStep?.with?.filters ?? ""); + const packagingBlock = filters.split(/\n\s*packaging:\s*\n/)[1] ?? ""; + const packaging = [...packagingBlock.matchAll(/-\s*'([^']+)'/g)].map(match => match[1]).sort(); expect(packaging).toEqual([ ".npmignore", ".gitattributes", @@ -756,23 +640,13 @@ describe("GitHub Actions hardening", () => { // Every packaging pattern that names a real path must also appear in the // shared expensive-CI filter. Otherwise the workflow records a cheap green // aggregate while silently skipping the packaging verification. - const ciPatterns = parsedFilters.ci ?? []; + const ciPatterns = (Bun.YAML.parse(filters) as { ci?: string[] }).ci ?? []; for (const pattern of packaging) { if (pattern === "scripts/prepare-package.ts") continue; // covered by scripts/** expect(`${pattern}:${ciPatterns.includes(pattern)}`).toBe(`${pattern}:true`); } }); - test("cross-platform CI enforces the checked-in workflow policy", async () => { - const workflow = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { - jobs?: Record }>; - }; - const policyStep = workflow.jobs?.gates?.steps?.find( - step => step.name === "Validate repository workflow policy", - ); - expect(policyStep?.run).toBe("bun scripts/ci/check-workflow-policy.ts"); - }); - test("stale needs-info workflow is schedule-only and least-privilege", async () => { const text = await readText(".github/workflows/stale-needs-info.yml"); const workflow = Bun.YAML.parse(text) as { @@ -843,17 +717,19 @@ describe("GitHub Actions hardening", () => { expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); }); - test("dev version bump stays a dormant callable fallback with a bounded repair", async () => { + test("dev version bump is a default-ref pre-move opener with one normalized target", async () => { const text = await readText(".github/workflows/dev-version-bump.yml"); const workflow = Bun.YAML.parse(text) as { on?: { - workflow_call?: { + workflow_dispatch?: { inputs?: Record; }; - workflow_dispatch?: unknown; + workflow_call?: unknown; }; jobs?: { "open-bump-pr"?: { @@ -868,34 +744,64 @@ describe("GitHub Actions hardening", () => { }; }; - expect(workflow.on?.workflow_dispatch).toBeUndefined(); - expect(Object.keys(workflow.on ?? {})).toEqual(["workflow_call"]); - const inputs = workflow.on?.workflow_call?.inputs ?? {}; - expect(inputs["released-version"]).toMatchObject({ required: true, type: "string" }); + expect(workflow.on?.workflow_call).toBeUndefined(); + expect(Object.keys(workflow.on ?? {})).toEqual(["workflow_dispatch"]); + const inputs = workflow.on?.workflow_dispatch?.inputs ?? {}; + expect(inputs["intended-version"]).toMatchObject({ required: true, type: "string" }); + expect(inputs.mode).toMatchObject({ + required: false, + default: "pre-move", + type: "choice", + options: ["pre-move", "repair"], + }); const steps = workflow.jobs?.["open-bump-pr"]?.steps ?? []; + const refGuard = steps.find(step => step.name === "Refuse a dispatch from a non-default ref"); + expect(refGuard?.run).toContain( + 'test "$GITHUB_REF" = "refs/heads/${{ github.event.repository.default_branch }}"', + ); + + const target = steps.find(step => step.name === "Resolve the target version"); const decision = steps.find(step => step.name === "Decide the version dev should carry"); + const targetFreeness = steps.find( + step => step.name === "Prove the intended version is not already released", + ); const chosenFreeness = steps.find(step => step.name === "Prove the chosen version is unused"); const openPr = steps.find(step => step.name === "Open the bump pull request"); - expect(decision?.env?.RELEASED_VERSION).toBe("${{ inputs.released-version }}"); + expect(target?.id).toBe("target"); + expect(target?.env).toEqual({ + INTENDED: "${{ inputs.intended-version }}", + MODE: "${{ inputs.mode }}", + }); + expect(target?.run).toContain('echo "version=${target}" >> "$GITHUB_OUTPUT"'); + expect(target?.run).toContain('echo "mode=repair" >> "$GITHUB_OUTPUT"'); + expect(target?.run).toContain('echo "mode=pre-move" >> "$GITHUB_OUTPUT"'); + expect(text.indexOf("- name: Resolve the target version")).toBeLessThan( + text.indexOf("- name: Decide the version dev should carry"), + ); + + expect(decision?.env?.RELEASED_VERSION).toBe("${{ steps.target.outputs.version }}"); + expect(targetFreeness?.if).toBe("${{ steps.target.outputs.mode == 'pre-move' }}"); + expect(targetFreeness?.env?.INTENDED).toBe("${{ steps.target.outputs.version }}"); + expect(targetFreeness?.run).toContain("git fetch --force --tags origin"); + expect(targetFreeness?.run).toContain('npm view "@bitkyc08/opencodex@${INTENDED#v}" version'); expect(chosenFreeness?.run).toBe("bun test tests/ci-workflows/release-version-line.test.ts"); expect(openPr?.env).toMatchObject({ - NEXT_VERSION: "${{ steps.decide.outputs.version }}", - RELEASED_VERSION: "${{ inputs.released-version }}", + MODE: "${{ steps.target.outputs.mode }}", + TARGET_VERSION: "${{ steps.target.outputs.version }}", }); expect(openPr?.run).toContain( - 'fix(release): move dev to ${NEXT_VERSION} after ${RELEASED_VERSION}', + 'chore(release): open dev at ${NEXT_VERSION} before releasing ${TARGET_VERSION}', ); - // Keep upstream's server-side owner/head filter: a locally filtered, paginated - // pull-request listing can miss an already-open repair from this repository. - expect(openPr?.run).toContain('gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls"'); - expect(openPr?.run).toContain('-f "head=${GITHUB_REPOSITORY_OWNER}:${branch}"'); - expect(openPr?.run).toContain("--force-with-lease="); - // The fork's promotion controller is the sole live post-release writer. - expect(await readText(".github/workflows/release.yml")).not.toContain( - "uses: ./.github/workflows/dev-version-bump.yml", + expect(openPr?.run).toContain( + 'fix(release): move dev to ${NEXT_VERSION} after ${TARGET_VERSION}', ); + + // The resolver is the sole raw-input boundary. Every consumer after it reads the + // normalized output, so a future input rename cannot split the decision from its PR. + expect(count(text, "${{ inputs.intended-version }}")).toBe(1); + expect(count(text, "${{ inputs.mode }}")).toBe(1); }); test("release workflow gates the exact SHA, channel, and service surface without injection", async () => { @@ -918,10 +824,6 @@ describe("GitHub Actions hardening", () => { // Keep the workflow unprivileged by default. Dispatch validation gets only // read access; write + OIDC permissions exist only on the gated publish job. expect(release.permissions).toEqual({}); - expect(workflow).toContain("repository_dispatch:\n types: [fork-auto-release]"); - expect(workflow).toContain("github.event.client_payload.expected_sha"); - expect(release.jobs).not.toHaveProperty("bump-dev-version"); - expect(workflow).not.toContain("uses: ./.github/workflows/dev-version-bump.yml"); expect(release.jobs?.["validate-dispatch"]?.["runs-on"]).toBe("ubuntu-latest"); expect(release.jobs?.["validate-dispatch"]?.permissions).toEqual({ @@ -955,7 +857,7 @@ describe("GitHub Actions hardening", () => { scripts?: Record; }; expect(packageJson.scripts?.["audit:high"]).toBe( - "bun run scripts/ci/audit-high.ts", + "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", ); expect(workflow).toContain("run: bun run audit:high"); expect(workflow).not.toContain("run: bun audit --audit-level=high"); @@ -1010,62 +912,70 @@ describe("GitHub Actions hardening", () => { } // The service gate must cover the post-restructure service surface and stay - // in sync with service-lifecycle.yml PR trigger paths. Every integration push - // to main/preview/dev runs without path filter to guarantee exact-head evidence. + // in sync with every service-lifecycle.yml push trigger path. const gateMatch = workflow.match(/grep -Eq '(\^\([^']+\)\$)'/); expect(gateMatch).not.toBeNull(); const gate = new RegExp(gateMatch![1]!); - const lifecycle = Bun.YAML.parse(await readText(".github/workflows/service-lifecycle.yml")) as { - on?: { - push?: { branches?: string[]; paths?: string[] }; - pull_request?: { branches?: string[]; paths?: string[] }; - }; - }; - expect([...(lifecycle.on?.push?.branches ?? [])].sort()).toEqual([ - "dev", - "main", - "preview", - ]); - expect(lifecycle.on?.push?.paths).toBeUndefined(); - - const prPaths = (lifecycle.on?.pull_request?.paths ?? []) as string[]; - expect(prPaths.length).toBeGreaterThanOrEqual(6); - for (const path of prPaths) { + const lifecycle = await readText(".github/workflows/service-lifecycle.yml"); + const pushPaths = lifecycle + .split("push:")[1]! + .split("workflow_dispatch:")[0]! + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith('- "')) + .map(line => line.slice(3, -1)); + expect(pushPaths.length).toBeGreaterThanOrEqual(6); + for (const path of pushPaths) { expect(gate.test(path)).toBe(true); } expect(gate.test("src/cli/index.ts")).toBe(true); expect(gate.test("src/lib/bun-runtime.ts")).toBe(true); expect(gate.test("src/cli.ts")).toBe(true); + + // PR and push triggers must stay path-set identical, and both must cover the + // pre-restructure compat stub src/cli.ts that the release gate regex checks + // (devlog 260716_passthrough_followups/020 — a release whose only service change + // is src/cli.ts must auto-trigger service-lifecycle instead of dead-ending the gate). + const prPaths = lifecycle + .split("pull_request:")[1]! + .split("push:")[0]! + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith('- "')) + .map(line => line.slice(3, -1)); + expect([...prPaths].sort()).toEqual([...pushPaths].sort()); expect(prPaths).toContain("src/cli.ts"); + expect(pushPaths).toContain("src/cli.ts"); expect(gate.test("src/router.ts")).toBe(false); expect(gate.test("docs-site/src/pages/index.astro")).toBe(false); // Channel guards stay branch-exact. - expect(workflow).toContain("Release must run from main, preview, or dev"); + expect(workflow).toContain("Release must run from main or preview"); expect(workflow).toContain("main releases must use a stable semver version"); expect(workflow).toContain("preview releases must use a preview prerelease version"); - expect(workflow).toContain("dev releases must use a dev prerelease version"); - // The fork advances dev through promote-dev.yml after the exact release tag is - // verified. A second pre-release writer here would race that controller and - // recreate the conflicting bump PR this topology deliberately removed. - expect(workflow).not.toContain("- name: Require dev to be ready for this release"); - expect(workflow).not.toContain("uses: ./.github/workflows/dev-version-bump.yml"); + const readinessStep = workflow + .split("- name: Require dev to be ready for this release")[1] + ?.split(/\n {6}- name:/)[0]; + expect(readinessStep).toBeDefined(); + expect(readinessStep).toContain( + "git fetch --force --tags origin +refs/heads/dev:refs/remotes/origin/dev", + ); + expect(readinessStep).toContain("git show origin/dev:package.json"); + expect(readinessStep).toContain( + 'bun scripts/version-line.ts assert-ahead "$dev_version" "$RELEASE_VERSION"', + ); const orderingStep = workflow .split("- name: Refuse a release the current tag set already outranks")[1] ?.split(/\n {6}- name:/)[0]; expect(orderingStep).toBeDefined(); expect(orderingStep).toContain( - 'git tag --list \'v*\' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" "${allow[@]}"', + 'git tag --list \'v*\' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" $allow', ); expect(orderingStep).toContain('existing_tag_sha="$(git rev-parse'); + expect(orderingStep).toContain('[ "$DRY_RUN" = "true" ]'); expect(orderingStep).toContain('[ "$existing_tag_sha" = "$GITHUB_SHA" ]'); - // release-postpublish.cjs has already rejected public state from any other - // source commit. Exact-head tags are therefore resumable for both a dry run - // and a real post-publish recovery, rather than being keyed to DRY_RUN here. - expect(orderingStep).not.toContain("DRY_RUN"); - expect(orderingStep).toContain('allow=(--allow-existing-tag "v${RELEASE_VERSION}")'); // This is an ordering gate, not an existence pin. It must consume the freshly // fetched tag set and must run before either dry-run packing or publication. @@ -1121,10 +1031,9 @@ describe("GitHub Actions hardening", () => { expect(createStep).toContain('notes_file="$GITHUB_WORKSPACE/.release-notes.md"'); expect(createStep).toContain('test -s "$notes_file"'); expect(createStep).not.toContain("generate-notes"); - expect(createStep).toContain('gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs"'); - expect(createStep).not.toContain('git push origin "refs/tags/${release_tag}"'); + expect(createStep).not.toContain("gh api"); expect(createStep.indexOf('test -s "$notes_file"')).toBeLessThan( - createStep.indexOf('gh api --method POST'), + createStep.indexOf('git tag "$release_tag"'), ); // The merged-only restriction remains on the service gate, whose @@ -1203,7 +1112,7 @@ describe("GitHub Actions hardening", () => { "require", "require", "require", - "require", + // pr-referenced-authors.cjs, for the carry-attribution assessor. "require", ] as const; @@ -1219,8 +1128,9 @@ describe("GitHub Actions hardening", () => { "pulls.get", "pulls.listFiles", "pulls.get", + // The carry-attribution assessor reads the branch's commit messages: a + // Co-authored-by trailer can live in a commit rather than the body. "pulls.listCommits", - "checks.listForRef", ...tail, ]; } @@ -1237,7 +1147,6 @@ describe("GitHub Actions hardening", () => { "pulls.listFiles", "pulls.get", "pulls.listCommits", - "checks.listForRef", ...tail, ]; } @@ -1260,7 +1169,6 @@ describe("GitHub Actions hardening", () => { "pulls.get", "pulls.listCommits", "pulls.listCommits", - "checks.listForRef", ...tail, ]; } @@ -1348,15 +1256,12 @@ describe("GitHub Actions hardening", () => { // workflow YAML under a write token against base-pinned scripts — a // mismatch that crashes the gate and breaks the trusted-base model. // - // CodeRabbit publishes a legacy commit status; Cursor Bugbot publishes a - // check run. Both are wake-up signals only: this privileged workflow is - // loaded from the default branch and re-reads live evidence before writes. + // `status` is the only extra trigger. CodeRabbit publishes a legacy + // commit status; this privileged workflow is loaded from the default branch + // and re-reads live review evidence before any mutation. expect(Object.keys(workflow.on ?? {}).sort()).toEqual([ - "check_run", "pull_request_target", "status", - "workflow_dispatch", - "workflow_run", ]); // And the trigger is exactly a `types:` list — nothing else. @@ -1370,12 +1275,6 @@ describe("GitHub Actions hardening", () => { // single assertion. expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual(["types"]); expect(Object.prototype.hasOwnProperty.call(workflow.on ?? {}, "status")).toBe(true); - expect(workflow.on?.check_run).toEqual({ types: ["completed"] }); - expect(workflow.on?.workflow_run).toEqual({ - workflows: ["Cross-platform CI"], - types: ["completed"], - }); - expect(Object.keys(workflow.on?.workflow_dispatch?.inputs ?? {})).toEqual(["pull_number"]); // Exactly the scopes this gate needs. `pull-requests: write` covers title and // comment updates. `contents: write` is required for the draft GraphQL @@ -1383,7 +1282,6 @@ describe("GitHub Actions hardening", () => { // when contents was unset). Asserting the whole object pins both presence // and the absence of anything broader (write-all, contents alone, …). expect(workflow.permissions).toEqual({ - checks: "read", contents: "write", "pull-requests": "write", }); @@ -1401,7 +1299,6 @@ describe("GitHub Actions hardening", () => { ]); expect(resolver?.["runs-on"]).toBe("ubuntu-latest"); expect(resolver?.permissions).toEqual({ - checks: "read", contents: "read", "pull-requests": "read", }); @@ -1409,10 +1306,6 @@ describe("GitHub Actions hardening", () => { expect(String(resolver?.["if"] ?? "")).toContain("github.event.state == 'success'"); expect(String(resolver?.["if"] ?? "")).toContain("github.event.sender.login == 'coderabbitai[bot]'"); expect(String(resolver?.["if"] ?? "")).toContain("github.event.sender.id == 136622811"); - expect(String(resolver?.["if"] ?? "")).toContain("github.event.workflow_run.name == 'Cross-platform CI'"); - expect(String(resolver?.["if"] ?? "")).toContain("github.event.workflow_run.event == 'pull_request'"); - expect(String(resolver?.["if"] ?? "")).toContain("github.event.workflow_run.status == 'completed'"); - expect(String(resolver?.["if"] ?? "")).toContain("github.event.workflow_run.repository.full_name == github.repository"); expect(String(resolver?.["if"] ?? "")).toContain("github.event.label.name == 'gui-screenshot-waived'"); expect(String(resolver?.["if"] ?? "")).toContain("github.event.label.name == 'intake: hygiene-blocked'"); expect(String(resolver?.["if"] ?? "")).toContain("github.event.label.name == 'maintainer-sponsored'"); @@ -1443,7 +1336,6 @@ describe("GitHub Actions hardening", () => { ]); expect(job?.["runs-on"]).toBe("ubuntu-latest"); expect(job?.permissions).toEqual({ - checks: "read", contents: "write", "pull-requests": "write", }); @@ -1460,7 +1352,7 @@ describe("GitHub Actions hardening", () => { const [checkout, scriptStep] = steps as [WorkflowStep, WorkflowStep]; expect(Object.keys(checkout).sort()).toEqual(["name", "uses", "with"]); expect(checkout.uses).toBe( - "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", ); expect(Object.keys(checkout.with ?? {}).sort()).toEqual([ "persist-credentials", @@ -1477,7 +1369,7 @@ describe("GitHub Actions hardening", () => { // so the scripts match the workflow definition `pull_request_target` // itself loaded; everything else resolves to `dev`. ref: - "${{ github.event_name != 'pull_request_target' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || 'dev') }}", + "${{ github.event_name == 'status' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || 'dev') }}", "persist-credentials": false, // MAINTAINERS.md rides along so the completion ping reads the canonical // maintainer list from the same trusted base revision as the scripts. @@ -1486,8 +1378,6 @@ describe("GitHub Actions hardening", () => { expect(Object.keys(scriptStep).sort()).toEqual(["env", "name", "uses", "with"]); expect(scriptStep.env).toEqual({ - CURSOR_BUGBOT_APP_ID: "${{ vars.CURSOR_BUGBOT_APP_ID }}", - CURSOR_BUGBOT_POLICY: "${{ vars.CURSOR_BUGBOT_POLICY }}", RESOLVED_PULL_NUMBER: "${{ needs.resolve-pr.outputs.pull-number }}", }); @@ -1559,11 +1449,10 @@ describe("GitHub Actions hardening", () => { expect(script).toContain("PR head moved while listing changed files"); expect(script).toContain("github.rest.repos.getCollaboratorPermissionLevel"); expect(script).toContain("github.rest.repos.compareCommitsWithBasehead"); - // The allow-list is fork-aware: upstream stays dev-only while the public - // fork also permits merge-from-main sync PRs. - expect(script).toMatch( - /const ALLOWED_BASES\s*=\s*context\.repo\.owner === "lidge-jun"\s*\? \["dev"\]\s*:\s*\["dev", "main"\]/, - ); + // The allow-list is the gate's whole policy, so it is pinned by value and + // not just by shape: a widened list is the one edit that opens every base + // at once while every behavioural scenario below still passes. + expect(script).toMatch(/const ALLOWED_BASES = \["dev"\];/); expect(script).toMatch(/const DEFAULT_BASE = "dev";/); // The read-only resolver is the single authority for PR identity. The @@ -1672,9 +1561,9 @@ describe("GitHub Actions hardening", () => { name !== "github.rest.repos.compareCommitsWithBasehead" && name !== "github.rest.repos.listPullRequestsAssociatedWithCommit" && name !== "github.rest.issues.listEvents" && - name !== "github.rest.checks.listForRef" && // Hygiene reassessment reads the changed-file list; not a write. name !== "github.rest.pulls.listFiles" && + // Carry attribution reads the branch's commit messages; not a write. name !== "github.rest.pulls.listCommits", ); expect([...new Set(restWrites)].sort()).toEqual([ @@ -1700,7 +1589,7 @@ describe("GitHub Actions hardening", () => { * await github.rest.pulls.update({ ...{ base: "main" }, owner, … }) * Object.assign(pr.base, { ref: EXPECTED_BASE }) * if (false) { …the entire body… } - * wrap the entire body in exception swallowing + * try { …the entire body… } catch {} * * A recording client does not care how the call was spelled. It records what * came out. `if (false)` and a swallowed exception show up as an empty call @@ -1871,37 +1760,6 @@ describe("GitHub Actions hardening", () => { expect(result.logs.join(" ")).toContain("All PR quality gates passed"); }); - test("required Bugbot policy accepts only the configured App's success on the live head", async () => { - const clean = await run({ - pr: { base: { ref: "dev" } }, - authorPermission: "write", - bugbotPolicy: "required", - bugbotAppId: 99, - checkRuns: [{ - name: "Cursor Bugbot", - status: "completed", - conclusion: "success", - app: { id: 99 }, - }], - }); - expect(clean.warnings.some(warning => warning.startsWith("setFailed:"))).toBe(false); - - for (const check of [ - { name: "Cursor Bugbot", status: "completed", conclusion: "neutral", app: { id: 99 } }, - { name: "Cursor Bugbot", status: "completed", conclusion: "success", app: { id: 7 } }, - { name: "Cursor Bugbot", status: "in_progress", conclusion: null, app: { id: 99 } }, - ]) { - const blocked = await run({ - pr: { base: { ref: "dev" } }, - authorPermission: "write", - bugbotPolicy: "required", - bugbotAppId: 99, - checkRuns: [check], - }); - expect(blocked.warnings.some(warning => warning.includes("bugbot_review"))).toBe(true); - } - }); - test("a contributor PR targeting dev is drafted with a readiness checklist", async () => { const result = await run({ pr: { base: { ref: "dev" } } }); @@ -2321,7 +2179,7 @@ describe("GitHub Actions hardening", () => { "graphql", "issues.createComment", ])); - expect(callsTo(result, "checks.listForRef")).toHaveLength(1); + expect(callsTo(result, "checks.listForRef")).toEqual([]); expect(callsTo(result, "pulls.update")).toEqual([]); const drafts = callsTo(result, "graphql") as [{ query: string }, { query: string }]; expect(drafts[0]!.query).toContain("reviewThreads"); @@ -2452,7 +2310,7 @@ describe("GitHub Actions hardening", () => { checkRuns, }); - expect(callsTo(result, "checks.listForRef")).toHaveLength(1); + expect(callsTo(result, "checks.listForRef")).toEqual([]); expect(callsTo(result, "pulls.update")).toEqual([]); const drafts = callsTo(result, "graphql") as [{ query: string }, { query: string }]; expect(drafts[1]!.query).toContain("markPullRequestReadyForReview"); @@ -3384,31 +3242,6 @@ describe("GitHub Actions hardening", () => { expect(callsTo(result, "graphql")).toEqual([]); }); - test("manual reconciliation resolves only a live open unmerged PR", async () => { - const open = await runResolver({ - pr: { number: 42, base: { ref: "dev" }, state: "open", merged: false }, - eventName: "workflow_dispatch", - resolvedPullNumber: 42, - }); - expect(open.outputs).toEqual([{ name: "pull-number", value: "42" }]); - expect(callsTo(open, "pulls.get")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42 }, - ]); - - for (const pr of [ - { number: 42, base: { ref: "dev" }, state: "closed" as const, merged: false }, - { number: 42, base: { ref: "dev" }, state: "closed" as const, merged: true }, - ]) { - const terminal = await runResolver({ - pr, - eventName: "workflow_dispatch", - resolvedPullNumber: 42, - }); - expect(terminal.outputs).toEqual([]); - expect(terminal.logs.join(" ")).toContain("not an open, unmerged PR"); - } - }); - test("the write gate consumes the resolved PR number without re-resolving status SHA", async () => { const result = await run({ pr: { base: { ref: "dev" }, number: 4242 }, @@ -3546,129 +3379,6 @@ describe("GitHub Actions hardening", () => { expect(result.warnings.join(" ")).toContain("Could not list open PRs"); }); - test("the resolver maps a completed same-repository Cross-platform CI run to its one current open PR", async () => { - const headSha = "7d42d17f213a632fc2def56053f0cd574b13d459"; - const result = await runResolver({ - pr: { base: { ref: "dev" }, number: 4242, head: { sha: headSha } }, - eventName: "workflow_run", - workflowRun: { - name: "Cross-platform CI", - event: "pull_request", - status: "completed", - conclusion: "success", - head_sha: headSha, - repository: { full_name: "lidge-jun/opencodex" }, - head_repository: { full_name: "lidge-jun/opencodex" }, - }, - openPulls: [ - { number: 4242, state: "open", head: { sha: headSha } }, - { number: 7777, state: "open", head: { sha: "other" } }, - ], - }); - - expect(result.outputs).toEqual([{ name: "pull-number", value: "4242" }]); - expect(callsTo(result, "pulls.list")).toHaveLength(1); - expect(callsTo(result, "repos.listPullRequestsAssociatedWithCommit")).toEqual([]); - }); - - test("the workflow_run resolver fails closed for untrusted, stale, or non-unique completions", async () => { - const headSha = "7d42d17f213a632fc2def56053f0cd574b13d459"; - const baseWorkflowRun = { - name: "Cross-platform CI", - event: "pull_request", - status: "completed", - conclusion: "success" as const, - head_sha: headSha, - repository: { full_name: "lidge-jun/opencodex" }, - head_repository: { full_name: "contributor/opencodex" }, - }; - const cases = [ - { - name: "wrong workflow name", - workflowRun: { ...baseWorkflowRun, name: "Untrusted CI" }, - openPulls: [{ number: 4242, state: "open", head: { sha: headSha } }], - listed: 0, - }, - { - name: "wrong original event", - workflowRun: { ...baseWorkflowRun, event: "push" }, - openPulls: [{ number: 4242, state: "open", head: { sha: headSha } }], - listed: 0, - }, - { - name: "non-completed status", - workflowRun: { ...baseWorkflowRun, status: "in_progress" }, - openPulls: [{ number: 4242, state: "open", head: { sha: headSha } }], - listed: 0, - }, - { - name: "wrong repository", - workflowRun: { - ...baseWorkflowRun, - repository: { full_name: "attacker/opencodex" }, - }, - openPulls: [{ number: 4242, state: "open", head: { sha: headSha } }], - listed: 0, - }, - { - name: "missing head SHA", - workflowRun: (() => { - const { head_sha: _headSha, ...missing } = baseWorkflowRun; - return missing; - })(), - openPulls: [{ number: 4242, state: "open", head: { sha: headSha } }], - listed: 0, - }, - { - name: "blank head SHA", - workflowRun: { ...baseWorkflowRun, head_sha: "" }, - openPulls: [{ number: 4242, state: "open", head: { sha: headSha } }], - listed: 0, - }, - { - name: "stale head", - workflowRun: { ...baseWorkflowRun, head_sha: "stale-head" }, - openPulls: [{ number: 4242, state: "open", head: { sha: headSha } }], - listed: 1, - }, - { - name: "closed candidate", - workflowRun: baseWorkflowRun, - openPulls: [{ number: 4242, state: "closed", head: { sha: headSha } }], - listed: 1, - }, - { - name: "merged candidate", - workflowRun: baseWorkflowRun, - openPulls: [{ number: 4242, state: "open", merged: true, head: { sha: headSha } }], - listed: 1, - }, - { - name: "ambiguous same-head candidates", - workflowRun: baseWorkflowRun, - openPulls: [ - { number: 4242, state: "open", head: { sha: headSha } }, - { number: 7777, state: "open", head: { sha: headSha } }, - ], - listed: 1, - }, - ] as const; - - for (const scenario of cases) { - const result = await runResolver({ - pr: { base: { ref: "dev" }, number: 4242, head: { sha: headSha } }, - eventName: "workflow_run", - workflowRun: scenario.workflowRun, - openPulls: scenario.openPulls, - }); - expect(result.outputs, scenario.name).toEqual([]); - expect(callsTo(result, "pulls.list"), scenario.name).toHaveLength(scenario.listed); - expect(callsTo(result, "pulls.get"), scenario.name).toEqual([]); - expect(callsTo(result, "issues.createComment"), scenario.name).toEqual([]); - expect(callsTo(result, "issues.updateComment"), scenario.name).toEqual([]); - } - }); - test("a non-maintainer issue_comment does not re-run the gate", async () => { // The `issue_comment` trigger must only re-run for maintainer comments // (OWNER / COLLABORATOR / MEMBER). A random comment from a contributor @@ -5548,108 +5258,6 @@ describe("GitHub Actions hardening", () => { expect(rootPkg).toContain("bun run typecheck && bun run lint:gui:if-changed && bun run test"); expect(rootPkg).toContain("bun run privacy:scan && bun run doctor:gui:if-changed"); }); - - test("cross-platform CI gates include dependency and workflow validation", async () => { - const workflow = await readText(".github/workflows/ci.yml"); - const ci = Bun.YAML.parse(workflow) as { - jobs?: Record }>; - }; - const gatesSteps = ci.jobs?.gates?.steps ?? []; - const audit = gatesSteps.find(step => step.name === "Dependency audit (high severity)"); - expect(audit).toBeDefined(); - expect(audit?.run).toBe("bun run audit:high"); - const workflowLint = gatesSteps.find(step => step.name === "Validate GitHub Actions workflows"); - expect(workflowLint?.run).toBe("bun run lint:workflows"); - expect(workflow).toContain("run: bun run audit:high"); - const packageJson = JSON.parse(await readText("package.json")) as { - scripts?: Record; - }; - expect(packageJson.scripts?.["audit:high"]).toBe( - "bun run scripts/ci/audit-high.ts", - ); - }); - - test("scheduled dependency audit is minimal, read-only, pinned and bounded", async () => { - const workflow = await readText(".github/workflows/dependency-audit.yml"); - const audit = Bun.YAML.parse(workflow) as { - on?: Record; - permissions?: Record; - jobs?: Record; steps?: Array<{ uses?: string; run?: string }> }>; - }; - expect(audit.on).toBeDefined(); - expect(audit.on?.schedule).toBeDefined(); - expect(audit.on?.workflow_dispatch).toBeDefined(); - expect(workflow).toContain('cron: "0 6 * * *"'); - expect(workflow).toContain("group: dependency-audit"); - expect(workflow).toContain("cancel-in-progress: false"); - expect(audit.permissions).toEqual({ contents: "read" }); - const jobs = Object.entries(audit.jobs ?? {}); - expect(jobs.length).toBeGreaterThanOrEqual(1); - for (const [name, job] of jobs) { - expect(typeof job["timeout-minutes"]).toBe("number"); - expect(job.permissions).toEqual({ contents: "read" }); - const runs = (job.steps ?? []).map(s => s.run ?? "").join("\n"); - expect(runs).toContain("bun run audit:high"); - } - expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); - expect(workflow).toContain("./.github/actions/setup-project-bun"); - expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); - expect(workflow).toContain("timeout-minutes:"); - }); - - test("reusable workflow calls do not use env or secrets in with and caller covers callee permissions", async () => { - const { readdir } = await import("node:fs/promises"); - const workflowDir = new URL("../../.github/workflows/", import.meta.url); - const files = await readdir(workflowDir); - const yamlFiles = files.filter(f => f.endsWith(".yml") || f.endsWith(".yaml")); - for (const file of yamlFiles) { - const text = await readText(`.github/workflows/${file}`); - const parsed = Bun.YAML.parse(text) as { - permissions?: Record | string; - jobs?: Record; permissions?: Record | string }>; - }; - const callerPerms = parsed.permissions; - for (const [jobName, job] of Object.entries(parsed.jobs ?? {})) { - if (!job.uses || !job.uses.startsWith("./.github/workflows/")) continue; - const withText = JSON.stringify(job.with ?? {}); - expect(withText).not.toMatch(/\b(?:env|secrets)\s*\./); - const calleePath = String(job.uses).split("#")[0]!.replace(/^\.\//, ""); - const calleeText = await readText(calleePath); - const callee = Bun.YAML.parse(calleeText) as { - permissions?: Record | string; - jobs?: Record | string }>; - }; - const asPermissionMap = ( - value: Record | string | undefined, - location: string, - ): Record => { - if (value === undefined) return {}; - if (typeof value === "string") { - throw new Error(`${location} uses ${value}; declare explicit permission scopes`); - } - return value; - }; - const callerEffective = asPermissionMap( - job.permissions ?? callerPerms, - `${file}:${jobName}`, - ); - const permissionRank: Record = { none: 0, read: 1, write: 2 }; - for (const [calleeJobName, calleeJob] of Object.entries(callee.jobs ?? {})) { - const needed = asPermissionMap( - calleeJob.permissions ?? callee.permissions, - `${calleePath}:${calleeJobName}`, - ); - for (const [scope, level] of Object.entries(needed)) { - expect(permissionRank[callerEffective[scope] ?? "none"] ?? -1).toBeGreaterThanOrEqual( - permissionRank[level] ?? Number.POSITIVE_INFINITY, - ); - } - } - } - } - // Zero calls is valid: tests/ci-workflows/bump-dev-version.test.ts explicitly protects the - // dormant bump workflow from being wired back into this fork's live release path. - }); }); describe("doctor-gui-if-changed", () => { @@ -5851,10 +5459,26 @@ describe("gui exhaustive-deps suppression stays scoped and effective", () => { expect(config.blocking).toBe("warning"); }); - test("the Models effect tracks its stable loader dependencies", async () => { + test("the effect keeps the in-file record of why the dep array stays short", async () => { const models = await readText("gui/src/pages/Models.tsx"); - const effectEnd = models.indexOf("}, [catalogActive, loadModelDiscovery, loadPresets, loadShadowCall, loadV2, reloadAliases]);"); + const effectEnd = models.indexOf("}, [catalogActive, loadShadowCall, loadV2]);"); expect(effectEnd).toBeGreaterThan(-1); + + // The reasoning has to sit on the effect, not in a commit message. Read the comment + // block immediately above the dep array rather than the whole file, or this passes on + // any incidental mention elsewhere. + const preceding = models.slice(0, effectEnd).split(/\r?\n/).slice(-8).join("\n"); + expect(preceding).toContain("PreserveManualMemo"); + expect(preceding).toContain("five react-compiler"); + + // Both suppressions are config-side, so the note must point at the two files a reader + // would otherwise have to find by grep. + expect(preceding).toContain("gui/.oxlintrc.json"); + expect(preceding).toContain("gui/doctor.config.json"); + + // An in-file react-doctor disable was tried and removed: doctor passes without it, and + // react/react-compiler penalises a component merely for carrying suppressions. If one + // reappears, the config route has been misunderstood. expect(models).not.toContain("react-doctor-disable-next-line"); }); }); diff --git a/tests/ci-workflows/macos-serial-lanes.test.ts b/tests/ci-workflows/macos-serial-lanes.test.ts new file mode 100644 index 0000000000..c9677cddf4 --- /dev/null +++ b/tests/ci-workflows/macos-serial-lanes.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, test } from "bun:test"; +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import type { Readable } from "node:stream"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +// Deliberately independent of the real six-file policy: expansion, quoting, and +// index-based ownership must work for canonical paths relative to tests/. +const SERIAL_FILES = [ + "serial/falcon.test.ts", + "nested/lane/ibis.test.ts", + "serial/lynx.test.ts", + "other/tern.test.ts", +]; +const GENERAL_FILES = ["general/ordinary.test.ts", "general/falcon-extra.test.ts"]; +const ASSERTION_STATUS = 23; +const CRASH_STATUS = 139; +const CRASH_SIGNATURES = [ + "oh no: Bun has crashed", + "Internal assertion failure", + "Segmentation fault at address 0x1234", + "Illegal instruction", + "Bus error", + "Aborted (core dumped)", +]; + +type Invocation = { kind: "manifest" | "test"; argv: string[]; pid: number }; +type FixtureOptions = { + manifest?: string[]; + manifestStatus?: number; + missing?: string; + collision?: boolean; + target?: "main" | string; + outcomes?: Array<"assert" | "crash">; + crashSignature?: string; +}; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function fixtureDiagnostics(value: string): string { + return CRASH_SIGNATURES.reduce((text, signature) => text.replaceAll(signature, "[simulated crash]"), value); +} + +function macosTestBlock(shard: number): string { + const workflow = Bun.YAML.parse(readFileSync(repoPath(".github/workflows/ci.yml"), "utf8")) as { + jobs: Record }>; + }; + const run = workflow.jobs["platform-macos"]?.steps.find(step => step.name === "Test")?.run; + if (!run) throw new Error("platform-macos must contain the executable Test step"); + // Render the existing Actions expression too, so the old workflow reaches + // the ownership assertions instead of failing with Bash's 'bad substitution'. + return run.replace(/\$\{\{\s*matrix\.shard\s*\}\}/g, String(shard)); +} + +// Only the Bun CLI is replaced. Bash, arrays, find, pipes, PIPESTATUS, and +// filesystem validation all execute unchanged from the actual YAML run block. +const FAKE_BUN = String.raw` +import { appendFileSync, readFileSync } from "node:fs"; +const config = JSON.parse(readFileSync(process.env.MACOS_FIXTURE_CONFIG, "utf8")); +const log = process.env.MACOS_FIXTURE_LOG; +const argv = process.argv.slice(2); +const record = kind => appendFileSync(log, JSON.stringify({ kind, argv, pid: process.pid }) + "\n"); +if (argv[0] === "-e") { + record("manifest"); + process.stdout.write(config.manifest.join("\n") + (config.manifest.length ? "\n" : "")); + process.exit(config.manifestStatus); +} +if (argv[0] !== "test") { + console.error("unexpected fake Bun invocation", JSON.stringify(argv)); + process.exit(97); +} +record("test"); +const matches = args => config.target === "main" ? args.includes("tests") + : args.some(arg => arg.replace(/^\.\//, "") === "tests/" + config.target); +if (!matches(argv)) process.exit(0); +const attempts = readFileSync(log, "utf8").trim().split("\n").map(line => JSON.parse(line)) + .filter(entry => entry.kind === "test" && matches(entry.argv)).length; +const outcome = config.outcomes[attempts - 1]; +if (outcome === "assert") { + console.error("(fail) fixture assertion: expected true, received false"); + process.exit(config.assertionStatus); +} +if (outcome === "crash") { + // Deliberately not the final output line: the shell must capture the stream. + console.error(config.crashSignature); + console.error("fixture runtime diagnostic tail"); + process.exit(config.crashStatus); +} +process.exit(0); +`; + +function createFixture(directory: string, options: FixtureOptions): void { + mkdirSync(join(directory, "bin")); + mkdirSync(join(directory, "tmp")); + for (const file of [...SERIAL_FILES, ...GENERAL_FILES]) { + if (file === options.missing) continue; + mkdirSync(dirname(join(directory, "tests", file)), { recursive: true }); + writeFileSync(join(directory, "tests", file), ""); + } + if (options.collision) { + mkdirSync(join(directory, "tests/collision")); + writeFileSync(join(directory, "tests/collision", basename(SERIAL_FILES[0]!)), ""); + } + writeFileSync(join(directory, "fake-bun.mjs"), FAKE_BUN); + writeFileSync(join(directory, "bin/bun"), + `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(join(directory, "fake-bun.mjs"))} "$@"\n`, + { mode: 0o755 }); + writeFileSync(join(directory, "config.json"), JSON.stringify({ + manifest: SERIAL_FILES, manifestStatus: 0, target: "main", outcomes: [], + assertionStatus: ASSERTION_STATUS, crashStatus: CRASH_STATUS, + crashSignature: CRASH_SIGNATURES[0], ...options, + })); +} + +function spawnErrorCode(error: unknown): string { + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "SPAWN_ERROR"; +} + +function runShell(directory: string, shard: number): Promise<{ status: number | null; output: string }> { + // Use the runner's native /bin/bash (Bash 3 on macOS), never a shell mock. + const command = macosTestBlock(shard); + return new Promise((resolve, reject) => { + let child: ChildProcessByStdio; + try { + child = spawn("/bin/bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", command], { + cwd: directory, detached: true, stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: `${join(directory, "bin")}:/usr/bin:/bin`, HOME: directory, + TMPDIR: join(directory, "tmp"), RUNNER_TEMP: join(directory, "tmp"), CI: "true", + MACOS_TEST_SHARD: String(shard), + MACOS_FIXTURE_CONFIG: join(directory, "config.json"), + MACOS_FIXTURE_LOG: join(directory, "invocations.jsonl"), + }, + }); + } catch (error) { + reject(new Error(`macOS shell harness failed: ${spawnErrorCode(error)}`)); + return; + } + + const chunks: Buffer[] = []; + const outputLimit = 256 * 1024; + let outputBytes = 0; + let failure: string | undefined; + let settled = false; + let cleanupTimer: ReturnType | undefined; + const deadline = setTimeout(() => interrupt("ETIMEDOUT"), INTERNAL_DEADLINE_MS); + + function finish(status: number | null): void { + if (settled) return; + settled = true; + clearTimeout(deadline); + if (cleanupTimer) clearTimeout(cleanupTimer); + if (failure) reject(new Error(`macOS shell harness failed: ${failure}`)); + else resolve({ status, output: fixtureDiagnostics(Buffer.concat(chunks).toString("utf8")) }); + } + + function interrupt(code: string): void { + if (settled || failure) return; + failure = code; + clearTimeout(deadline); + // Only an interrupted run is signalled. Normal close (including an + // assertion's nonzero status) never kills a completed/reusable PID. + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + } catch (error) { + const killCode = spawnErrorCode(error); + if (killCode !== "ESRCH") failure = `${code}; CLEANUP_${killCode}`; + } + // Await close after group termination, but inherited pipes cannot keep + // the harness or fixture cleanup pending forever. This is cleanup grace, + // not another test attempt or an extension of the execution deadline. + cleanupTimer = setTimeout(() => { + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + failure = `${failure}; CLEANUP_TIMEOUT`; + finish(null); + }, 1_000); + } + + function capture(chunk: Buffer): void { + if (settled || failure) return; + const remaining = outputLimit - outputBytes; + const kept = chunk.subarray(0, remaining); + if (kept.length) chunks.push(Buffer.from(kept)); + outputBytes += kept.length; + if (chunk.length > remaining) interrupt("OUTPUT_LIMIT"); + } + + child.stdout.on("data", capture); + child.stderr.on("data", capture); + child.stdout.on("error", error => interrupt(spawnErrorCode(error))); + child.stderr.on("error", error => interrupt(spawnErrorCode(error))); + child.on("error", error => interrupt(spawnErrorCode(error))); + child.once("exit", (_status, signal) => { + if (signal) interrupt(signal); + }); + child.once("close", (status, signal) => { + if (signal) interrupt(signal); + finish(status); + }); + }); +} + +async function runShard(shard: number, options: FixtureOptions = {}) { + // Spaces and a quote in cwd exercise the executable/config/log path quoting + // without inventing manifest characters forbidden by the source path policy. + const directory = mkdtempSync(join(tmpdir(), "ocx macos' lanes-")); + try { + createFixture(directory, options); + const log = join(directory, "invocations.jsonl"); + const result = await runShell(directory, shard); + const invocations: Invocation[] = existsSync(log) + ? readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) + : []; + return { ...result, invocations }; + } finally { + // runShell settles only after close or its finite termination grace. + removeTreeWithRetry(directory); + } +} + +function testCalls(result: Awaited>): Invocation[] { + return result.invocations.filter(call => call.kind === "test"); +} + +function testPaths(call: Invocation): string[] { + return call.argv.map(arg => arg.replace(/^\.\//, "")) + .filter(arg => arg === "tests" || arg.startsWith("tests/")); +} + +function targets(call: Invocation, target: string): boolean { + return testPaths(call).includes(target === "main" ? "tests" : `tests/${target}`); +} + +function optionValues(argv: string[], option: string): string[] { + return argv.flatMap((arg, index) => arg === option ? [argv[index + 1] ?? ""] + : arg.startsWith(`${option}=`) ? [arg.slice(option.length + 1)] : []); +} + +function expectGeneralCall(call: Invocation, shard: number): void { + const ignores = optionValues(call.argv, "--path-ignore-patterns"); + expect(ignores.toSorted()).toEqual(SERIAL_FILES.map(file => `**/${basename(file)}`).toSorted()); + expect(optionValues(call.argv, "--shard")).toEqual([`${shard}/2`]); + expect(optionValues(call.argv, "--timeout")).toEqual(["60000"]); + expect(call.argv).toContain("--isolate"); + expect(testPaths(call)).toEqual(["tests"]); + // Account for every CLI argument: a name filter or extra exclusion could + // silently drop ordinary files even while the serial ownership oracle passes. + expect(call.argv.toSorted()).toEqual([ + "test", "--isolate", "--timeout", "60000", "tests", `--shard=${shard}/2`, + ...SERIAL_FILES.flatMap(file => ["--path-ignore-patterns", `**/${basename(file)}`]), + ].toSorted()); + // Exact exclusions above plus the unrestricted tests root leave these files + // in the main pool. Similar basenames must not become accidental exclusions. + for (const file of GENERAL_FILES) expect(ignores).not.toContain(`**/${basename(file)}`); +} + +// These are explicitly Unix Bash integration tests; Windows still runs the +// existing cross-platform workflow source/layout contracts unchanged. +describe.skipIf(process.platform === "win32")("macOS serial lane shell ownership", () => { + test("both shards own each canonical file exactly once in a fresh isolated process", async () => { + const runs = [await runShard(1), await runShard(2)]; + for (const [index, run] of runs.entries()) { + expect(run.status, run.output).toBe(0); + const calls = testCalls(run); + const serial = calls.filter(call => !call.argv.includes("tests")); + const owned = SERIAL_FILES.filter((_, fileIndex) => fileIndex % 2 === index); + // First oracle deliberately fails old CI for missing isolated ownership. + expect(serial.length, "missing isolated ownership of canonical serial files").toBe(owned.length); + expect(calls.filter(call => call.argv.includes("tests"))).toHaveLength(1); + expectGeneralCall(calls[0]!, index + 1); + expect(serial.map(testPaths)).toEqual(owned.map(file => [`tests/${file}`])); + for (const call of serial) { + expect(call.argv).toContain("--parallel=1"); + expect(call.argv).toContain("--isolate"); + expect(optionValues(call.argv, "--timeout")).toEqual(["60000"]); + expect(optionValues(call.argv, "--shard")).toEqual([]); + expect(optionValues(call.argv, "--path-ignore-patterns")).toEqual([]); + } + const manifests = run.invocations.filter(call => call.kind === "manifest"); + expect(manifests).toHaveLength(1); + expect(manifests[0]!.argv[1]).toContain("SERIAL_FULL_SUITE_FILES"); + } + const calls = runs.flatMap(testCalls); + expect(new Set(calls.map(call => call.pid)).size).toBe(calls.length); + }, SPAWN_BUDGET_MS); + + for (const target of ["main", SERIAL_FILES[0]!] as const) { + test(`${target}: assertion failure propagates without retry or later files`, async () => { + const run = await runShard(1, { target, outcomes: ["assert"] }); + expect(run.status, run.output).toBe(ASSERTION_STATUS); + const calls = testCalls(run); + expect(calls).toHaveLength(target === "main" ? 1 : 2); + expect(targets(calls.at(-1)!, target)).toBe(true); + }, SPAWN_BUDGET_MS); + + for (const [caseIndex, signature] of CRASH_SIGNATURES.entries()) { + test(`${target}: retries one runtime crash (case ${caseIndex + 1}), then finishes`, async () => { + const run = await runShard(1, { target, outcomes: ["crash"], crashSignature: signature }); + expect(run.status, run.output).toBe(0); + const calls = testCalls(run); + const attempts = calls.filter(call => targets(call, target)); + expect(attempts).toHaveLength(2); + expect(attempts[0]!.argv).toEqual(attempts[1]!.argv); + expect(new Set(calls.map(call => call.pid)).size).toBe(calls.length); + expect(calls).toHaveLength(4); // Main plus two owned serial files plus one retry. + expect(targets(calls.at(-1)!, SERIAL_FILES[2]!)).toBe(true); + }, SPAWN_BUDGET_MS); + } + + test(`${target}: a repeated crash fails after exactly one retry`, async () => { + const run = await runShard(1, { target, outcomes: ["crash", "crash"] }); + expect(run.status, run.output).toBe(CRASH_STATUS); + const calls = testCalls(run); + expect(calls).toHaveLength(target === "main" ? 2 : 3); + const attempts = calls.filter(call => targets(call, target)); + expect(attempts).toHaveLength(2); + expect(attempts[0]!.argv).toEqual(attempts[1]!.argv); + }, SPAWN_BUDGET_MS); + + test(`${target}: assertion on the crash retry retains its own exit status`, async () => { + const run = await runShard(1, { target, outcomes: ["crash", "assert"] }); + expect(run.status, run.output).toBe(ASSERTION_STATUS); + const calls = testCalls(run); + expect(calls).toHaveLength(target === "main" ? 2 : 3); + expect(calls.filter(call => targets(call, target))).toHaveLength(2); + expect(run.output).toContain("assertion failures are not retried"); + expect(run.output).not.toContain("crash repeated"); + }, SPAWN_BUDGET_MS); + } + + const invalidManifests: Array<[string, FixtureOptions]> = [ + ["producer failure despite valid output", { manifestStatus: 19 }], + ["empty manifest", { manifest: [] }], + ["duplicate entry", { manifest: [...SERIAL_FILES, SERIAL_FILES[0]!] }], + ["missing file", { missing: SERIAL_FILES[3] }], + ["basename collision", { collision: true }], + ["basename without its full relative path", { manifest: [basename(SERIAL_FILES[0]!)] }], + ["absolute path", { manifest: [`/${SERIAL_FILES[0]}`] }], + ["parent traversal", { manifest: ["serial/../serial/falcon.test.ts"] }], + ]; + test.each(invalidManifests)("rejects %s before any tests start", async (_name, options) => { + for (const shard of [1, 2]) { + const run = await runShard(shard, options); + expect(run.status, run.output).not.toBe(0); + expect(testCalls(run), run.output).toEqual([]); + expect(run.invocations.filter(call => call.kind === "manifest")).toHaveLength(1); + } + }, SPAWN_BUDGET_MS); +}); diff --git a/tests/ci-workflows/skill-ocx.test.ts b/tests/ci-workflows/skill-ocx.test.ts index 9293dd080a..dbd9693dcc 100644 --- a/tests/ci-workflows/skill-ocx.test.ts +++ b/tests/ci-workflows/skill-ocx.test.ts @@ -163,3 +163,135 @@ describe("the consent boundary is stated, not implied", () => { expect(recipes).toContain("get approval"); }); }); + +describe("access-key recipes keep plaintext outside agent sessions", () => { + // CLI oracle: access.ts removes one --json before checking exact commit/abort tokens. + // These canonical spellings are case-sensitive; commit-old-id is a start, not a commit. + const secretBearingAccessKeyCommand = + /\b(?:ocx|opencodex)(?:\.(?:exe|mjs|cmd|ps1))?["']?\s+(?:access\s+keys?|api-key)\s+(?:create\b|rotate\b(?!\s+(?:--json\s+)?(?:commit|abort)(?=\s|$)))/gm; + const secretBearingManagementRequest = + /(?:(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["'])[^\n]{0,240}\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])|\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])[^\n]{0,240}(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["']))/gim; + + /** + * Early warning for literal recipes in ordinary fences and single-backtick spans. + * Not a shell/JS parser: implicit POSTs, dynamic calls, alternate Markdown and + * arbitrary multiline requests remain outside this bounded detector. + */ + function secretBearingCommandsInCode(text: string): string[] { + const spans: string[] = []; + const prose = text.replace(/```[^\n]*\n([\s\S]*?)```/g, (_all: string, body: string) => { + spans.push(body); + return ""; + }); + for (const span of prose.matchAll(/`([^`\n]+)`/g)) spans.push(span[1]!); + const matches: string[] = []; + for (const span of spans) { + const executable = span.replace(/(?:\\|`|\^)\r?\n\s*/g, " "); + matches.push(...Array.from(executable.matchAll(secretBearingAccessKeyCommand), match => match[0])); + matches.push(...Array.from(executable.matchAll(secretBearingManagementRequest), match => match[0])); + } + return matches; + } + + test("all key aliases reject creation/start and preserve non-secret commit/abort", () => { + for (const binary of ["ocx", "opencodex"]) { + for (const group of ["access key", "access keys", "api-key"]) { + const prefix = `${binary} ${group}`; + for (const action of [ + "create rotated", "create rotated --json", + "rotate old-id", "rotate old-id --json", "rotate --json old-id", + ]) { + const command = `${prefix} ${action}`; + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toHaveLength(1); + } + for (const operation of ["commit", "abort"]) { + for (const args of [ + `${operation} old-id rotation-id`, + `${operation} old-id rotation-id --json`, + `--json ${operation} old-id rotation-id`, + ]) { + const command = `${prefix} rotate ${args}`; + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toEqual([]); + } + const start = `${prefix} rotate --json ${operation}-old-id`; + expect(secretBearingCommandsInCode("`" + start + "`"), start).toHaveLength(1); + } + } + } + }); + + test("wrappers, shell continuations and inline examples cannot hide literal commands", () => { + for (const command of [ + "& ocx access keys create rotated --json", + "command ocx access key create rotated", + "env ocx api-key rotate old-id", + "& 'C:\\Tools\\opencodex.exe' api-key rotate old-id", + "node /opt/bin/ocx.mjs access key create rotated", + "ocx.cmd access key create rotated", + "& './opencodex.ps1' access keys rotate old-id", + "ocx access key \\\n create rotated --json", + "ocx access key `\r\n create rotated --json", + "ocx access key ^\n rotate old-id", + "ocx access key rotate COMMIT", + ]) { + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toHaveLength(1); + } + expect(secretBearingCommandsInCode("Run `ocx api-key create rotated --json` next.")).toHaveLength(1); + expect(secretBearingCommandsInCode("Do not run `ocx api-key create rotated --json`.")).toHaveLength(1); + expect(secretBearingCommandsInCode("Creation under `ocx access key` returns plaintext.")).toEqual([]); + }); + + test("explicit management POST recipes are detected without banning commit or abort", () => { + for (const route of ["/api/keys", "/api/keys/rotate"]) { + for (const command of [ + `POST ${route}`, + `curl -X POST http://127.0.0.1:3000${route}`, + `curl 'http://127.0.0.1:3000${route}?source=recipe' --request POST`, + `curl --request POST \\\n 'http://127.0.0.1:3000${route}#example'`, + `Invoke-RestMethod http://127.0.0.1:3000${route} -Method Post`, + `Invoke-WebRequest -Method Post http://127.0.0.1:3000${route}`, + `fetch('${route}', { method: 'POST' })`, + ]) { + expect(secretBearingCommandsInCode("```text\n" + command + "\n```"), command).toHaveLength(1); + } + } + expect(secretBearingCommandsInCode("Run `POST /api/keys` next.")).toHaveLength(1); + for (const command of [ + "ocx access key list --json", + "ocx access key remove old-id --yes --json", + "ocx connect rotate --admin-token-stdin --json", + "curl -X POST http://127.0.0.1:3000/api/keys/rotate/commit", + "curl -X DELETE http://127.0.0.1:3000/api/keys/rotate", + "curl -X DELETE http://127.0.0.1:3000/api/keys", + "curl http://127.0.0.1:3000/api/keys\ncurl -X POST http://127.0.0.1:3000/api/keys/rotate/commit", + ]) { + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toEqual([]); + } + expect(secretBearingCommandsInCode("| POST | `/api/keys/rotate` |")).toEqual([]); + }); + + test("the original unsafe recipe is detected and every shipped page is scanned", () => { + const original = "```bash\nocx access key list --json\nocx access key create rotated --json\n" + + "ocx access key remove --yes --json\nocx access key list --json\n```"; + expect(secretBearingCommandsInCode(original)).toHaveLength(1); + for (const file of ["SKILL.md", ...REFERENCES.map(ref => join("references", ref))]) { + expect(secretBearingCommandsInCode(read(file)), file).toEqual([]); + } + }); + + test("guidance distinguishes configuration confirmation from revocation authority", () => { + // Documentation presence/order only: these assertions do not prove agent behavior. + const skill = readFileSync(SKILL, "utf8"); + const recipes = read("references/03_recipes.md"); + for (const text of [skill, recipes]) { + expect(text).toMatch(/outside the agent\s+session/); + expect(text).toMatch(/configuration confirmation is not (?:revocation )?approval/i); + expect(text).toMatch(/existing explicit\s+approval for that exact revocation remains valid/); + } + const approvalAt = recipes.indexOf("separate explicit revocation approval"); + expect(approvalAt).toBeGreaterThanOrEqual(0); + for (const command of ["ocx access key rotate commit", "ocx access key remove"]) { + expect(recipes.indexOf(command)).toBeGreaterThan(approvalAt); + } + }); +}); diff --git a/tests/ci-workflows/test-runner.test.ts b/tests/ci-workflows/test-runner.test.ts index b7d4e64808..84b0dfc928 100644 --- a/tests/ci-workflows/test-runner.test.ts +++ b/tests/ci-workflows/test-runner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path"; import { @@ -7,22 +8,19 @@ import { createIsolatedTestEnvironment, ensureGuiDependencies, inspectChangedRun, - runTestLaneForTests, resolveBunTestArgs, resolveBunTestPlan, selectChangedComparisonRef, SERIAL_FULL_SUITE_FILES, - terminateTestProcessForTests, - waitWithMonotonicTimeout, } from "../../scripts/test"; -import { SERIAL_TEST_FILES } from "../../scripts/ci/test-lanes"; -import { fixturePath, repoPath, repoRoot } from "../helpers/repo-root"; +import { repoPath, repoRoot } from "../helpers/repo-root"; import { acquireTestRunLock, resolveBareTestRunIdentity, resolveDefaultTestRunLockPath, resolveInheritedTestRunLock, resolveWrappedTestRunLockPath, + TEST_RUN_ID_ENV, TEST_RUN_LOCK_PATH_ENV, TEST_RUN_LOCK_TOKEN_ENV, TEST_RUN_NO_QUEUE_ENV, @@ -34,6 +32,7 @@ import { windowsIdentityPowerShellSpawnOptionsForTests, } from "../../src/codex/user-identity"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; function runGit(cwd: string, ...args: string[]): string { @@ -50,40 +49,6 @@ function runGit(cwd: string, ...args: string[]): string { // handed to git are identical either way. const FIXTURE_COMMIT_EMAIL = ["test", "opencodex.invalid"].join("@"); -async function waitForFile(path: string, timeoutMs = 2_000): Promise { - const deadline = Date.now() + timeoutMs; - while (!existsSync(path)) { - if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`); - await Bun.sleep(10); - } -} - -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function exitWithin(child: Bun.Subprocess, timeoutMs = 5_000): Promise { - return await Promise.race([ - child.exited, - Bun.sleep(timeoutMs).then(() => { throw new Error(`pid ${child.pid} did not exit within ${timeoutMs}ms`); }), - ]); -} - -async function expectProcessTreeDead(markerPath: string): Promise { - const pids = JSON.parse(await Bun.file(markerPath).text()) as { child: number; grandchild: number }; - const deadline = Date.now() + 2_000; - while ((processIsAlive(pids.child) || processIsAlive(pids.grandchild)) && Date.now() < deadline) { - await Bun.sleep(10); - } - expect(processIsAlive(pids.child)).toBe(false); - expect(processIsAlive(pids.grandchild)).toBe(false); -} - function pathIsContainedBy(parent: string, candidate: string, platform: "posix" | "win32"): boolean { const path = platform === "win32" ? win32 : posix; const relative = path.relative(path.resolve(parent), path.resolve(candidate)); @@ -208,8 +173,6 @@ describe("bun test argv", () => { test("the default full suite quarantines load-sensitive files into one-worker lanes", () => { const plan = resolveBunTestPlan([]); - expect(SERIAL_FULL_SUITE_FILES) - .toEqual(SERIAL_TEST_FILES.map(file => file.slice("tests/".length))); expect(plan).toHaveLength(SERIAL_FULL_SUITE_FILES.length + 1); expect(plan[0]?.label).toBe("parallel suite"); expect(plan[0]?.args).toContain("--parallel=4"); @@ -225,18 +188,9 @@ describe("bun test argv", () => { ]); } expect(plan.find(lane => lane.label === "release-helper.test.ts")?.timeoutMs).toBe(5 * 60 * 1000); - expect(plan.find(lane => lane.label === "ocx-launcher-runtime.test.ts")?.timeoutMs).toBe(5 * 60 * 1000); expect(plan.find(lane => lane.label === "codex-shim.test.ts")?.timeoutMs).toBe(3 * 60 * 1000); }); - test("a timed full suite keeps load-sensitive files in serial lanes", () => { - const plan = resolveBunTestPlan(["--timings", ".bun-timings.json"]); - expect(plan).toHaveLength(SERIAL_TEST_FILES.length + 1); - expect(plan[0]?.label).toBe("parallel suite"); - expect(plan.slice(1).map(lane => lane.label)) - .toEqual(SERIAL_FULL_SUITE_FILES.map(file => basename(file))); - }); - test("serial lanes override caller parallelism without changing the main lane", () => { const plan = resolveBunTestPlan(["--parallel=2", "--only-failures"]); expect(plan[0]?.args).toContain("--parallel=2"); @@ -447,102 +401,6 @@ describe("bun test argv", () => { }); }); -describe("test-runner process-tree termination", () => { - test("a premature timer wakeup rearms against the monotonic deadline", async () => { - let now = 0; - let nextHandle = 0; - const scheduled = new Map void; delayMs: number }>(); - const pending = waitWithMonotonicTimeout(new Promise(() => {}), 100, { - now: () => now, - schedule: (callback, delayMs) => { - const handle = ++nextHandle; - scheduled.set(handle, { callback, delayMs }); - return handle; - }, - clear: handle => { scheduled.delete(handle as number); }, - }); - - expect([...scheduled.values()].map(entry => entry.delayMs)).toEqual([100]); - now = 10; - const early = scheduled.get(1); - scheduled.delete(1); - early?.callback(); - expect([...scheduled.values()].map(entry => entry.delayMs)).toEqual([90]); - now = 100; - const deadline = scheduled.get(2); - scheduled.delete(2); - deadline?.callback(); - expect(await pending).toBeNull(); - expect(scheduled.size).toBe(0); - }); - - test("rejects invalid process IDs before signaling", async () => { - let signals = 0; - await expect(terminateTestProcessForTests({ - pid: 0, - platform: "linux", - exited: Promise.resolve(0), - signalGroup: () => { signals += 1; }, - })).rejects.toThrow("positive safe integer"); - expect(signals).toBe(0); - }); - - test.if(process.platform !== "win32")( - "timeout and forwarded signals reap the complete child process group", - async () => { - const root = mkdtempSync(join(tmpdir(), "opencodex-test-tree-")); - const controllerPath = fixturePath("test-runner-tree-controller.ts"); - const controllers: Bun.Subprocess[] = []; - try { - for (const [mode, expected] of [["timeout", 124], ["SIGTERM", 143]] as const) { - const markerPath = join(root, `${mode}.json`); - const controller = Bun.spawn([process.execPath, controllerPath, mode, markerPath], { - cwd: repoRoot(), - stdout: "pipe", - stderr: "pipe", - }); - controllers.push(controller); - await waitForFile(markerPath); - if (mode === "SIGTERM") controller.kill(mode); - expect(await exitWithin(controller)).toBe(expected); - await expectProcessTreeDead(markerPath); - } - } finally { - for (const controller of controllers) { - if (!processIsAlive(controller.pid)) continue; - controller.kill("SIGKILL"); - await exitWithin(controller).catch(() => {}); - } - for (const mode of ["timeout", "SIGTERM"] as const) { - const markerPath = join(root, `${mode}.json`); - if (!existsSync(markerPath)) continue; - const { child } = JSON.parse(await Bun.file(markerPath).text()) as { child: number }; - try { process.kill(-child, "SIGKILL"); } catch { /* already dead */ } - } - rmSync(root, { recursive: true, force: true }); - } - }, - ); - - test("the injectable lane runner still terminates before returning", async () => { - let terminations = 0; - const exitCode = await runTestLaneForTests( - { label: "timeout fixture", args: [], timeoutMs: 5 }, - "timeout-fixture", - { - command: [process.execPath, "-e", "await new Promise(() => {})"], - terminateProcess: async child => { - terminations += 1; - child.kill("SIGKILL"); - await child.exited; - }, - }, - ); - expect(exitCode).toBe(124); - expect(terminations).toBe(1); - }); -}); - describe("bun test user lock", () => { test("distinct POSIX users receive distinct temp-runtime locks", () => { const common = { env: {}, tempDir: "/tmp", hostName: "builder-1", platform: "linux" as const }; @@ -801,6 +659,60 @@ describe("bun test user lock", () => { expect(resolveCalls).toBe(0); }); + test.if(process.platform === "win32" && process.env[TEST_RUN_NO_QUEUE_ENV] !== "1")( + "nested Windows Bun tests inherit the acquired live lock and refuse an incomplete capability", + () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-nested-test-")); + try { + const lockPath = process.env[TEST_RUN_LOCK_PATH_ENV]; + expect(Boolean(lockPath && process.env[TEST_RUN_LOCK_TOKEN_ENV] && process.env[TEST_RUN_ID_ENV])).toBe(true); + const ownerBefore = readFileSync(join(lockPath!, "owner.json"), "utf8"); + const fixture = join(root, "nested.test.ts"); + writeFileSync(fixture, ` + import { test } from "bun:test"; + import { readFileSync, existsSync } from "node:fs"; + import { join } from "node:path"; + test("nested lock receipt", () => { + const path = process.env.OCX_TEST_RUN_LOCK_PATH; + const owner = JSON.parse(readFileSync(join(path, "owner.json"), "utf8")); + console.log(JSON.stringify({ nestedLockReceipt: { + samePath: path === ${JSON.stringify(lockPath)}, + sameRun: owner.runId === ${JSON.stringify(process.env[TEST_RUN_ID_ENV])}, + sameToken: owner.token === process.env.OCX_TEST_RUN_LOCK_TOKEN, + member: existsSync(join(path, "members", process.pid + "-" + owner.token)), + preloadRan: process.env.OCX_TEST_PRELOAD_PID === String(process.pid), + guardArmed: process.env.OCX_TEST_HOME_GUARD === "1", + } })); + }); + `); + const args = ["test", "--preload", repoPath("tests/preload.ts"), fixture]; + const child = spawnSync(process.execPath, args, { + cwd: root, env: { ...process.env }, encoding: "utf8", timeout: INTERNAL_DEADLINE_MS, + }); + // Keep process diagnostics bounded and never render the owner token or child output. + expect(child.status).toBe(0); + const marker = child.stdout.split("\n").find(line => line.startsWith('{"nestedLockReceipt":')); + expect(marker ? JSON.parse(marker).nestedLockReceipt : null).toEqual({ + samePath: true, sameRun: true, sameToken: true, member: true, preloadRan: true, guardArmed: true, + }); + expect(readFileSync(join(lockPath!, "owner.json"), "utf8") === ownerBefore).toBe(true); + + const incomplete = { ...process.env }; + delete incomplete[TEST_RUN_LOCK_TOKEN_ENV]; + const refused = spawnSync(process.execPath, args, { + cwd: root, env: incomplete, encoding: "utf8", timeout: INTERNAL_DEADLINE_MS, + }); + expect(refused.status).toBe(1); + expect(refused.stderr.includes("capability is incomplete")).toBe(true); + expect(refused.stdout.includes('{"nestedLockReceipt":')).toBe(false); + expect(readFileSync(join(lockPath!, "owner.json"), "utf8") === ownerBefore).toBe(true); + } finally { + removeTreeWithRetry(root); + } + }, + { timeout: SPAWN_BUDGET_MS }, + ); + test("falls back from an unsafe XDG root to a validated mode-0700 UID directory", () => { if (process.platform === "win32" || typeof process.getuid !== "function") return; const root = mkdtempSync(join(tmpdir(), "opencodex-runtime-fallback-")); @@ -879,13 +791,9 @@ describe("bun test user lock", () => { test("one run owns the lock while sibling workers with its run ID join", async () => { const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); const lockPath = join(root, "suite.lock"); - // Lock semantics must not depend on the environment of the runner that is - // executing this test file. Hosted CI deliberately disables its redundant - // outer queue, while these unit cases still need to exercise the queue. - const env: NodeJS.ProcessEnv = {}; try { - const owner = await acquireTestRunLock({ runId: "suite-a", lockPath, pollMs: 5, maxWaitMs: 50, env }); - const sibling = await acquireTestRunLock({ runId: "suite-a", lockPath, pollMs: 5, maxWaitMs: 50, env }); + const owner = await acquireTestRunLock({ runId: "suite-a", lockPath, pollMs: 5, maxWaitMs: 50 }); + const sibling = await acquireTestRunLock({ runId: "suite-a", lockPath, pollMs: 5, maxWaitMs: 50 }); expect(owner.acquired).toBe(true); expect(sibling.acquired).toBe(false); sibling.release(); @@ -900,15 +808,13 @@ describe("bun test user lock", () => { test("an inherited worker can only join the exact live wrapper owner", async () => { const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); const lockPath = join(root, "suite.lock"); - const env: NodeJS.ProcessEnv = {}; try { - const owner = await acquireTestRunLock({ runId: "wrapped", lockPath, pollMs: 5, maxWaitMs: 50, env }); + const owner = await acquireTestRunLock({ runId: "wrapped", lockPath, pollMs: 5, maxWaitMs: 50 }); expect(owner.owner).not.toBeNull(); const sibling = await acquireTestRunLock({ runId: "wrapped", lockPath, joinExistingOwnerToken: owner.owner!.token, - env, }); expect(sibling.acquired).toBe(false); const wrongToken = owner.owner!.token === "57f44b0e-b750-4bd2-b23d-4a035e75da18" @@ -919,7 +825,6 @@ describe("bun test user lock", () => { runId: "wrapped", lockPath, joinExistingOwnerToken: wrongToken, - env, })).rejects.toThrow("refusing to create or reclaim"); owner.release(); @@ -928,7 +833,6 @@ describe("bun test user lock", () => { runId: "wrapped", lockPath, joinExistingOwnerToken: owner.owner!.token, - env, })).rejects.toThrow("refusing to create or reclaim"); expect(existsSync(lockPath)).toBe(false); } finally { @@ -939,7 +843,6 @@ describe("bun test user lock", () => { test("a dead owner is reclaimed even when the next bare invocation derives the same run ID", async () => { const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); const lockPath = join(root, "suite.lock"); - const env: NodeJS.ProcessEnv = {}; try { const stale = await acquireTestRunLock({ runId: "stale", @@ -947,9 +850,8 @@ describe("bun test user lock", () => { lockPath, pollMs: 5, maxWaitMs: 50, - env, }); - const replacement = await acquireTestRunLock({ runId: "stale", lockPath, pollMs: 5, maxWaitMs: 50, env }); + const replacement = await acquireTestRunLock({ runId: "stale", lockPath, pollMs: 5, maxWaitMs: 50 }); expect(replacement.acquired).toBe(true); stale.release(); expect(existsSync(lockPath)).toBe(true); @@ -963,16 +865,14 @@ describe("bun test user lock", () => { test("a live competing run fails closed after the bounded wait", async () => { const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); const lockPath = join(root, "suite.lock"); - const env: NodeJS.ProcessEnv = {}; try { - const owner = await acquireTestRunLock({ runId: "live", lockPath, pollMs: 5, maxWaitMs: 50, env }); + const owner = await acquireTestRunLock({ runId: "live", lockPath, pollMs: 5, maxWaitMs: 50 }); let waits = 0; await expect(acquireTestRunLock({ runId: "blocked", lockPath, pollMs: 5, maxWaitMs: 20, - env, onWait: () => { waits += 1; }, })).rejects.toThrow("timed out"); expect(waits).toBe(1); diff --git a/tests/claude-integration/claude-agent-startup-sync.test.ts b/tests/claude-integration/claude-agent-startup-sync.test.ts index 33d374eacb..f41bef5219 100644 --- a/tests/claude-integration/claude-agent-startup-sync.test.ts +++ b/tests/claude-integration/claude-agent-startup-sync.test.ts @@ -17,6 +17,66 @@ const config = (claudeCode: OcxConfig["claudeCode"] = {}): OcxConfig => ({ } as OcxConfig); describe("Claude agent roster proxy-start synchronization (#2200)", () => { + test("keeps readiness pending until the fourth registry callback settles", async () => { + const gate = createReadinessGate(); + let releaseRegistry!: () => void; + let enterRegistry!: () => void; + const entered = new Promise(resolve => { enterRegistry = resolve; }); + const pending = new Promise(resolve => { releaseRegistry = resolve; }); + const result = { ran: true }; + const startup = reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markReady(); return result; }, + async () => undefined, + async () => { enterRegistry(); await pending; }, + ); + await entered; + expect(gate.getStatus()).toBe("pending"); + releaseRegistry(); + expect(await startup).toBe(result); + expect(gate.getStatus()).toBe("ready"); + }); + + test("registry initialization cannot reverse a failed Codex readiness verdict", async () => { + const gate = createReadinessGate(); + let registryRan = false; + await reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markFailed(); return { ran: true }; }, + async () => undefined, + async () => { expect(gate.getStatus()).toBe("failed"); registryRan = true; }, + ); + expect(registryRan).toBe(true); + expect(gate.getStatus()).toBe("failed"); + }); + + test("a best-effort registry callback can handle failure before readiness opens", async () => { + const gate = createReadinessGate(); + let handled = false; + await reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markReady(); }, + async () => undefined, + async () => { + try { throw new Error("registry unavailable"); } + catch { handled = true; expect(gate.getStatus()).toBe("pending"); } + }, + ); + expect(handled).toBe(true); + expect(gate.getStatus()).toBe("ready"); + }); + + test("an unhandled registry callback error remains visible and cannot mark ready", async () => { + const gate = createReadinessGate(); + await expect(reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markReady(); }, + async () => undefined, + async () => { throw new Error("unexpected registry failure"); }, + )).rejects.toThrow("unexpected registry failure"); + expect(gate.getStatus()).toBe("pending"); + }); + test("keeps readiness pending until the best-effort roster fence settles", async () => { const gate = createReadinessGate(); let releaseRoster!: () => void; diff --git a/tests/claude-integration/claude-agents-inject.test.ts b/tests/claude-integration/claude-agents-inject.test.ts index 9eb36e85b1..b3251312c2 100644 --- a/tests/claude-integration/claude-agents-inject.test.ts +++ b/tests/claude-integration/claude-agents-inject.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildClaudeAgentDefs, injectClaudeAgentDefs, syncClaudeAgentDefs } from "../../src/claude/agents-inject"; import { buildClaudeContextWindows } from "../../src/claude/context-windows"; +import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; import { fetchProviderModels } from "../../src/codex/catalog/provider-fetch"; import { OAUTH_PROVIDERS } from "../../src/oauth"; import type { OcxConfig } from "../../src/types"; @@ -338,3 +339,40 @@ describe("syncClaudeAgentDefs ownership contract (audit 071 #2/#3)", () => { expect(readdirSync(join(dir, "agents"))).toEqual([]); }); }); + + +test("stale Desktop selectors retain the roster and configured blocked skills", () => { + const model = "claude-opus-4-8-20260702"; + buildDesktop3pRegistry([], []); + try { + const directory = tempDir(); + writeFileSync(join(directory, "settings.json"), JSON.stringify({ model })); + const defs = buildClaudeAgentDefs(cfg({ + subagentModels: [], + claudeCode: { blockedSkills: ["restricted-test-skill"] }, + }), {}, directory); + const stale = defs.find(def => def.name === "ocx-self"); + expect(stale).toBeDefined(); + expect(stale!.model).toBe(model); + expect(stale!.blockedSkills).toEqual(["restricted-test-skill"]); + expect(defs.length).toBeGreaterThan(0); + } finally { buildDesktop3pRegistry([], []); } +}); + +test("unexpected resolver errors still propagate from roster construction", () => { + const model = "claude-opus-4-8-20260702"; + const modelMap: Record = {}; + // Fault injection at the resolver's exact-map read; only its expected request + // error may be converted into conservative blocked-skill policy. + Object.defineProperty(modelMap, model, { + get() { throw new Error("injected-resolver-failure"); }, + }); + buildDesktop3pRegistry([], []); + try { + const directory = tempDir(); + writeFileSync(join(directory, "settings.json"), JSON.stringify({ model })); + expect(() => buildClaudeAgentDefs(cfg({ + subagentModels: [], claudeCode: { modelMap, blockedSkills: ["restricted-test-skill"] }, + }), {}, directory)).toThrow("injected-resolver-failure"); + } finally { buildDesktop3pRegistry([], []); } +}); diff --git a/tests/claude-integration/claude-code-thought-signature-scope.test.ts b/tests/claude-integration/claude-code-thought-signature-scope.test.ts index 2437a8d157..eb544dce97 100644 --- a/tests/claude-integration/claude-code-thought-signature-scope.test.ts +++ b/tests/claude-integration/claude-code-thought-signature-scope.test.ts @@ -97,16 +97,19 @@ describe("Claude Code Anthropic inbound reasoning-replay scope", () => { const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); expect(parsed._clientThreadId).toBeUndefined(); expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(false); }); test("the shared Desktop prompt_cache_key cohort does not create a scope", async () => { const parsed = await drive({ promptCacheKey: "shared-cohort-key", promptCacheKeyIsSharedCohort: true }); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(true); }); test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { const parsed = await drive({}); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBeUndefined(); }); test("an overlong prompt_cache_key is hashed, not stored raw", async () => { diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts index 20bedaa763..874fcba405 100644 --- a/tests/claude-integration/claude-compatibility.test.ts +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -1,447 +1,113 @@ import { describe, expect, test } from "bun:test"; -import { - analyzeClaudeCompatibility, - collectClaudeFeatureCodes, - isClaudeCompatibilityMode, - resolveClaudeCompatibilityMode, -} from "../../src/claude/compatibility"; - -describe("claude compatibility analyzer (pure, no Lab)", () => { - test("collect: empty body has no codes, beta header ignored when empty", () => { - expect(collectClaudeFeatureCodes({}, undefined)).toEqual([]); - expect(collectClaudeFeatureCodes({}, "")).toEqual([]); - }); - - test("collect: cache_control from any nested block", () => { - const body = { - system: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }], - messages: [{ role: "user", content: "hi" }], - }; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("cache_control"); - }); - - test("collect: context_management top-level", () => { - expect(collectClaudeFeatureCodes({ context_management: { edits: [] } }, undefined)).toContain("context_management"); - }); - - test("collect: thinking_block via thinking param", () => { - expect(collectClaudeFeatureCodes({ thinking: { type: "enabled", budget_tokens: 1000 } }, undefined)).toContain("thinking_block"); - }); - - test("collect: thinking_block via message block type thinking", () => { - const body = { messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "..." }] }] }; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("thinking_block"); - }); - - test("collect: thinking_block via redacted_thinking block", () => { - const body = { messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "x" }] }] }; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("thinking_block"); - }); - - test("collect: server_tool via tool type != function", () => { - const body = { tools: [{ type: "web_search_20250305", name: "web_search", description: "x" }] }; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("web_search_tool"); - }); - - test("collect: server_tool via content block server_tool_use", () => { - const body = { - messages: [{ role: "assistant", content: [{ type: "server_tool_use", id: "1", name: "web_search" }] }], - }; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("web_search_tool"); - }); - - test("collect: deferred_tools via tools.defer true", () => { - const body = { tools: [{ name: "foo", input_schema: { type: "object" }, defer: true }] }; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("deferred_tools"); - }); - - test("collect: deferred_tools via top-level flag", () => { - expect(collectClaudeFeatureCodes({ deferred_tools: true } as unknown as Record, undefined)).toContain("deferred_tools"); - }); - - test("collect: structured_output via output_config.format json_schema", () => { - const body = { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("structured_output"); - - const topLevelBody = { output_format: { type: "json_schema", schema: { type: "object" } } }; - expect(collectClaudeFeatureCodes(topLevelBody, undefined)).toContain("structured_output"); - expect(collectClaudeFeatureCodes(topLevelBody, undefined)).not.toContain("unknown_body_field"); - - const invalidNestedBody = { output_config: { output_format: { type: "json_schema", schema: { type: "object" } } } }; - expect(collectClaudeFeatureCodes(invalidNestedBody, undefined)).not.toContain("structured_output"); - expect(collectClaudeFeatureCodes(invalidNestedBody, undefined)).toContain("unknown_body_field"); - const routed = analyzeClaudeCompatibility(invalidNestedBody, { mode: "enforce", adapter: "openai-responses" }); - expect(routed.decision).toBe("reject"); - expect(routed.compatible).toBe(false); - expect(routed.reason).toContain("unknown_body_field"); - const anthropic = analyzeClaudeCompatibility(invalidNestedBody, { mode: "enforce", adapter: "anthropic" }); - expect(anthropic.decision).toBe("allow"); - expect(anthropic.compatible).toBe(true); - }); - - test("collect: structured_output via output_config.format json_object is not advertised (only json_schema is official)", () => { - const body = { output_config: { format: { type: "json_object" } } }; - expect(collectClaudeFeatureCodes(body, undefined)).not.toContain("structured_output"); - // json_schema remains the only official SDK shape (0.122.0/main) and stays advertised - const schemaBody = { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }; - expect(collectClaudeFeatureCodes(schemaBody, undefined)).toContain("structured_output"); - // smallest fail-closed: no structured_output code, so no lossless mapping is claimed for json_object - const r = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }); - expect(r.featureCodes).not.toContain("structured_output"); - }); - - test("collect: beta_* sanitized, sorted, de-duped", () => { - const codes = collectClaudeFeatureCodes({}, "context-1m-2025-08-07, effort-2025-11-24 , context-1m-2025-08-07"); - expect(codes).toEqual(["beta_context_1m_2025_08_07", "beta_effort_2025_11_24"]); - }); - - test("collect: beta token sanitizes non-alphanum to _ and trims", () => { - const codes = collectClaudeFeatureCodes({}, " Foo-Bar.Baz__ "); - expect(codes).toEqual(["beta_foo_bar_baz"]); - }); - - test("collect: incompatible body + beta combined, sorted stable", () => { - const body = { context_management: {}, tools: [{ type: "web_search_20250305", name: "ws" }] }; - const codes = collectClaudeFeatureCodes(body, "beta-1"); - expect(codes).toEqual(["beta_beta_1", "context_management", "web_search_tool"]); - }); - - // ── mode resolution ── - test("isClaudeCompatibilityMode: only shadow|enforce", () => { - expect(isClaudeCompatibilityMode("shadow")).toBe(true); - expect(isClaudeCompatibilityMode("enforce")).toBe(true); - expect(isClaudeCompatibilityMode("allow")).toBe(false); - expect(isClaudeCompatibilityMode(undefined)).toBe(false); - expect(isClaudeCompatibilityMode("")).toBe(false); - }); - - test("resolveClaudeCompatibilityMode: default enforce with explicit shadow escape", () => { - expect(resolveClaudeCompatibilityMode(undefined)).toBe("enforce"); - expect(resolveClaudeCompatibilityMode({})).toBe("enforce"); - expect(resolveClaudeCompatibilityMode({ compatibility: "shadow" })).toBe("shadow"); - expect(resolveClaudeCompatibilityMode({ compatibility: "enforce" })).toBe("enforce"); - // invalid values fall back to enforce - expect(resolveClaudeCompatibilityMode({ compatibility: "bogus" } as unknown as Record)).toBe("enforce"); - }); - - // ── analyze: compatibility decisions ── - test("analyze: positional cache_control remains diagnostic but does not block translated targets", () => { - const body: Record = { - system: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }], - thinking: { type: "enabled" }, - messages: [{ role: "user", content: "hi" }], - }; - const r = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }); - expect(r.compatible).toBe(true); - expect(r.decision).toBe("allow"); - expect(r.featureCodes).toEqual(expect.arrayContaining(["cache_control", "thinking_block"])); - }); - - test("analyze: context management no-ops ({}, {edits:[]}, keep-all) are routed no-ops", () => { - for (const cm of [ - {}, - { edits: [] }, - { edits: [{ type: "clear_thinking_20251015", keep: "all" }] }, - ]) { - const body = { context_management: cm }; - const result = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "google" }); - expect(result.decision).toBe("allow"); - expect(result.compatible).toBe(true); - expect(result.featureCodes).toContain("context_management"); - } - }); - - test("analyze: context management with unknown keys or removing edits still fails closed", () => { - for (const cm of [ - { edits: [{ type: "clear_thinking_20251015", keep: { type: "thinking_turns", value: 1 } }] }, - { edits: [], extra_key: true }, - { edits: [{ type: "clear_thinking_20251015", keep: "all", extra: 1 }] }, - { edits: [{ type: "unknown_edit_type", keep: "all" }] }, - ]) { - const body = { context_management: cm }; - const result = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "google" }); - expect(result.decision).toBe("reject"); - expect(result.reason).toContain("context_management"); - } - }); - - test("analyze: incompatible features reject in enforce, shadow only records", () => { - const cases: Array<{ body: Record; code: string }> = [ - { body: { context_management: { edits: [{ type: "clear_thinking_20251015", keep: { type: "thinking_turns", value: 1 } }] } }, code: "context_management" }, - { body: { tools: [{ type: "code_execution_20250501", name: "code_execution" }] } as unknown as Record, code: "code_execution" }, - { body: { messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "hi" } }] }] } as unknown as Record, code: "documents" }, - { body: { tools: [{ name: "foo", input_examples: [{ input: "x" }] }] } as unknown as Record, code: "input_examples" }, - ]; - for (const { body, code } of cases) { - const enforce = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }); - expect(enforce.decision).toBe("reject"); - expect(enforce.compatible).toBe(false); - expect(enforce.featureCodes).toContain(code); - expect(enforce.reason).toContain(code); - - const shadow = analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "openai-responses" }); - expect(shadow.decision).toBe("shadow"); - expect(shadow.compatible).toBe(true); - expect(shadow.featureCodes).toContain(code); - expect(shadow.reason).toMatch(/shadow: would reject/); - } - }); - - test("analyze: client-executed MCP function names stay routable, server MCP toolsets do not", () => { - const clientTool = { - tools: [{ type: "function", name: "mcp__codex_app__automation_update", input_schema: { type: "object" } }], - }; - expect(collectClaudeFeatureCodes(clientTool, undefined)).not.toContain("mcp_tool"); - for (const adapter of ["cursor", "google", "openai-responses", "kiro", "openai-chat"]) { - expect(analyzeClaudeCompatibility(clientTool, { mode: "enforce", adapter }).decision).toBe("allow"); - } - - const serverToolset = { tools: [{ type: "mcp_toolset", mcp_server_name: "remote" }] }; - expect(collectClaudeFeatureCodes(serverToolset, undefined)).toContain("mcp_tool"); - expect(analyzeClaudeCompatibility(serverToolset, { mode: "enforce", adapter: "cursor" }).decision).toBe("reject"); - }); - - test("analyze: official custom tools and tool-search-like client names stay routable", () => { - const body = { - tools: [ - { type: "custom", name: "calculator", input_schema: { type: "object" } }, - { type: "custom", name: "tool_search_tool_local", input_schema: { type: "object" } }, - ], - messages: [{ - role: "assistant", - content: [{ type: "tool_use", id: "call_1", name: "tool_search_tool_local", input: {} }], - }], - }; - for (const adapter of ["cursor", "google", "openai-responses", "kiro", "openai-chat"]) { - const result = analyzeClaudeCompatibility(body, { mode: "enforce", adapter }); - expect(result.decision).toBe("allow"); - expect(result.featureCodes).not.toContain("server_tool"); - expect(result.featureCodes).not.toContain("tool_search"); - } - }); - - test("analyze: server MCP history is classified as mcp_tool and fails closed", () => { - for (const type of ["mcp_tool_use", "mcp_tool_result"]) { +import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../../src/claude/compatibility"; + +// Shapes from Anthropic's thinking, tool-search, strict-tool-use and Messages docs: +// https://platform.claude.com/docs/en/build-with-claude/thinking +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use +// https://platform.claude.com/docs/en/api/http/messages/create +const userBlock = (block: Record) => ({ messages: [{ role: "user", content: [block] }] }); +const assistantBlock = (block: Record) => ({ messages: [{ role: "assistant", content: [block] }] }); +const functionTool = { name: "lookup", input_schema: { type: "object", properties: {} } }; + +describe("Claude translated compatibility", () => { + const rejected: Array<[string, Record, string[]]> = [ + ["document", userBlock({ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }), ["documents"]], + ["nested document", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "document" }] }), ["documents"]], + ["thinking replay", assistantBlock({ type: "thinking", thinking: "private-fixture", signature: "private-signature" }), ["thinking_replay"]], + ["redacted replay", assistantBlock({ type: "redacted_thinking", data: "opaque-fixture" }), ["thinking_replay"]], + ["hosted search", { tools: [{ type: "tool_search_tool_regex_20251119", name: "tool_search" }] }, ["tool_search"]], + ["hosted search history", assistantBlock({ type: "server_tool_use", name: "tool_search", id: "srv1", input: {} }), ["tool_search"]], + ["search result", userBlock({ type: "tool_search_tool_result", tool_use_id: "srv1", content: { type: "tool_search_tool_search_result", tool_references: [] } }), ["tool_search"]], + ["client search reference", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "tool_reference", tool_name: "lookup" }] }), ["tool_reference"]], + ["deferred tool", { tools: [{ ...functionTool, defer_loading: true }] }, ["deferred_tools"]], + ["strict tool", { tools: [{ ...functionTool, strict: true }] }, ["strict_tools"]], + ["programmatic caller", { tools: [{ ...functionTool, allowed_callers: ["code_execution_20260120"] }] }, ["caller_mode"]], + ["caller replay", assistantBlock({ type: "tool_use", name: "lookup", id: "t1", input: {}, caller: { type: "code_execution_20260120", tool_id: "srv1" } }), ["caller_mode"]], + ["structured output", { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }, ["structured_output"]], + ["service tier", { service_tier: "standard_only" }, ["service_tier"]], + ["MCP connector", { mcp_servers: [{ type: "url", url: "https://example.invalid/mcp", authorization_token: "private-fixture" }] }, ["mcp_tool"]], + ["MCP toolset", { tools: [{ type: "mcp_toolset", mcp_server_name: "example" }] }, ["mcp_tool"]], + ["MCP replay", assistantBlock({ type: "mcp_tool_use", id: "t1", name: "lookup" }), ["mcp_tool"]], + ["web search controls", { tools: [{ type: "web_search_20250305", name: "web_search", allowed_domains: ["example.invalid"], max_uses: 1 }] }, ["web_search_tool"]], + ["web search result", userBlock({ type: "web_search_tool_result", content: [] }), ["web_search_tool"]], + ["code execution", { tools: [{ type: "code_execution_20260120", name: "code_execution" }] }, ["code_execution"]], + ["computer toolset", { tools: [{ type: "computer_toolset_20260801", name: "computer" }] }, ["computer_use"]], + ["context editing", { context_management: { edits: [{ type: "clear_thinking_20251015", keep: "all" }] } }, ["context_management"]], + ["container", { container: "private-container" }, ["container"]], + ["placement", { inference_geo: "us" }, ["inference_geo"]], + ["profile", { user_profile_id: "private-profile" }, ["user_profile"]], + ["unknown body field", { future_option: "private-fixture" }, ["unknown_body_field"]], + ["unknown content", userBlock({ type: "future_block", payload: "private-fixture" }), ["unknown_content_block"]], + ["nested unknown content", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "future_block" }] }), ["unknown_content_block"]], + ["name cannot hide unsupported type", { tools: [{ type: "future_server_tool", name: "tool_search", input_schema: {} }] }, ["server_tool"]], + ]; + for (const [name, body, featureCodes] of rejected) { + test(`rejects ${name} in enforce and observes it in shadow`, () => { + const enforce = analyzeClaudeCompatibility(body, { mode: "enforce" }); + expect(enforce).toMatchObject({ decision: "reject", compatible: false, featureCodes }); + const shadow = analyzeClaudeCompatibility(body, { mode: "shadow" }); + expect(shadow).toMatchObject({ decision: "shadow", compatible: false, featureCodes }); + for (const result of [enforce, shadow]) { + expect(result.reason?.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(result)).not.toContain("private-"); + expect(JSON.stringify(result)).not.toContain("example.invalid"); + } + }); + } + + test("ordinary client names, schemas and arguments are not protocol declarations", () => { + for (const name of ["mcp_lookup", "tool_search", "tool_search_tool_local", "safe_code_execution", "computer"]) { const result = analyzeClaudeCompatibility({ - messages: [{ role: "assistant", content: [{ type }] }], - }, { mode: "enforce", adapter: "openai-responses" }); - expect(result.decision).toBe("reject"); - expect(result.featureCodes).toContain("mcp_tool"); - expect(result.featureCodes).not.toContain("unknown_content_block"); + tools: [{ ...functionTool, name, type: "function", input_schema: { + type: "object", properties: { cache_control: { type: "string" }, strict: { const: true } }, + } }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name, id: "t1", input: { + type: "document", defer_loading: true, mcp_servers: [], + } }] }], + }, { mode: "enforce" }); + expect(result).toEqual({ decision: "allow", compatible: true, featureCodes: [] }); } }); - test("analyze: client-executed function tools containing code_execution stay routable, server code execution does not", () => { - const clientTool = { - tools: [{ type: "function", name: "run_code_execution", input_schema: { type: "object" } }], - }; - expect(collectClaudeFeatureCodes(clientTool, undefined)).not.toContain("code_execution"); - for (const adapter of ["cursor", "google", "openai-responses", "kiro", "openai-chat"]) { - expect(analyzeClaudeCompatibility(clientTool, { mode: "enforce", adapter }).decision).toBe("allow"); - } - - const serverCodeExec = { tools: [{ type: "code_execution_20250501", name: "code_execution" }] }; - expect(collectClaudeFeatureCodes(serverCodeExec, undefined)).toContain("code_execution"); - expect(analyzeClaudeCompatibility(serverCodeExec, { mode: "enforce", adapter: "cursor" }).decision).toBe("reject"); + test("inactive flags and direct callers remain ordinary tools", () => { + expect(analyzeClaudeCompatibility({ + defer_tools: false, deferred_tools: [], + tools: [{ ...functionTool, strict: false, defer_loading: false, allowed_callers: ["direct"] }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name: "lookup", id: "t1", input: {}, caller: { type: "direct" } }] }], + }, { mode: "enforce" })).toEqual({ decision: "allow", compatible: true, featureCodes: [] }); }); - test("analyze: documents nested in tool_result.content reject routed adapters and allow on Anthropic", () => { - const nestedDocBody = { - messages: [{ - role: "user", - content: [{ - type: "tool_result", - tool_use_id: "toolu_123", - content: [ - { type: "text", text: "report output:" }, - { type: "document", source: { type: "text", media_type: "text/plain", data: "data" } }, - ], - }], - }], - }; - expect(collectClaudeFeatureCodes(nestedDocBody, undefined)).toContain("documents"); - for (const adapter of ["cursor", "google", "openai-responses", "kiro", "openai-chat"]) { - const routed = analyzeClaudeCompatibility(nestedDocBody, { mode: "enforce", adapter }); - expect(routed.decision).toBe("reject"); - expect(routed.compatible).toBe(false); - expect(routed.reason).toContain("documents"); + test("cache hints, examples and thinking settings are explicitly tolerated degradation", () => { + for (const thinking of [{ type: "disabled" }, { type: "enabled", budget_tokens: 2048 }, { type: "adaptive" }]) { + expect(analyzeClaudeCompatibility({ + thinking, + system: [{ type: "text", text: "private-fixture", cache_control: { type: "ephemeral", ttl: "1h" } }], + tools: [{ ...functionTool, input_examples: [{ value: "private-example" }] }], + }, { mode: "enforce" })).toEqual({ + decision: "allow", compatible: true, featureCodes: ["cache_control", "input_examples", "thinking_settings"], + }); } - - const anthropic = analyzeClaudeCompatibility(nestedDocBody, { mode: "enforce", adapter: "anthropic" }); - expect(anthropic.decision).toBe("allow"); - expect(anthropic.compatible).toBe(true); - expect(anthropic.featureCodes).toContain("documents"); }); - test("analyze: beta tokens alone never trigger rejection", () => { - const r = analyzeClaudeCompatibility({}, { mode: "enforce", anthropicBeta: "deferred-tools-2025-01-01" }); - expect(r.decision).toBe("allow"); - expect(r.compatible).toBe(true); - expect(r.featureCodes).toEqual(["beta_deferred_tools_2025_01_01"]); + test("header volume cannot hide semantic rejection or persist header text", () => { + const result = analyzeClaudeCompatibility(userBlock({ type: "document" }), { + mode: "enforce", anthropicBeta: Array.from({ length: 100 }, (_, i) => `private-header-${i}`).join(","), + }); + expect(result).toEqual({ + decision: "reject", compatible: false, featureCodes: ["documents", "unknown_beta"], + reason: "unsupported translated Claude features: documents", + }); + expect(analyzeClaudeCompatibility({}, { mode: "enforce", anthropicBeta: "private-header" })).toEqual({ + decision: "allow", compatible: true, featureCodes: ["unknown_beta"], + }); }); - test("analyze: multiple incompatibles listed together in reason", () => { - const body = { - context_management: { edits: [{ type: "clear_thinking_20251015", keep: { type: "thinking_turns", value: 1 } }] }, - tools: [{ type: "code_execution_20250501", name: "code_execution" }], - messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "hi" } }] }], - }; - const r = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "cursor" }); - expect(r.decision).toBe("reject"); - expect(r.reason).toContain("context_management"); - expect(r.reason).toContain("code_execution"); - expect(r.reason).toContain("documents"); - }); - - test("analyze: web_search, structured_output, service_tier, tool_search are compatible (lossless) on routed", () => { - const webSearch = { tools: [{ type: "web_search_20250305", name: "ws" }] } as unknown as Record; - expect(analyzeClaudeCompatibility(webSearch, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - const structured = { output_config: { format: { type: "json_schema", schema: { type: "object" } } } } as unknown as Record; - expect(analyzeClaudeCompatibility(structured, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - const structuredTop = { output_format: { type: "json_schema", schema: { type: "object" } } } as unknown as Record; - expect(analyzeClaudeCompatibility(structuredTop, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - const tier = { service_tier: "standard" } as unknown as Record; - expect(analyzeClaudeCompatibility(tier, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - const ts = { tools: [{ name: "tool_search_tool_bm25", type: "tool_search_tool_bm25_20251119" }] } as unknown as Record; - expect(analyzeClaudeCompatibility(ts, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - }); - - test("analyze: deferred tools require the native Responses adapter", () => { - const body = { tools: [{ name: "lookup", input_schema: { type: "object" }, defer_loading: true }] }; - expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - const cursor = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "cursor" }); - expect(cursor.decision).toBe("reject"); - expect(cursor.reason).toContain("deferred_tools"); - }); - - test("analyze: Anthropic-only and unknown body fields fail closed on translated targets", () => { - for (const [field, code] of [ - ["container", "container"], - ["inference_geo", "inference_geo"], - ["user_profile_id", "user_profile"], - ["future_semantic_option", "unknown_body_field"], - ] as const) { - const result = analyzeClaudeCompatibility({ [field]: {} }, { mode: "enforce", adapter: "openai-responses" }); - expect(result.decision).toBe("reject"); - expect(result.featureCodes).toContain(code); - } - }); - - test("analyze: empty body allow in both modes", () => { - expect(analyzeClaudeCompatibility({}, { mode: "enforce" }).decision).toBe("allow"); - expect(analyzeClaudeCompatibility({}, { mode: "shadow" }).decision).toBe("allow"); - }); - - test("analyze: unknown top-level system block type fails closed on translated routes", () => { - const body = { system: [{ type: "text" as const, text: "hi" }, { type: "future_unknown_block", text: "x" } as unknown as Record] } as unknown as Record; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("unknown_content_block"); - const enforce = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }); - expect(enforce.decision).toBe("reject"); - expect(enforce.compatible).toBe(false); - expect(enforce.featureCodes).toContain("unknown_content_block"); - const shadow = analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "openai-responses" }); - expect(shadow.decision).toBe("shadow"); - expect(shadow.compatible).toBe(true); - const anthropic = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "anthropic" }); - expect(anthropic.decision).toBe("allow"); - expect(anthropic.compatible).toBe(true); - // known text system blocks remain allowed - const ok = { system: [{ type: "text", text: "hello" }] }; - expect(collectClaudeFeatureCodes(ok, undefined)).not.toContain("unknown_content_block"); - expect(analyzeClaudeCompatibility(ok, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - // string system stays allowed - const str = { system: "hello" }; - expect(collectClaudeFeatureCodes(str, undefined)).not.toContain("unknown_content_block"); - }); - - test("analyze: json_object format is not advertised as compatible structured_output", () => { - const jsonObject = { output_config: { format: { type: "json_object" as const } } } as unknown as Record; - expect(collectClaudeFeatureCodes(jsonObject, undefined)).not.toContain("structured_output"); - // even with a schema, json_object is not the official json_schema shape - const withSchema = { output_config: { format: { type: "json_object" as const, schema: { type: "object" } } } } as unknown as Record; - expect(collectClaudeFeatureCodes(withSchema, undefined)).not.toContain("structured_output"); - const enforce = analyzeClaudeCompatibility(jsonObject, { mode: "enforce", adapter: "google" }); - expect(enforce.featureCodes).not.toContain("structured_output"); - // json_schema remains advertised and allowed losslessly - const jsonSchema = { output_config: { format: { type: "json_schema" as const, schema: { type: "object" } } } } as unknown as Record; - expect(analyzeClaudeCompatibility(jsonSchema, { mode: "enforce", adapter: "google" }).decision).toBe("allow"); - expect(analyzeClaudeCompatibility(jsonSchema, { mode: "enforce", adapter: "google" }).featureCodes).toContain("structured_output"); - }); - // ── signed thinking safety invariant ── - test("signed thinking: genuine signature is incompatible and fail-closed even in shadow", () => { - const body = { - messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "chain", signature: "AnthropicSig123" }] }], - } as unknown as Record; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("signed_thinking"); - expect(collectClaudeFeatureCodes(body, undefined)).toContain("thinking_block"); - const enforce = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }); - expect(enforce.decision).toBe("reject"); - expect(enforce.compatible).toBe(false); - expect(enforce.reason).toContain("signed_thinking"); - const shadow = analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "openai-responses" }); - expect(shadow.decision).toBe("reject"); - expect(shadow.compatible).toBe(false); - expect(shadow.reason).toContain("signed_thinking"); - // also rejects on other routed adapters and when adapter is undefined - expect(analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "google" }).decision).toBe("reject"); - expect(analyzeClaudeCompatibility(body, { mode: "shadow" }).decision).toBe("reject"); - }); - - test("signed thinking: Anthropic adapter allows genuine signature", () => { - const body = { - messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "chain", signature: "GenuineSig" }] }], - } as unknown as Record; - const r = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "anthropic" }); - expect(r.decision).toBe("allow"); - expect(r.compatible).toBe(true); - expect(r.featureCodes).toContain("signed_thinking"); - const shadowAnthropic = analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "anthropic" }); - expect(shadowAnthropic.decision).toBe("allow"); - }); - - test("signed thinking: unsigned thinking (no sig, empty sig) remains compatible", () => { - const noSig = { messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "chain" }] }] } as unknown as Record; - const emptySig = { messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "chain", signature: "" }] }] } as unknown as Record; - for (const body of [noSig, emptySig]) { - expect(collectClaudeFeatureCodes(body, undefined)).toContain("thinking_block"); - expect(collectClaudeFeatureCodes(body, undefined)).not.toContain("signed_thinking"); - expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - expect(analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "openai-responses" }).decision).toBe("allow"); + test("mode recognition is exact", () => { + expect(isClaudeCompatibilityMode("shadow")).toBe(true); + expect(isClaudeCompatibilityMode("enforce")).toBe(true); + for (const value of [undefined, null, false, 1, {}, [], "ENFORCE", "enforce ", "invalid"]) { + expect(isClaudeCompatibilityMode(value)).toBe(false); } }); - - test("signed thinking: ocxr1 owned signature is not classified as genuine", () => { - const body = { - messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "chain", signature: "ocxr1:eyJ0eHQiOiJoaSJ9" }] }], - } as unknown as Record; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("thinking_block"); - expect(collectClaudeFeatureCodes(body, undefined)).not.toContain("signed_thinking"); - expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-responses" }).decision).toBe("allow"); - expect(analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "openai-responses" }).decision).toBe("allow"); - }); - - test("signed thinking: malformed non-string signatures fail closed", () => { - const body = { - messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "chain", signature: 123 }] }], - } as unknown as Record; - expect(analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "google" }).decision).toBe("reject"); - }); - - test("signed thinking: redacted_thinking with data is genuine and fail-closed in shadow", () => { - const body = { - messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "redacted-payload" }] }], - } as unknown as Record; - expect(collectClaudeFeatureCodes(body, undefined)).toContain("signed_thinking"); - const enforce = analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "google" }); - expect(enforce.decision).toBe("reject"); - const shadow = analyzeClaudeCompatibility(body, { mode: "shadow", adapter: "google" }); - expect(shadow.decision).toBe("reject"); - expect(shadow.compatible).toBe(false); - // Anthropic allows redacted - expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "anthropic" }).decision).toBe("allow"); - // redacted with empty data is not genuine - const empty = { messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "" }] }] } as unknown as Record; - expect(collectClaudeFeatureCodes(empty, undefined)).not.toContain("signed_thinking"); - expect(analyzeClaudeCompatibility(empty, { mode: "enforce", adapter: "google" }).decision).not.toBe("reject"); - }); }); diff --git a/tests/claude-integration/claude-desktop-cli.test.ts b/tests/claude-integration/claude-desktop-cli.test.ts index 6c810b53da..a33026bf34 100644 --- a/tests/claude-integration/claude-desktop-cli.test.ts +++ b/tests/claude-integration/claude-desktop-cli.test.ts @@ -1,16 +1,31 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyProfile, handleClaudeDesktopCommand } from "../../src/cli/claude-desktop"; +import { applyProfile as applyProfileProduction, handleClaudeDesktopCommand as handleClaudeDesktopCommandProduction, type ApplyProfileDeps } from "../../src/cli/claude-desktop"; +import * as managementApi from "../../src/server/management-api"; import { buildClaudeDesktopState } from "../../src/server/management-api"; -import { loadConfig, saveConfig } from "../../src/config"; +import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import { emptyDesktopProfile } from "../../src/claude/desktop-profile"; +import { applyRemoteDesktopStore, restoreRemoteDesktopStore, writeDesktopDisconnectReceipt, type DesktopDisconnectReceipt } from "../../src/claude/desktop-remote-store"; +import * as lifecycleLock from "../../src/client/lifecycle-lock"; +import { readClientConnectionState, clearClientConnection } from "../../src/client/state"; +import { HubClientError } from "../../src/client/hub-client"; +import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../../src/codex/desired-state"; +import { serviceApiTokenBackupPath, serviceApiTokenFilePath, writeServiceApiTokenFile } from "../../src/lib/service-secrets"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let dir = ""; let previousHome: string | undefined; let previousDesktopDir: string | undefined; +let restoreLocalBuild: (() => void) | undefined; + +const fixtureLock = () => ({ lockPath: join(dir, "lifecycle.sqlite") }); +const applyProfile = (profile: Parameters[0], mode: Parameters[1], deps: ApplyProfileDeps = {}) => + applyProfileProduction(profile, mode, { lifecycleLockDeps: fixtureLock(), ...deps }); +const handleClaudeDesktopCommand = (args: string[], deps: ApplyProfileDeps = {}) => + handleClaudeDesktopCommandProduction(args, { lifecycleLockDeps: fixtureLock(), ...deps }); beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; @@ -28,6 +43,8 @@ beforeEach(() => { }); afterEach(() => { + restoreLocalBuild?.(); + restoreLocalBuild = undefined; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousDesktopDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -35,6 +52,358 @@ afterEach(() => { removeTreeWithRetry(dir); }); +const remoteModels = [{ + name: "claude-opus-4-8-20260203", labelOverride: "Hub-selected model", anthropicFamilyTier: "sonnet" as const, + isFamilyDefault: true, supports1m: true as const, +}]; + +function connectDesktopFixture(blockLocalBuild = true): void { + const { fingerprint } = writeServiceApiTokenFile("ocx_desktop_fixture_token"); + const config = loadConfig(); + config.runtimeRole = "client"; + config.client = { + serverUrl: "https://hub.example.test", managementUrl: "https://hub.example.test", managementTransport: "direct", + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "desktop-key", + tokenFingerprint: fingerprint, protocolVersion: 1, connectedAt: "2026-09-06T00:00:00.000Z", + }; + saveConfig(config); + expect(readClientConnectionState().kind).toBe("connected"); + if (blockLocalBuild) { + const spy = spyOn(managementApi, "buildClaudeDesktopState").mockImplementation(async () => { + throw new Error("connected apply must not build local Desktop state"); + }); + restoreLocalBuild = () => spy.mockRestore(); + } +} + +function pendingRotation(): NonNullable["pendingOperation"]> { + return { kind: "rotate", rotationId: "rotation-fixture", newKeyIssuedAt: "2026-09-06T01:00:00.000Z", oldKeyBackupPath: serviceApiTokenBackupPath() }; +} + +function oldDesktopFile(): string { + mkdirSync(join(dir, "desktop"), { recursive: true }); + const path = join(dir, "desktop", "existing.json"); + writeFileSync(path, "existing Desktop bytes"); + return path; +} + +test.each([ + ["--static", "static"], ["--hybrid", "hybrid"], ["--discovery-only", "discovery"], +] as const)("connected CLI %s applies exact hub IDs without local reconciliation", async (flag, mode) => { + connectDesktopFixture(); + setIntegrationEnabled("claude-desktop", false); + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + let writtenPath = ""; + let downloads = 0; + try { + expect(await handleClaudeDesktopCommand(["apply", flag], { + downloadDesktop3pModelsImpl: async (url, token) => { + downloads++; + expect(url).toBe("https://hub.example.test"); + expect(token).toBe("ocx_desktop_fixture_token"); + expect(claudeDesktopIntegrationEnabledNow()).toBe(true); + return { version: 1, models: remoteModels }; + }, + applyRemoteDesktopStoreImpl: (held, options) => { + expect(options).toEqual({ baseUrl: "https://hub.example.test", apiKey: "ocx_desktop_fixture_token", mode, models: remoteModels, + owner: { serverUrl: "https://hub.example.test", apiKeyId: "desktop-key", connectedAt: "2026-09-06T00:00:00.000Z" }, + expectedTokenFingerprint: loadConfig().client!.tokenFingerprint }); + const result = applyRemoteDesktopStore(held, options); + writtenPath = result.ok ? result.path ?? "" : ""; + return result; + }, + findLiveProxyImpl: async () => { throw new Error("must not look for local proxy"); }, + postApplyImpl: async () => { throw new Error("must not call local management"); }, + probeClaudeDesktopPolicy: () => "present", + })).toBe(0); + expect(downloads).toBe(1); + const written = JSON.parse(readFileSync(writtenPath, "utf8")); + expect(written.inferenceGatewayBaseUrl).toBe("https://hub.example.test"); + expect(written.inferenceGatewayApiKey).toBe("ocx_desktop_fixture_token"); + expect(written.inferenceModels).toEqual(mode === "discovery" ? undefined : remoteModels); + expect(loadConfig().claudeCode?.desktopProfile).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Windows managed Claude policy is active")); + expect(error).not.toHaveBeenCalled(); + } finally { log.mockRestore(); warn.mockRestore(); error.mockRestore(); } +}); + +test.each([ + ["absent", "client_token_absent"], + ["unsafe", "client_token_unsafe"], + ["mismatch", "client_token_mismatch"], + ["pending", "client_rotation_pending"], + ["invalid", "client_connection_invalid"], + ["mismatched", "client_connection_invalid"], +] as const)( + "connected apply rejects %s state before download or writing", async (fault, reason) => { + connectDesktopFixture(); + setIntegrationEnabled("claude-desktop", false); + const oldPath = oldDesktopFile(); + writeFileSync(serviceApiTokenBackupPath(), "backup must remain"); + const config = loadConfig(); + if (fault === "absent" || fault === "unsafe") unlinkSync(serviceApiTokenFilePath()); + if (fault === "unsafe") mkdirSync(serviceApiTokenFilePath()); + if (fault === "mismatch") writeFileSync(serviceApiTokenFilePath(), "different-token"); + if (fault === "pending") { config.client!.pendingOperation = pendingRotation(); saveConfig(config); } + if (fault === "invalid") writeFileSync(getConfigPath(), "{invalid-config"); + if (fault === "mismatched") writeFileSync(getConfigPath(), JSON.stringify({ ...config, runtimeRole: "hub" })); + const configBefore = readFileSync(getConfigPath(), "utf8"); + let downloads = 0; + let writes = 0; + const result = await applyProfile(emptyDesktopProfile(), "static", { + downloadDesktop3pModelsImpl: async () => { downloads++; return { version: 1, models: remoteModels }; }, + applyRemoteDesktopStoreImpl: () => { writes++; return { ok: true, changed: true, status: "applied", path: oldPath, restartRequired: true }; }, + }); + expect(result).toEqual({ ok: false, path: "", reason }); + expect(downloads).toBe(0); + expect(writes).toBe(0); + expect(readFileSync(getConfigPath(), "utf8")).toBe(configBefore); + if (fault === "absent" || fault === "unsafe" || fault === "mismatch") { + expect(claudeDesktopIntegrationEnabledNow()).toBe(false); + } + expect(readFileSync(oldPath, "utf8")).toBe("existing Desktop bytes"); + expect(readFileSync(serviceApiTokenBackupPath(), "utf8")).toBe("backup must remain"); + }, +); + +test.each(["empty", "failed"])("connected CLI handles %s snapshot without claiming a saved local profile", async outcome => { + connectDesktopFixture(); + const oldPath = oldDesktopFile(); + const error = spyOn(console, "error").mockImplementation(() => {}); + let writes = 0; + try { + expect(await handleClaudeDesktopCommand(["apply"], { + downloadDesktop3pModelsImpl: async () => { + if (outcome === "failed") throw new HubClientError("desktop_snapshot_unsupported", "remote-marker"); + return { version: 1, models: [] }; + }, + applyRemoteDesktopStoreImpl: () => { writes++; return { ok: true, changed: true, status: "applied", path: oldPath, restartRequired: true }; }, + })).toBe(1); + expect(writes).toBe(0); + expect(readFileSync(oldPath, "utf8")).toBe("existing Desktop bytes"); + expect(loadConfig().claudeCode?.desktopProfile).toBeUndefined(); + const output = error.mock.calls.flat().join(" "); + expect(output).toContain(outcome === "empty" ? "desktop_unavailable" : "desktop_snapshot_unsupported"); + expect(output).not.toContain("프로필은 저장"); + expect(output).not.toContain("remote-marker"); + expect(output).not.toContain("ocx_desktop_fixture_token"); + } finally { error.mockRestore(); } +}); + +test.each(["off", "server", "key", "fingerprint", "connectedAt", "disconnect", "pending", "token", "invalid"])( + "connected apply fences a %s transition during download", async transition => { + connectDesktopFixture(); + const oldPath = oldDesktopFile(); + writeFileSync(serviceApiTokenBackupPath(), "backup must remain"); + let started!: () => void; + const downloading = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const downloadGate = new Promise(resolve => { release = resolve; }); + let writes = 0; + const applying = applyProfile(emptyDesktopProfile(), "static", { + downloadDesktop3pModelsImpl: async () => { started(); await downloadGate; return { version: 1, models: remoteModels }; }, + applyRemoteDesktopStoreImpl: () => { writes++; return { ok: true, changed: true, status: "applied", path: oldPath, restartRequired: true }; }, + }); + await downloading; + try { + const config = loadConfig(); + if (transition === "off") setIntegrationEnabled("claude-desktop", false); + else if (transition === "token") writeFileSync(serviceApiTokenFilePath(), "different-token"); + else if (transition === "invalid") writeFileSync(getConfigPath(), "{invalid-config"); + else { + if (transition === "server") config.client!.serverUrl = "https://other.example.test"; + if (transition === "key") config.client!.apiKeyId = "other-key"; + if (transition === "fingerprint") config.client!.tokenFingerprint = "1".repeat(64); + if (transition === "connectedAt") config.client!.connectedAt = "2026-09-06T02:00:00.000Z"; + if (transition === "pending") config.client!.pendingOperation = pendingRotation(); + if (transition === "disconnect") { config.runtimeRole = "standalone"; delete config.client; } + saveConfig(config); + } + } finally { release(); } + expect(await applying).toMatchObject({ ok: false, reason: transition === "off" ? "desired_state_changed" : "client_connection_changed" }); + expect(writes).toBe(0); + expect(readFileSync(oldPath, "utf8")).toBe("existing Desktop bytes"); + expect(readFileSync(serviceApiTokenBackupPath(), "utf8")).toBe("backup must remain"); + if (transition === "off") expect(claudeDesktopIntegrationEnabledNow()).toBe(false); + }, +); + +test("a prepared disconnect receipt rejects connected apply after its download", async () => { + connectDesktopFixture(); + const oldPath = oldDesktopFile(); + let writes = 0; + const result = await applyProfile(emptyDesktopProfile(), "static", { + downloadDesktop3pModelsImpl: async () => { + const connection = loadConfig().client!; + lifecycleLock.withClientLifecycleSync(held => writeDesktopDisconnectReceipt(held, null, { + version: 1, owner: { serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt }, + tokenFingerprint: connection.tokenFingerprint, keepCatalog: false, phase: "prepared", + }), fixtureLock()); + return { version: 1, models: remoteModels }; + }, + applyRemoteDesktopStoreImpl: () => { writes++; return { ok: true, changed: true, status: "applied", path: oldPath, restartRequired: true }; }, + }); + expect(result).toMatchObject({ ok: false, reason: "client_disconnect_pending" }); + expect(writes).toBe(0); + expect(readFileSync(oldPath, "utf8")).toBe("existing Desktop bytes"); +}); + +test("remote import --apply refuses before saving or building a local profile", async () => { + connectDesktopFixture(); + const source = join(dir, "import.json"); + writeFileSync(source, JSON.stringify(emptyDesktopProfile())); + const before = readFileSync(getConfigPath(), "utf8"); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["import", source, "--apply"])).toBe(2); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(error.mock.calls.flat().join(" ")).toContain("hub profile"); + } finally { error.mockRestore(); } +}); + +test("import --apply also refuses a connection established while local reconciliation awaited", async () => { + const localState = await buildClaudeDesktopState(loadConfig()); + const source = join(dir, "import.json"); + writeFileSync(source, JSON.stringify(emptyDesktopProfile())); + let builds = 0; + const build = spyOn(managementApi, "buildClaudeDesktopState").mockImplementation(async () => { + if (++builds === 2) connectDesktopFixture(false); + return localState; + }); + const error = spyOn(console, "error").mockImplementation(() => {}); + let downloads = 0; + try { + expect(await handleClaudeDesktopCommand(["import", source, "--apply"], { + downloadDesktop3pModelsImpl: async () => { downloads++; return { version: 1, models: [] }; }, + })).toBe(2); + expect(builds).toBe(2); + expect(downloads).toBe(0); + expect(loadConfig().claudeCode?.desktopProfile).toBeUndefined(); + expect(readClientConnectionState().kind).toBe("connected"); + } finally { build.mockRestore(); error.mockRestore(); } +}); + +test.each([ + ["move", "rotation"], ["default", "rotation"], ["import", "rotation"], + ["move", "disconnect"], ["default", "disconnect"], ["import", "disconnect"], +] as const)("delayed %s cannot overwrite a %s transition", async (command, transition) => { + connectDesktopFixture(false); + const capturedState = await buildClaudeDesktopState(loadConfig()); + const originalProfile = structuredClone(loadConfig().claudeCode?.desktopProfile); + const source = join(dir, "profile-race.json"); + writeFileSync(source, JSON.stringify(emptyDesktopProfile())); + const args = command === "move" ? ["move", "mock/test-model", "sonnet"] + : command === "default" ? ["default", "opus", "mock/test-model"] : ["import", source]; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const build = spyOn(managementApi, "buildClaudeDesktopState").mockImplementation(async () => { + entered(); await gate; return capturedState; + }); + const error = spyOn(console, "error").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const pending = handleClaudeDesktopCommand(args); + try { + await started; + if (transition === "rotation") { + const current = loadConfig(); + writeFileSync(serviceApiTokenBackupPath(), readFileSync(serviceApiTokenFilePath()), { mode: 0o600 }); + current.client!.pendingOperation = pendingRotation(); + saveConfig(current); + } else { + // Construct an interrupted disconnect after its own token/state cleanup using + // only this fixture's OCX files. The awaited local edit must not resurrect client. + const current = loadConfig().client!; + const owner = { serverUrl: current.serverUrl, apiKeyId: current.apiKeyId, connectedAt: current.connectedAt }; + lifecycleLock.withClientLifecycleSync(held => { + let receipt: DesktopDisconnectReceipt = { version: 1, owner, tokenFingerprint: current.tokenFingerprint, keepCatalog: false, phase: "prepared" }; + writeDesktopDisconnectReceipt(held, null, receipt); + const restored = restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [current.tokenFingerprint] }); + expect(restored.ok).toBe(true); + const advance = (phase: DesktopDisconnectReceipt["phase"], fields: Partial = {}) => { + const next = { ...receipt, ...fields, phase }; + writeDesktopDisconnectReceipt(held, receipt, next); receipt = next; + }; + advance("desktop_restored"); + advance("catalog_settled", { catalogAfter: { kind: "absent" } }); + advance("removing_token"); unlinkSync(serviceApiTokenFilePath()); + advance("token_removed"); advance("clearing_connection"); + expect(clearClientConnection(owner)).toBe("committed"); + }, fixtureLock()); + } + const afterTransition = readFileSync(getConfigPath(), "utf8"); + release(); + expect(await pending).toBe(1); + expect(readFileSync(getConfigPath(), "utf8")).toBe(afterTransition); + expect(loadConfig().claudeCode?.desktopProfile).toEqual(originalProfile); + if (transition === "rotation") expect(loadConfig().client?.pendingOperation).toEqual(pendingRotation()); + else { + expect(readClientConnectionState().kind).toBe("disconnected"); + expect(existsSync(serviceApiTokenFilePath())).toBe(false); + } + } finally { release(); await pending; build.mockRestore(); error.mockRestore(); warn.mockRestore(); } +}); + +test("local profile mutation preserves unrelated current settings after its builder await", async () => { + connectDesktopFixture(false); + const capturedState = await buildClaudeDesktopState(loadConfig()); + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const build = spyOn(managementApi, "buildClaudeDesktopState").mockImplementation(async () => { entered(); await gate; return capturedState; }); + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const pending = handleClaudeDesktopCommand(["move", "mock/test-model", "sonnet"]); + try { + await started; + const latest = loadConfig(); + latest.port = 20202; + latest.clientIntegrations = { ...latest.clientIntegrations, grok: false }; + saveConfig(latest); + release(); + expect(await pending).toBe(0); + expect(loadConfig().port).toBe(20202); + expect(loadConfig().clientIntegrations?.grok).toBe(false); + expect(loadConfig().claudeCode?.desktopProfile?.assignments["mock/test-model"]?.family).toBe("sonnet"); + } finally { release(); await pending; build.mockRestore(); log.mockRestore(); warn.mockRestore(); } +}); + +test("connected show/export and local edits identify the local profile view", async () => { + connectDesktopFixture(false); + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["show", "--json"])).toBe(0); + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0])).scope).toBe("local"); + const target = join(dir, "export.json"); + expect(await handleClaudeDesktopCommand(["export", target])).toBe(0); + expect(JSON.parse(readFileSync(target, "utf8")).version).toBe(1); + expect(await handleClaudeDesktopCommand(["move", "mock/test-model", "sonnet"])).toBe(0); + expect(await handleClaudeDesktopCommand(["default", "sonnet", "mock/test-model"])).toBe(0); + expect(warn.mock.calls).toHaveLength(4); + expect(warn.mock.calls.every(call => String(call[0]).includes("Local client profile only"))).toBe(true); + } finally { log.mockRestore(); warn.mockRestore(); } +}); + +test("a disconnected hub retains local apply instead of downloading a remote snapshot", async () => { + const config = loadConfig(); + config.runtimeRole = "hub"; + saveConfig(config); + expect(readClientConnectionState().kind).toBe("disconnected"); + const deps: ApplyProfileDeps = { + findLiveProxyImpl: async () => ({ pid: 4242, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + postApplyImpl: async () => ({ ok: true, path: "/local-daemon" }), + downloadDesktop3pModelsImpl: async () => { throw new Error("must not download for a disconnected hub"); }, + }; + expect(await applyProfile(undefined, "static", deps)).toMatchObject({ ok: true, path: "/local-daemon" }); + expect(loadConfig().claudeCode?.desktopProfile).toBeDefined(); +}); + test("show --json, move, default and export use the same persisted profile", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); const error = spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/claude-integration/claude-desktop-discovery.test.ts b/tests/claude-integration/claude-desktop-discovery.test.ts new file mode 100644 index 0000000000..26789af313 --- /dev/null +++ b/tests/claude-integration/claude-desktop-discovery.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { buildDesktopDiscoveryInputs } from "../../src/claude/desktop-discovery-inputs"; +import { buildDesktop3pRegistry, generateDesktop3pModels, resolveDesktop3pAlias } from "../../src/claude/desktop-3p"; +import { parseDesktopProfile } from "../../src/claude/desktop-profile"; +import { desktopVisibleNativeSlugs, type CatalogModel } from "../../src/codex/catalog"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import type { CodexModelEntitlementSnapshot } from "../../src/codex/model-entitlements"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const emptyEntitlements = (): CodexModelEntitlementSnapshot => ({ + modelsByAccount: new Map(), clientVersionByAccount: new Map(), + confirmedAccountIds: new Set(), credentialIdentities: new Map(), +}); + +function projectionConfig(mode: "direct" | "pool" = "pool"): OcxConfig { + return { + port: 0, defaultProvider: "test", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://example.test/v1", codexAccountMode: mode }, + test: { adapter: "openai-chat", baseUrl: "https://example.test/v1", selectedModels: ["model-123", "model-155"] }, + }, + subagentModels: ["test/model-155"], + providerContextCaps: { openai: 272_000 }, + } as OcxConfig; +} + +describe("shared Desktop discovery inputs", () => { + afterEach(() => buildDesktop3pRegistry([], [])); + + test("filters direct grants against main and preserves routed metadata and input arrays", () => { + const snapshot: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([ + [MAIN_CODEX_ACCOUNT_ID, new Set()], + ["pool-fixture", new Set(["gpt-daybreak-blue-latest"])], + ]), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID, "pool-fixture"]), + credentialIdentities: new Map(), + }; + const rows: CatalogModel[] = [ + { provider: "test", id: "model-123", contextWindow: 200_000 }, + { provider: "test", id: "not-selected" }, + { provider: "test", id: "model-155", reasoningEfforts: ["low", "high"], contextWindow: 1_000_000, inputModalities: ["text", "image"] }, + ]; + const before = structuredClone(rows); + const candidates = Object.freeze(["gpt-5.6-sol", "gpt-daybreak-blue-latest"]); + const direct = buildDesktopDiscoveryInputs({ + config: projectionConfig("direct"), models: rows, + modelEntitlements: snapshot, desktopNativeCandidates: candidates, + }); + expect(direct.nativeSlugs).toEqual(["gpt-5.6-sol"]); + expect(direct.routedModels.map(row => row.id)).toEqual(["model-155", "model-123"]); + expect(direct.routedModels[0]).toEqual(before[2]); + expect(direct.nativeContextCap.cap).toBe(272_000); + const pooled = buildDesktopDiscoveryInputs({ + config: projectionConfig("pool"), models: rows, + modelEntitlements: snapshot, desktopNativeCandidates: candidates, + }); + expect(pooled.nativeSlugs).toEqual(["gpt-5.6-sol", "gpt-daybreak-blue-latest"]); + expect(rows).toEqual(before); + expect(candidates).toEqual(["gpt-5.6-sol", "gpt-daybreak-blue-latest"]); + }); + + test("respects Desktop native opt-out and disabled routed selections", () => { + const config = projectionConfig(); + config.claudeCode = { desktopNativeModels: false }; + config.disabledModels = ["test/model-155"]; + const result = buildDesktopDiscoveryInputs({ + config, modelEntitlements: emptyEntitlements(), + desktopNativeCandidates: desktopVisibleNativeSlugs(config), + models: [{ provider: "test", id: "model-123" }, { provider: "test", id: "model-155" }], + }); + expect(result.nativeSlugs).toEqual([]); + expect(result.routedModels.map(row => row.id)).toEqual(["model-123"]); + }); + + test("uses featured ordering for the no-profile hash collision winner on either install path", () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const inputs = buildDesktopDiscoveryInputs({ + config: projectionConfig(), modelEntitlements: emptyEntitlements(), desktopNativeCandidates: [], + models: [{ provider: "test", id: "model-123" }, { provider: "test", id: "model-155" }], + }); + buildDesktop3pRegistry(inputs.nativeSlugs, inputs.routedModels, undefined, inputs.nativeContextCap); + expect(resolveDesktop3pAlias("claude-opus-4-8-vdu")).toBe("test/model-155"); + const models = generateDesktop3pModels(inputs.nativeSlugs, inputs.routedModels, undefined, inputs.nativeContextCap); + expect(models.map(model => model.name)).toEqual(["claude-opus-4-8-vdu"]); + expect(resolveDesktop3pAlias("claude-opus-4-8-vdu")).toBe("test/model-155"); + } finally { warning.mockRestore(); } + }); +}); + +describe("Desktop snapshot through authenticated model discovery", () => { + const key = "ocx_data_desktopsnapshotfixture"; + const envKeys = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_API_AUTH_TOKEN"] as const; + let previous: Array; + let dir: string; + let codexHome: IsolatedCodexHome; + let upstream: ReturnType; + let server: ReturnType | undefined; + + beforeEach(() => { + previous = envKeys.map(name => process.env[name]); + dir = mkdtempSync(join(tmpdir(), "ocx-desktop-discovery-")); + process.env.OPENCODEX_HOME = dir; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(dir, "desktop"); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + codexHome = installIsolatedCodexHome("ocx-desktop-discovery-codex-"); + upstream = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch: () => Response.json({ data: [{ id: "model-123" }, { id: "model-155" }] }), + }); + }); + + afterEach(async () => { + await server?.stop(true); + server = undefined; + await upstream.stop(true); + buildDesktop3pRegistry([], []); + codexHome.restore(); + envKeys.forEach((name, index) => { + if (previous[index] === undefined) delete process.env[name]; + else process.env[name] = previous[index]; + }); + removeTreeWithRetry(dir); + }); + + function launch(enabled = true, pickerOrder?: string[]): void { + saveConfig({ + port: 0, hostname: "0.0.0.0", defaultProvider: "test", runtimeRole: "hub", + ...(pickerOrder ? { modelPickerOrder: pickerOrder, subagentModels: [], subagentModelsVersion: 1 } : {}), + providers: { + test: { adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, apiKey: "fixture", allowPrivateNetwork: true, models: ["model-123", "model-155"] }, + }, + claudeCode: { + enabled, desktopNativeModels: false, + desktopProfile: parseDesktopProfile({ + version: 1, + assignments: { "test/model-155": { family: "fable", alias: "claude-opus-4-8-20260304" } }, + defaults: { opus: null, fable: "test/model-155", sonnet: null, haiku: null }, + }), + }, + apiKeys: [{ id: "snapshot", name: "Snapshot", key, createdAt: "2026-09-06T00:00:00.000Z" }], + } as OcxConfig); + server = startServer(0); + } + + function request(query: string, headers: Record = { "x-opencodex-api-key": key }): Promise { + return fetch(`http://127.0.0.1:${server!.port}/v1/models${query}`, { headers }); + } + + test("saved order reaches both public Codex and Claude discovery consumers", async () => { + launch(true, ["test/model-155", "test/model-123"]); + const anthropic = await request("?flavor=anthropic&ids=cli"); + expect(anthropic.status).toBe(200); + const info = await anthropic.json() as { data: Array<{ display_name: string }> }; + expect(info.data.filter(row => row.display_name.endsWith("(test)")).map(row => row.display_name)) + .toEqual(["model-155 (test)", "model-123 (test)"]); + const codex = await request("?client_version=0.145.0"); + expect(codex.status).toBe(200); + const catalog = await codex.json() as { models: Array<{ slug: string; priority: number }> }; + const routed = catalog.models.filter(row => row.slug.startsWith("test/")); + expect(routed.toSorted((a, b) => a.priority - b.priority).map(row => row.slug)) + .toEqual(["test/model-155", "test/model-123"]); + expect(routed.find(row => row.slug === "test/model-155")?.priority).toBe(1000); + expect(routed.find(row => row.slug === "test/model-123")?.priority).toBe(1001); + }); + + test("snapshot installs its exact aliases and retains ordinary discovery shapes", async () => { + launch(); + const snapshot = await request("?ids=desktop&format=desktop-config"); + expect(snapshot.status).toBe(200); + expect(snapshot.headers.get("cache-control")).toBe("no-store"); + const body = await snapshot.json() as { version: number; models: Array<{ name: string; anthropicFamilyTier: string }> }; + expect(body.version).toBe(1); + expect(body.models.find(model => model.name === "claude-opus-4-8-20260304")?.anthropicFamilyTier).toBe("fable"); + expect(resolveDesktop3pAlias("claude-opus-4-8-20260304")).toBe("test/model-155"); + const anthropic = await request("?flavor=anthropic&ids=desktop"); + expect(anthropic.status).toBe(200); + const anthropicBody = await anthropic.json() as { data: Array<{ id: string }>; version?: number }; + expect(anthropicBody.version).toBeUndefined(); + expect(anthropicBody.data.some(model => model.id === "claude-opus-4-8-20260304")).toBe(true); + expect(resolveDesktop3pAlias("claude-opus-4-8-20260304")).toBe("test/model-155"); + const cli = await request("?flavor=anthropic&ids=cli"); + expect(cli.status).toBe(200); + expect((await cli.json() as { data: Array<{ id: string }> }).data.some(model => model.id.startsWith("claude-ocx-test--"))).toBe(true); + const openai = await request(""); + expect(openai.status).toBe(200); + const openaiBody = await openai.json() as { object: string; data: unknown[]; version?: number }; + expect(openaiBody.object).toBe("list"); + expect(openaiBody.version).toBeUndefined(); + expect(openaiBody.data.length).toBeGreaterThan(0); + }); + + test("keeps data admission and origin checks ahead of snapshot format parsing", async () => { + launch(); + expect((await request("?format=desktop-config&ids=cli", {})).status).toBe(401); + expect((await request("?format=desktop-config", { "x-opencodex-api-key": key, Origin: "https://untrusted.example.test" })).status).toBe(403); + for (const query of ["?format=desktop-config&ids=cli", "?format=desktop-config&client_version=0.150.0"]) { + expect((await request(query)).status).toBe(400); + } + }); + + test("disabled Claude returns a valid empty snapshot without changing ordinary disabled discovery", async () => { + launch(false); + const snapshot = await request("?format=desktop-config"); + expect(snapshot.status).toBe(200); + expect(snapshot.headers.get("cache-control")).toBe("no-store"); + expect(await snapshot.json()).toEqual({ version: 1, models: [] }); + const ordinary = await request("?flavor=anthropic"); + expect(await ordinary.json()).toEqual({ data: [] }); + }); +}); diff --git a/tests/claude-integration/claude-desktop-remote-hub.test.ts b/tests/claude-integration/claude-desktop-remote-hub.test.ts new file mode 100644 index 0000000000..e126d6cf2e --- /dev/null +++ b/tests/claude-integration/claude-desktop-remote-hub.test.ts @@ -0,0 +1,278 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { delimiter, dirname, isAbsolute, join } from "node:path"; +import { tmpdir } from "node:os"; +import type { OcxConfig } from "../../src/types"; +import type { Desktop3pModelEntry } from "../../src/claude/desktop-3p"; +import { repoPath, fixturePath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +const DATA_KEY = "test-key"; +const cliPath = repoPath("src/cli/index.ts"); +const preloadPath = fixturePath("claude-desktop-network-guard.ts"); +const roots: string[] = []; +const children: ReturnType[] = []; +const servers: Array<{ stop(closeActiveConnections?: boolean): void | Promise }> = []; + +function fixture(side: string, allowedOrigins: string[]) { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-" + side + "-")); + roots.push(root); + const paths = { + root, ocx: join(root, "ocx"), codex: join(root, "codex"), + desktop: join(root, "desktop"), user: join(root, "user"), + denied: join(root, "denied-network.txt"), + }; + for (const path of [paths.ocx, paths.codex, paths.desktop, paths.user]) mkdirSync(path, { recursive: true }); + // A valid, isolated API-key auth file prevents fallback to a real OAuth account. + writeFileSync(join(paths.codex, "auth.json"), JSON.stringify({ OPENAI_API_KEY: "fixture-only-not-a-real-key" }), { mode: 0o600 }); + const env: Record = { + HOME: paths.user, USERPROFILE: paths.user, + OPENCODEX_HOME: paths.ocx, CODEX_HOME: paths.codex, + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: paths.desktop, + CLAUDE_CONFIG_DIR: join(paths.user, ".claude"), + XDG_CONFIG_HOME: join(paths.user, ".config"), XDG_DATA_HOME: join(paths.user, ".local", "share"), + XDG_RUNTIME_DIR: join(root, "runtime"), APPDATA: join(paths.user, "AppData", "Roaming"), + LOCALAPPDATA: join(paths.user, "AppData", "Local"), + PATH: [dirname(process.execPath), ...(process.platform === "win32" + ? [join(process.env.SystemRoot ?? "C:\\Windows", "System32")] + : ["/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(delimiter), + SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, + CI: "true", TERM: "dumb", NO_PROXY: "127.0.0.1,localhost", + OCX_TEST_ALLOWED_ORIGINS: JSON.stringify(allowedOrigins), + OCX_TEST_DENIED_REQUESTS: paths.denied, + }; + mkdirSync(env.XDG_RUNTIME_DIR!, { recursive: true }); + if (process.platform === "win32") { + // A fresh profile otherwise rebuilds PowerShell's command-analysis cache + // in every CLI child. Seed an owned copy; background updates must never + // write the runner's or developer's original cache. + const cache = join(root, "module-analysis-cache"); + env.PSModuleAnalysisCachePath = cache; + const source = Object.entries(process.env).find(([key]) => + key.toLowerCase() === "psmoduleanalysiscachepath")?.[1]; + if (source && isAbsolute(source)) { + try { + const before = lstatSync(source); + if (before.isFile() && !before.isSymbolicLink()) { + copyFileSync(source, cache); + if (lstatSync(cache).size !== before.size) rmSync(cache, { force: true }); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ESTALE") { + throw new Error("Desktop fixture could not read or copy the PowerShell module cache"); + } + rmSync(cache, { force: true }); + } + } + } + return { ...paths, env }; +} +type Fixture = ReturnType; + +function writeConfig(fx: Fixture, config: OcxConfig): void { + writeFileSync(join(fx.ocx, "config.json"), JSON.stringify(config), { mode: 0o600 }); +} +function readConfig(fx: Fixture): OcxConfig { + return JSON.parse(readFileSync(join(fx.ocx, "config.json"), "utf8")) as OcxConfig; +} +function spawnOwned(fx: Fixture, args: string[]) { + const child = Bun.spawn({ + cmd: [process.execPath, "--preload", preloadPath, cliPath, ...args], + cwd: fx.root, env: fx.env, stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + const owned = { child, stdout: new Response(child.stdout).text(), stderr: new Response(child.stderr).text() }; + children.push(owned); + return owned; +} +async function within(promise: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(label)), ms); }), + ]); + } finally { if (timer !== undefined) clearTimeout(timer); } +} +async function stopOwned(owned: ReturnType): Promise { + if (owned.child.exitCode === null) owned.child.kill("SIGTERM"); + try { await within(owned.child.exited, 8_000, "CLI shutdown deadline"); } + catch { + if (owned.child.exitCode === null) owned.child.kill("SIGKILL"); + await within(owned.child.exited, 5_000, "CLI forced shutdown deadline"); + } + await within(Promise.all([owned.stdout, owned.stderr]), 5_000, "CLI output drain deadline"); +} +async function startHub(fx: Fixture) { + const owned = spawnOwned(fx, ["start"]); + const deadline = performance.now() + 45_000; + while (performance.now() < deadline) { + if (owned.child.exitCode !== null) { + throw new Error("Hub exited before readiness: " + await within(owned.stderr, 5_000, "Exited hub output deadline")); + } + try { + const runtime = JSON.parse(readFileSync(join(fx.ocx, "runtime-port.json"), "utf8")) as { pid: number; port: number }; + if (runtime.pid === owned.child.pid && runtime.port > 0) { + const origin = "http://127.0.0.1:" + runtime.port; + const ready = await fetch(origin + "/readyz", { signal: AbortSignal.timeout(500) }); + await ready.text(); + if (ready.ok) return { owned, origin, port: runtime.port }; + } + } catch { /* listener/runtime record is not ready */ } + await Bun.sleep(20); + } + throw new Error("Hub readiness deadline"); +} + +function mockProvider() { + const inference: Array<{ url: string; model: unknown }> = []; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/v1/models") return Response.json({ object: "list", data: [] }); + if (url.pathname !== "/v1/chat/completions") return new Response("unexpected fixture path", { status: 404 }); + const body = await req.json() as { model?: unknown }; + inference.push({ url: req.url, model: body.model }); + const chunks = [ + { choices: [{ index: 0, delta: { role: "assistant", content: "fixture reply" } }] }, + { choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 2, completion_tokens: 2 } }, + ]; + return new Response(chunks.map(chunk => "data: " + JSON.stringify(chunk) + "\n\n").join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + servers.push(server); + return { server, inference }; +} +function profile(chosenDay: string, decoyDay: string): NonNullable["desktopProfile"]> { + return { + version: 1, + assignments: { + "chosen/model-target": { family: "opus", alias: "claude-opus-4-8-" + chosenDay }, + "decoy/model-decoy": { family: "sonnet", alias: "claude-opus-4-8-" + decoyDay }, + }, + defaults: { opus: "chosen/model-target", fable: null, sonnet: "decoy/model-decoy", haiku: null }, + }; +} +function deniedTraffic(fx: Fixture): string { + return existsSync(fx.denied) ? readFileSync(fx.denied, "utf8") : ""; +} + +afterEach(async () => { + const failures: unknown[] = []; + for (const owned of children.splice(0)) { + try { await stopOwned(owned); } + catch (error) { + failures.push(error); + if (owned.child.exitCode === null) children.push(owned); + } + } + for (const server of servers.splice(0)) { + try { await server.stop(true); } catch (error) { failures.push(error); } + } + for (const root of roots.splice(0)) { + try { removeTreeWithRetry(root); } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "Desktop process fixture cleanup failed"); +}, 90_000); + +for (const storedProfile of [true, false]) { + test("connected Desktop uses hub IDs across a cold restart (stored profile=" + storedProfile + ")", async () => { + const chosen = mockProvider(); + const decoy = mockProvider(); + const hub = fixture("hub", [chosen.server.url.origin, decoy.server.url.origin]); + hub.env.OPENCODEX_API_AUTH_TOKEN = DATA_KEY; + const provider = (target: ReturnType, model: string) => ({ + adapter: "openai-chat" as const, baseUrl: target.server.url.origin + "/v1", + apiKey: "fixture-provider-key", models: [model], liveModels: false, allowPrivateNetwork: true, + }); + writeConfig(hub, { + port: 0, hostname: "127.0.0.1", runtimeRole: "hub", + defaultProvider: "decoy", codexAutoStart: false, syncResumeHistory: false, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + providers: { chosen: provider(chosen, "model-target"), decoy: provider(decoy, "model-decoy") }, + subagentModels: ["decoy/model-decoy", "chosen/model-target"], + claudeCode: { + enabled: true, nativePassthrough: false, desktopNativeModels: true, + systemEnv: false, injectAgents: false, + ...(storedProfile ? { desktopProfile: profile("20260211", "20260212") } : {}), + }, + } as OcxConfig); + const first = await startHub(hub); + // Retain the allocated endpoint so the client's persisted origin survives restart. + writeConfig(hub, { ...readConfig(hub), port: first.port }); + const client = fixture("client", [first.origin]); + const localProfile = profile("20260911", "20260912"); + writeConfig(client, { + port: 1, hostname: "127.0.0.1", runtimeRole: "client", defaultProvider: "unused", + providers: { unused: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "unused-fixture-key", liveModels: false, models: ["client-only"], allowPrivateNetwork: true } }, + claudeCode: { desktopProfile: localProfile, systemEnv: false, injectAgents: false }, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + client: { + serverUrl: first.origin, managementUrl: first.origin, managementTransport: "direct", + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", + tokenFingerprint: createHash("sha256").update(DATA_KEY).digest("hex"), + protocolVersion: 1, connectedAt: "2026-01-01T00:00:00.000Z", + }, + } as OcxConfig); + writeFileSync(join(client.ocx, "service-api-token"), DATA_KEY + "\n", { mode: 0o600 }); + const snapshotResponse = await fetch(first.origin + "/v1/models?ids=desktop&format=desktop-config", { + headers: { "x-opencodex-api-key": DATA_KEY }, signal: AbortSignal.timeout(5_000), + }); + expect(snapshotResponse.status).toBe(200); + expect(snapshotResponse.headers.get("cache-control")).toBe("no-store"); + const snapshot = await snapshotResponse.json() as { version: number; models: Desktop3pModelEntry[] }; + expect(snapshot.version).toBe(1); + expect(snapshot.models.some(model => model.labelOverride.includes("(native)"))).toBe(true); + const chosenEntry = snapshot.models.find(model => model.labelOverride === "Model Target (chosen)"); + expect(chosenEntry).toBeDefined(); + if (storedProfile) expect(chosenEntry!.name).toBe("claude-opus-4-8-20260211"); + else expect(chosenEntry!.name).toMatch(/^claude-opus-4-8-[a-z][a-z0-9]{2}$/); + + const apply = spawnOwned(client, ["claude", "desktop", "apply", "--static"]); + // The Windows known-folder lookup alone may validly use its 30s budget + // (22.8s in hosted tracing); leave room for the rest of this real CLI apply. + const appliedCode = await within(apply.child.exited, SPAWN_BUDGET_MS, "Remote Desktop apply deadline"); + const appliedOutput = await within(Promise.all([apply.stdout, apply.stderr]), 5_000, "Apply output drain deadline"); + if (appliedCode !== 0) throw new Error("Remote Desktop apply failed: " + appliedOutput[1]); + expect(appliedOutput.join("\n")).not.toContain(DATA_KEY); + const metadata = JSON.parse(readFileSync(join(client.desktop, "_meta.json"), "utf8")) as { appliedId: string }; + const written = JSON.parse(readFileSync(join(client.desktop, metadata.appliedId + ".json"), "utf8")); + expect(written.inferenceGatewayBaseUrl).toBe(first.origin); + expect(written.inferenceGatewayApiKey).toBe(DATA_KEY); + expect(written.inferenceModels).toEqual(snapshot.models); + expect(readConfig(client).claudeCode?.desktopProfile).toEqual(localProfile); + if (!storedProfile) expect(readConfig(hub).claudeCode?.desktopProfile).toBeUndefined(); + + const send = async (origin: string) => { + const response = await fetch(origin + "/v1/messages", { + method: "POST", signal: AbortSignal.timeout(10_000), + headers: { "content-type": "application/json", "x-opencodex-api-key": DATA_KEY, "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model: chosenEntry!.name, max_tokens: 8, stream: true, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("message_stop"); + }; + await send(first.origin); + expect(chosen.inference).toEqual([{ url: chosen.server.url.origin + "/v1/chat/completions", model: "model-target" }]); + expect(decoy.inference).toEqual([]); + + await stopOwned(first.owned); + const restarted = await startHub(hub); + expect(restarted.origin).toBe(first.origin); + // No model discovery call occurs between restart and this saved-ID request. + await send(restarted.origin); + expect(chosen.inference).toEqual([ + { url: chosen.server.url.origin + "/v1/chat/completions", model: "model-target" }, + { url: chosen.server.url.origin + "/v1/chat/completions", model: "model-target" }, + ]); + expect(decoy.inference).toEqual([]); + await stopOwned(restarted.owned); + expect(deniedTraffic(hub)).toBe(""); + expect(deniedTraffic(client)).toBe(""); + }, { timeout: 240_000 }); +} diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 30b90c3256..fa99f3c14f 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -3,8 +3,9 @@ import { managementFetch as fetch } from "../helpers/management-auth"; import { mkdtempSync, readdirSync, readFileSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadConfig, mutatePersistedConfig, saveConfig } from "../../src/config"; -import { startServer } from "../../src/server"; +import { loadConfig, saveConfig } from "../../src/config"; +import { startServer as startServerImpl } from "../../src/server"; +import { writeDesktop3pConfig, removeDesktop3pStandardPivot } from "../../src/claude/desktop-3p"; import * as systemEnv from "../../src/server/system-env"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -21,6 +22,19 @@ let previousClaudeConfigDir: string | undefined; let previousDesktopConfigDir: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; +// Keep the production SQLite kernel while each fixture owns its lock namespace. +function startServer(port?: number, deps: NonNullable[1]> = {}) { + return startServerImpl(port, { + ...deps, + managementApi: { + writeDesktop3pConfig: (port, slugs, models, key, mode, profile, cap) => + writeDesktop3pConfig(port, slugs, models, key, mode, profile, cap, { lockPath: join(testDir, "lifecycle.sqlite") }), + removeDesktop3pStandardPivot: options => removeDesktop3pStandardPivot({ ...options, lifecycleLockDeps: { lockPath: join(testDir, "lifecycle.sqlite") } }), + ...deps.managementApi, + }, + }); +} + beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; @@ -715,10 +729,12 @@ test("Claude Desktop apply installs the alias registry in the serving process (# const { resolveDesktop3pAlias, activeDesktop3pAlias } = await import("../../src/claude/desktop-3p"); // A provider unique to this test: no prior test can have populated its // alias, so resolution proves THIS apply built the registry in-process. - mutatePersistedConfig(fresh => { - fresh.providers.unique859 = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, models: ["test-model-x"] }; - return { changed: true, value: undefined }; - }); + const seeded = loadConfig(); + seeded.providers = { + ...seeded.providers, + unique859: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, models: ["test-model-x"] }, + }; + saveConfig(seeded); const server = startServer(0); try { const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { @@ -854,10 +870,8 @@ test("Claude Desktop PUT retains but cannot move an unavailable route", async () version: 1, assignments: { "missing/old-model": { family: "opus", alias: "claude-opus-4-8-20260101" }, - "missing/haiku-model": { family: "haiku", alias: "claude-opus-4-8-20260102" }, - "missing/haiku-model-2": { family: "haiku", alias: "claude-opus-4-8-20260103" }, }, - defaults: { opus: "missing/old-model", fable: null, sonnet: null, haiku: "missing/haiku-model" }, + defaults: { opus: "missing/old-model", fable: null, sonnet: null, haiku: null }, }, }; saveConfig(seeded); @@ -874,35 +888,9 @@ test("Claude Desktop PUT retains but cannot move an unavailable route", async () headers: { "Content-Type": "application/json" }, body: JSON.stringify({ profile: edited }), }); - expect(put.status).toBe(409); - const conflict = await put.json() as { - error: { code: string; message: string; route: string }; - current: { profile: typeof state.profile; models: Array<{ route: string; available: boolean }>; rendered: unknown; port: number }; - }; - expect(conflict.error).toEqual({ - code: "catalog_changed", - message: "현재 사용할 수 없는 모델은 옮길 수 없습니다: missing/old-model", - route: "missing/old-model", - }); - expect(conflict.current.models.find(model => model.route === "missing/old-model")?.available).toBe(false); - expect(conflict.current.profile).toEqual(state.profile); - expect(conflict.current.port).toBe(Number(new URL(server.url).port)); + expect(put.status).toBe(400); + expect((await put.json() as { error: string }).error).toContain("사용할 수 없는 모델"); expect(loadConfig().claudeCode?.desktopProfile?.assignments["missing/old-model"]?.family).toBe("opus"); - - const defaultEdit = structuredClone(state.profile); - defaultEdit.defaults.haiku = "missing/haiku-model-2"; - const defaultPut = await fetch(new URL("/api/claude-desktop", server.url), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile: defaultEdit }), - }); - expect(defaultPut.status).toBe(409); - const defaultConflict = await defaultPut.json() as { - error: { code: string; route: string }; - current: { profile: typeof state.profile }; - }; - expect(defaultConflict.error).toMatchObject({ code: "catalog_changed", route: "missing/haiku-model-2" }); - expect(defaultConflict.current.profile).toEqual(state.profile); } finally { await server.stop(true); } diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 2bf7eccfee..a84e09a878 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -4,14 +4,17 @@ import { logsFromApiBody } from "../helpers/logs-api"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { replacePersistedConfig, saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; +import { readRecentUsageEntries } from "../../src/usage/log"; +import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; +import type { DesktopProfile } from "../../src/claude/desktop-profile"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; -import { getOrCreateDirectiveSigningKey } from "../../src/claude/directive-key"; -import { signDirective } from "../../src/claude/directive-sign"; import { clearableDeadline } from "../../src/lib/abort"; import { clearRequestLogsForTests, + addRequestLog, getRequestLogEntries, + hydrateRequestLogsFromDisk, type RequestLogContext, } from "../../src/server/request-log"; import { startServer } from "../../src/server"; @@ -19,7 +22,6 @@ import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspec import { estimateClaudeRequestTokens, fetchWithHeaderDeadline, - handleClaudeCountTokens, handleClaudeMessages, readBoundedPassthroughBody, resolvePassthroughBodyGuard, @@ -77,9 +79,11 @@ function mockChatUpstream() { function mockChatUpstreamCapturing() { const captured: Array> = []; + const urls: string[] = []; const server = Bun.serve({ port: 0, async fetch(req) { + urls.push(req.url); const url = new URL(req.url); if (!url.pathname.endsWith("/chat/completions")) { return Response.json({ error: { message: `unexpected path ${url.pathname}` } }, { status: 404 }); @@ -94,7 +98,7 @@ function mockChatUpstreamCapturing() { return new Response(frames.join(""), { headers: { "Content-Type": "text/event-stream" } }); }, }); - return { server, captured }; + return { server, captured, urls }; } function mockConfig(baseUrl: string, claudeCode?: OcxConfig["claudeCode"]): OcxConfig { @@ -104,7 +108,6 @@ function mockConfig(baseUrl: string, claudeCode?: OcxConfig["claudeCode"]): OcxC providers: { mock: { adapter: "openai-chat", baseUrl, apiKey: "k", allowPrivateNetwork: true }, }, - subagentModels: ["mock/test-model"], ...(claudeCode ? { claudeCode } : {}), } as OcxConfig; } @@ -204,56 +207,6 @@ test("non-streaming /v1/messages returns an Anthropic message JSON", async () => } }); -test("benchmark usage observer is one-shot, isolated from mutation, and non-disruptive", async () => { - const upstream = mockChatUpstream(); - const config = mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`); - const observations: Array<{ adapterKind: string; modelId: string; inputTokens?: number }> = []; - const request = () => new Request("http://localhost/v1/messages", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - model: "mock/test-model", - max_tokens: 8, - stream: false, - messages: [{ role: "user", content: "observer isolation" }], - }), - }); - try { - const mutated = await handleClaudeMessages( - request(), - config, - { model: "test-model", provider: "mock", surface: "claude" }, - undefined, - config, - { onRawUsage: observation => { - observations.push({ - adapterKind: observation.adapterKind, - modelId: observation.modelId, - inputTokens: observation.usage?.inputTokens, - }); - if (observation.usage) observation.usage.inputTokens = 999_999; - } }, - ); - expect(mutated.status).toBe(200); - const body = await mutated.json() as { usage: { input_tokens: number } }; - expect(body.usage.input_tokens).toBe(12); - expect(observations).toEqual([{ adapterKind: "openai-chat", modelId: "test-model", inputTokens: 12 }]); - - const throwing = await handleClaudeMessages( - request(), - config, - { model: "test-model", provider: "mock", surface: "claude" }, - undefined, - config, - { onRawUsage: () => { throw new Error("observer failure"); } }, - ); - expect(throwing.status).toBe(200); - expect((await throwing.json() as { usage: { input_tokens: number } }).usage.input_tokens).toBe(12); - } finally { - upstream.stop(true); - } -}); - test("Desktop OFF leaves Claude messages and health live", async () => { const upstream = mockChatUpstream(); saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); @@ -1020,7 +973,7 @@ test("Claude replay owns optional main enrichment while routed work survives dra completeNativeMainRecovery(recoveryHomeId); recoveryHomeId = null; await server.stop(true); - replacePersistedConfig({ + saveConfig({ port: 0, openaiProviderTierVersion: 2, defaultProvider: "openai", @@ -1254,38 +1207,6 @@ test("count_tokens returns a positive estimate in the exact contract shape", asy } }); -test("routed count_tokens stays local and does not invoke provider transport or benchmark observation", async () => { - const raw = { - model: "mock/test-model", - system: "be brief", - messages: [{ role: "user", content: "count this routed request" }], - tools: [{ name: "Read", input_schema: { type: "object" } }], - }; - const expected = estimateClaudeRequestTokens(raw, raw.model); - // The count_tokens handler has no benchmark observer parameter; keep this - // sentinel to make the no-observation invariant explicit in the regression. - const observerCalls: unknown[] = []; - const previousFetch = globalThis.fetch; - globalThis.fetch = (() => { - throw new Error("provider transport must not be reached by routed count_tokens"); - }) as typeof fetch; - try { - const response = await handleClaudeCountTokens( - new Request("http://localhost/v1/messages/count_tokens", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(raw), - }), - mockConfig("http://127.0.0.1:1/v1"), - ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ input_tokens: expected }); - expect(observerCalls).toHaveLength(0); - } finally { - globalThis.fetch = previousFetch; - } -}); - /** Minimal PNG header (signature + IHDR) so the attachment sniffer can read real dimensions. */ function countTokensPngBase64(width: number, height: number): string { const u32be = (n: number): number[] => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; @@ -1467,6 +1388,232 @@ test("claudeCode.enabled=false -> 403 permission_error on both routes", async () } }); +test("compatibility is uniform across translated adapters and rejects before inference", async () => { + let sends = 0; + const upstream = Bun.serve({ port: 0, fetch() { sends++; return new Response("unexpected inference", { status: 500 }); } }); + const baseUrl = new URL("/v1", upstream.url).href; + const features: Array> = [ + { messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }] }] }, + { messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "private-fixture", signature: "opaque-fixture" }] }] }, + { messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "opaque-fixture" }] }] }, + { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "document" }] }] }] }, + { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "tool_reference", tool_name: "lookup" }] }] }] }, + { tools: [{ type: "tool_search_tool_regex_20251119", name: "tool_search" }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, defer_loading: true }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, strict: true }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, allowed_callers: ["code_execution_20260120"] }] }, + { tools: [{ type: "web_search_20250305", name: "web_search", allowed_domains: ["example.invalid"] }] }, + { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }, + { service_tier: "standard_only" }, + { mcp_servers: [{ type: "url", name: "mcp", url: "https://example.invalid", authorization_token: "private-fixture" }] }, + { tools: [{ type: "mcp_toolset", mcp_server_name: "mcp" }] }, + { tools: [{ type: "code_execution_20260120", name: "code_execution" }] }, + { tools: [{ type: "computer_20250124", name: "computer" }] }, + { context_management: { edits: [] } }, { container: "private-fixture" }, + { inference_geo: "us" }, { user_profile_id: "private-fixture" }, + { future_option: true }, + { messages: [{ role: "user", content: [{ type: "future_block" }] }] }, + ]; + try { + for (const adapter of ["anthropic", "openai-responses", "openai-chat"] as const) { + const config = mockConfig(baseUrl, { compatibility: "enforce" }); + config.providers.mock.adapter = adapter; + saveConfig(config); + const server = startServer(0); + try { + clearRequestLogsForTests(); + for (const [index, feature] of features.entries()) { + const response = await fetch(new URL("/v1/messages?beta=true", server.url), { + method: "POST", headers: { + "content-type": "application/json", "x-api-key": "placeholder", + "anthropic-beta": Array.from({ length: 50 }, (_, i) => `private-header-${i}`).join(","), + }, + body: JSON.stringify({ model: "mock/test-model", max_tokens: 64, stream: index % 2 === 0, + messages: [{ role: "user", content: "hi" }], ...feature }), + }); + expect(response.status).toBe(400); + const error = await response.json() as { type: string; error: { type: string; message: string } }; + expect(error.type).toBe("error"); + expect(error.error.type).toBe("invalid_request_error"); + expect(error.error.message).not.toContain("private-"); + expect(getRequestLogEntries()).toHaveLength(index + 1); + expect(getRequestLogEntries().at(-1)?.errorCode).toBe("claude_compatibility_unsupported"); + } + expect(sends).toBe(0); + // Count-token success is intentionally independent of Messages admission. + const counted = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", messages: [{ role: "user", content: [{ type: "document" }] }] }), + }); + expect(counted.status).toBe(200); + } finally { await server.stop(true); } + } + } finally { await upstream.stop(true); } +}); + +test("compatibility unset, shadow persistence and ordinary enforce controls", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + const document = { model: "mock/test-model", max_tokens: 64, stream: true, + messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }] }] }; + try { + for (const compatibility of [undefined, "shadow", "enforce"] as const) { + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const body = compatibility === "enforce" ? { + ...document, stream: false, thinking: { type: "disabled" }, + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], + tools: [{ name: "mcp_lookup", input_schema: { type: "object" }, strict: false, defer_loading: false, input_examples: [{}] }], + messages: [{ role: "user", content: "hi" }], + } : document; + const response = await postMessages(server.url.toString(), body); + expect(response.status).toBe(200); + await response.text(); + const expected = compatibility === "shadow" ? { + decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents", + } : undefined; + expect(getRequestLogEntries()).toHaveLength(1); + const requestId = getRequestLogEntries()[0].requestId; + expect(getRequestLogEntries()[0].claudeCompatibility).toEqual(expected); + expect(readRecentUsageEntries(1)[0]?.claudeCompatibility).toEqual(expected); + const dto = logsFromApiBody<{ requestId: string; claudeCompatibility?: unknown }>( + await (await fetch(new URL("/api/logs", server.url))).json()); + expect(dto.find(row => row.requestId === requestId)?.claudeCompatibility).toEqual(expected); + clearRequestLogsForTests(); + hydrateRequestLogsFromDisk(); + expect(getRequestLogEntries().find(row => row.requestId === requestId)?.claudeCompatibility).toEqual(expected); + if (compatibility === "shadow") { + addRequestLog({ requestId: "compatibility-boundary", timestamp: Date.now(), model: "test-model", provider: "mock", + status: 200, durationMs: 1, usageStatus: "unreported", + claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents","private-header"],"reason":"private-reason"}') }); + const rows = logsFromApiBody<{ requestId: string; claudeCompatibility?: unknown }>( + await (await fetch(new URL("/api/logs", server.url))).json()); + expect(rows.find(row => row.requestId === "compatibility-boundary")?.claudeCompatibility).toEqual(expected); + } + } finally { await server.stop(true); } + } + expect(captured).toHaveLength(3); + } finally { await upstream.stop(true); } +}); + +test("present invalid compatibility modes return one fixed 503 before translation", async () => { + const { server: upstream, urls } = mockChatUpstreamCapturing(); + try { + for (const compatibility of [null, "", "enfroce", false, 1, [], { secret: "private-fixture" }]) { + writeFileSync(join(testDir, "config.json"), JSON.stringify({ + ...mockConfig(new URL("/v1", upstream.url).href), claudeCode: { compatibility }, + })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "mock/test-model", max_tokens: 16, messages: [{ role: "user", content: "hi" }], + }); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ type: "error", error: { type: "api_error", message: "Invalid claudeCode.compatibility setting" } }); + expect(getRequestLogEntries()).toHaveLength(1); + expect(getRequestLogEntries()[0].errorCode).toBe("claude_compatibility_configuration"); + if (compatibility === null) { + const malformed = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: { "content-type": "application/json" }, body: "{", + }); + expect(malformed.status).toBe(400); + expect(getRequestLogEntries()).toHaveLength(2); + } + } finally { await server.stop(true); } + } + expect(urls).toEqual([]); + } finally { await upstream.stop(true); } +}); + +test("native passthrough precedes even invalid compatibility mode", async () => { + const received: unknown[] = []; + const upstream = Bun.serve({ port: 0, async fetch(req) { + received.push(await req.json()); + return Response.json({ id: "msg_test", type: "message", role: "assistant", model: "claude-haiku-4-5", + content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } }); + } }); + const body = { model: "claude-haiku-4-5", max_tokens: 16, messages: [ + { role: "assistant", content: [{ type: "thinking", thinking: "fixture", signature: "opaque-fixture" }] }, + { role: "user", content: "continue" }, + ] }; + try { + for (const compatibility of ["enforce", "invalid"]) { + writeFileSync(join(testDir, "config.json"), JSON.stringify({ ...mockConfig("http://127.0.0.1:1/v1"), + claudeCode: { compatibility, anthropicBaseUrl: upstream.url.origin } })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { method: "POST", + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, body: JSON.stringify(body) }); + expect(response.status).toBe(200); + await response.text(); + } finally { await server.stop(true); } + } + expect(received).toEqual([body, body]); + } finally { await upstream.stop(true); } +}); + +test("compatibility survives management toggles and rejects Desktop source features", async () => { + const { server: upstream, urls } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility: "enforce" })); + const server = startServer(0); + try { + for (const enabled of [false, true]) { + const toggle = await fetch(new URL("/api/native-integrations/claude", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }), + }); + expect(toggle.status).toBe(200); + expect(loadConfig().claudeCode?.compatibility).toBe("enforce"); + } + const settings = await fetch(new URL("/api/claude-code", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ injectAgents: false }), + }); + expect(settings.status).toBe(200); + expect(loadConfig().claudeCode?.compatibility).toBe("enforce"); + buildDesktop3pRegistry([], [{ provider: "mock", id: "test-model" }], { + version: 1, assignments: { "mock/test-model": { family: "opus", alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "mock/test-model", fable: null, sonnet: null, haiku: null }, + }); + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "claude-opus-4-8-20260201", max_tokens: 16, + system: [{ type: "text", text: "" }], + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "fixture", signature: "opaque-fixture" }] }, + { role: "user", content: "continue" }], + }); + expect(response.status).toBe(400); + expect(await response.text()).toContain("thinking_replay"); + expect(getRequestLogEntries()).toHaveLength(1); + expect(getRequestLogEntries()[0].surface).toBe("claude-desktop"); + expect(urls).toEqual([]); + } finally { + buildDesktop3pRegistry([], []); + await server.stop(true); + await upstream.stop(true); + } +}); + +test("shadow captures source thinking settings before an effort directive removes them", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility: "shadow" })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "mock/test-model", max_tokens: 64, stream: true, thinking: { type: "disabled" }, + system: [{ type: "text", text: "\n" }], + messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "fixture" } }] }], + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + expect(getRequestLogEntries()[0]?.claudeCompatibility).toEqual({ + decision: "shadow", featureCodes: ["documents", "thinking_settings"], reason: "shadow: would reject: documents", + }); + } finally { await server.stop(true); await upstream.stop(true); } +}); + async function postMessages(serverUrl: string, body: Record): Promise { return fetch(new URL("/v1/messages", serverUrl), { method: "POST", @@ -1505,8 +1652,6 @@ test("generated agent effort directive restores exact xhigh and max after Claude const { server: upstream, captured } = mockChatUpstreamCapturing(); saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); const server = startServer(0); - const route = "claude-ocx-mock--test-model"; - const key = getOrCreateDirectiveSigningKey(); try { for (const effort of ["xhigh", "max"]) { const response = await postMessages(server.url.toString(), { @@ -1514,9 +1659,8 @@ test("generated agent effort directive restores exact xhigh and max after Claude max_tokens: 32000, stream: true, system: [ - { type: "text", text: `` }, + { type: "text", text: "" }, { type: "text", text: `` }, - { type: "text", text: `` }, ], thinking: { type: "enabled", budget_tokens: 31999 }, messages: [{ role: "user", content: "hi" }], @@ -1553,7 +1697,6 @@ test("generated agent effort directive preserves routed Anthropic structured out saveConfig({ port: 0, defaultProvider: "mock-anthropic", - subagentModels: ["mock-anthropic/claude-sonnet-5"], providers: { "mock-anthropic": { adapter: "anthropic", @@ -1564,9 +1707,6 @@ test("generated agent effort directive preserves routed Anthropic structured out }, } as OcxConfig); const server = startServer(0); - const route = "claude-ocx-mock-anthropic--claude-sonnet-5"; - const effort = "max"; - const key = getOrCreateDirectiveSigningKey(); const schema = { type: "object", properties: { answer: { type: "string" } }, @@ -1579,9 +1719,8 @@ test("generated agent effort directive preserves routed Anthropic structured out max_tokens: 32000, stream: true, system: [ - { type: "text", text: `` }, - { type: "text", text: `` }, - { type: "text", text: `` }, + { type: "text", text: "" }, + { type: "text", text: "" }, ], thinking: { type: "enabled", budget_tokens: 31999 }, output_config: { @@ -1666,3 +1805,170 @@ test("count_tokens is CJK-aware: Korean body counts more tokens than equal-lengt await server.stop(true); } }); + + +const managedDesktopProfile: DesktopProfile = { + version: 1, + assignments: { "selected/model-selected": { family: "opus", alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "selected/model-selected", fable: null, sonnet: null, haiku: null }, +}; +const desktopRequestHeaders = { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + authorization: "Bearer sk-ant-oat01-tst", +}; + +for (const { fallbacks, fastRows } of [ + { fallbacks: false, fastRows: false }, { fallbacks: true, fastRows: false }, + { fallbacks: false, fastRows: true }, { fallbacks: true, fastRows: true }, +]) { + test(`missing Desktop dates stay unavailable across registry states (fallbacks=${fallbacks}, fastRows=${fastRows})`, async () => { + const selected = mockChatUpstreamCapturing(); + const fallback = mockChatUpstreamCapturing(); + const native = mockChatUpstreamCapturing(); + const provider = (upstream: ReturnType, models: string[]) => ({ + adapter: "openai-chat" as const, baseUrl: new URL("/v1", upstream.server.url).href, + apiKey: "test-key", allowPrivateNetwork: true, liveModels: false, models, + }); + saveConfig({ + port: 0, defaultProvider: "fallback", fastRows, + providers: { + selected: provider(selected, ["model-selected"]), + fallback: provider(fallback, ["model-default", "model-dateless", "model-classifier"]), + }, + claudeCode: { + anthropicBaseUrl: native.server.url.origin, + ...(fallbacks ? { + modelMap: { "claude-opus-4-8": "fallback/model-dateless" }, + classifierModel: "fallback/model-classifier", + } : {}), + }, + } as OcxConfig); + const server = startServer(0); + try { + for (const registryState of ["cold", "prior-success", "degraded-empty"] as const) { + if (registryState === "prior-success") { + buildDesktop3pRegistry([], [{ provider: "selected", id: "model-selected" }], managedDesktopProfile); + const success = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: desktopRequestHeaders, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ model: "claude-opus-4-8-20260201", stream: true, max_tokens: 8, + messages: [{ role: "user", content: "hello" }] }), + }); + expect(success.status).toBe(200); + expect(await success.text()).toContain("message_stop"); + expect(selected.captured.map(body => body.model)).toEqual(["model-selected"]); + } else { + buildDesktop3pRegistry([], []); + } + const selectedBefore = selected.urls.length; + const cases: Array<[string, number]> = [ + ["claude-opus-4-8-20260202", 503], + // Retrying without new mapping evidence must not become a 400 or fallback. + ["claude-opus-4-8-20260202", 503], + ["claude-opus-4-8-20260202[1m]", 503], + ["claude-opus-4-8-20260202--fast", 503], + ["claude-opus-4-8-20260202--fast[1m]", 503], + ["claude-opus-4-8-zzz", 400], ["claude-opus-4-zzz", 400], + ["claude-opus-4-8-zzz--fast", 400], ["claude-opus-4-zzz--fast", 400], + ]; + if (registryState === "degraded-empty") cases.push(["claude-opus-4-8-20260201", 503]); + for (const [model, status] of cases) { + for (const path of ["/v1/messages", "/v1/messages/count_tokens"]) { + const response = await fetch(new URL(path, server.url), { + method: "POST", headers: desktopRequestHeaders, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ model, max_tokens: 8, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(status); + const body = await response.json() as { type: string; error: { type: string; message: string; code?: string } }; + expect(body.type).toBe("error"); + expect(body.error.type).toBe(status === 503 ? "api_error" : "invalid_request_error"); + if (status === 503) { + expect(body.error.code).toBe("desktop_model_mapping_unavailable"); + expect(response.headers.get("retry-after")).toBe("1"); + expect(body.error.message).not.toContain("Unknown Claude Desktop alias"); + } else { + expect(body.error.message).toContain("Unknown Claude Desktop alias"); + expect(body.error.code).not.toBe("desktop_model_mapping_unavailable"); + expect(response.headers.get("retry-after")).toBeNull(); + } + } + } + expect(selected.urls).toHaveLength(selectedBefore); + expect(fallback.urls).toEqual([]); + expect(native.urls).toEqual([]); + } + } finally { + await server.stop(true); + selected.server.stop(true); fallback.server.stop(true); native.server.stop(true); + buildDesktop3pRegistry([], []); + } + }, { timeout: SERVER_BUDGET_MS }); +} + +for (const fastRows of [false, true]) { +test(`registered Desktop IDs and exact overrides reach intended routes (fastRows=${fastRows})`, async () => { + const selected = mockChatUpstreamCapturing(); + const explicit = mockChatUpstreamCapturing(); + const fallback = mockChatUpstreamCapturing(); + const provider = (upstream: ReturnType, model: string) => ({ + adapter: "openai-chat" as const, baseUrl: new URL("/v1", upstream.server.url).href, + apiKey: "test-key", allowPrivateNetwork: true, liveModels: false, models: [model], + }); + saveConfig({ + port: 0, defaultProvider: "fallback", fastRows, + providers: { + selected: provider(selected, "model-selected"), explicit: provider(explicit, "model-explicit"), + fallback: provider(fallback, "model-fallback"), + }, + claudeCode: { + anthropicBaseUrl: fallback.server.url.origin, + modelMap: { + "claude-opus-4-8-20260202": "explicit/model-explicit", + "claude-opus-4-8-20260203--fast": "explicit/model-explicit", + "claude-opus-4-8": "fallback/model-fallback", + }, + classifierModel: "fallback/model-fallback", + }, + } as OcxConfig); + buildDesktop3pRegistry([], [{ provider: "selected", id: "model-selected" }], managedDesktopProfile); + const server = startServer(0); + try { + for (const model of [ + "claude-opus-4-8-20260201", "claude-opus-4-8-20260201[1m]", + ...(fastRows ? ["claude-opus-4-8-20260201--fast"] : []), + "claude-opus-4-8-20260202", "claude-opus-4-8-20260203--fast", + ]) { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: desktopRequestHeaders, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ model, stream: true, max_tokens: 8, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("message_stop"); + const count = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", headers: desktopRequestHeaders, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ model, messages: [{ role: "user", content: "hello" }] }), + }); + expect(count.status).toBe(200); + expect((await count.json() as { input_tokens: number }).input_tokens).toBeGreaterThan(0); + } + expect(selected.captured.map(body => body.model)).toEqual(Array(fastRows ? 3 : 2).fill("model-selected")); + expect(explicit.captured.map(body => body.model)).toEqual(["model-explicit", "model-explicit"]); + expect(selected.urls).toEqual(Array(fastRows ? 3 : 2).fill(new URL("/v1/chat/completions", selected.server.url).href)); + expect(explicit.urls).toEqual(Array(2).fill(new URL("/v1/chat/completions", explicit.server.url).href)); + expect(fallback.urls).toEqual([]); + const count = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", headers: desktopRequestHeaders, + body: JSON.stringify({ model: "claude-opus-4-8-20260203--fast", messages: [{ role: "user", content: "hello" }] }), + }); + expect(count.status).toBe(200); + expect((await count.json() as { input_tokens: number }).input_tokens).toBeGreaterThan(0); + expect(explicit.urls).toHaveLength(2); + expect(fallback.urls).toEqual([]); + } finally { + await server.stop(true); + selected.server.stop(true); explicit.server.stop(true); fallback.server.stop(true); + buildDesktop3pRegistry([], []); + } +}, { timeout: SERVER_BUDGET_MS }); +} diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index 0a2d151a1b..34a49be617 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -44,6 +44,22 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(info!.capabilities.effort.max.supported).toBe(true); }); + test("readable Fable rows keep base and 1M selections distinct in Claude Code", () => { + const infos = buildAnthropicModelInfos([], [{ + provider: "anthropic", + id: "claude-fable-5-1", + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + }], undefined, "readable"); + + expect(infos.map(info => info.id)).toEqual([ + "claude-fable-5-1", + "claude-ocx-native--claude-fable-5-1[1m]", + ]); + expect(infos[1]!.display_name).toBe("claude-fable-5-1 (anthropic) · 1M"); + expect(infos[1]!.max_input_tokens).toBe(1_000_000); + }); + test("native effective ladder only advertises clamp-identity rungs (audit R4#1)", () => { for (const slug of ["gpt-5.5", "gpt-5.4", "gpt-5.6-sol"]) { for (const rung of nativeEffectiveLadder(slug)) { @@ -174,3 +190,29 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(hashed.map(i => i.id).some(id => id.startsWith("claude-ocx-"))).toBe(false); }); }); + + +describe("saved picker order changes groups after identity selection", () => { + test.each(["readable", "desktop3p"] as const)("%s keeps native and featured groups, metadata and siblings", idStyle => { + const models = [ + { provider: "p", id: "featured", contextWindow: 1_000_000, reasoningEfforts: ["high"] }, + { provider: "p", id: "a", contextWindow: 1_000_000, reasoningEfforts: ["high"] }, + { provider: "p", id: "b", contextWindow: 1_000_000, reasoningEfforts: ["high"] }, + ]; + const alias = (provider: string, id: string) => `${provider}-${id}`; + const before = buildAnthropicModelInfos(["gpt-5.5"], models, undefined, idStyle, alias, undefined, false, () => true); + const after = buildAnthropicModelInfos(["gpt-5.5"], models, undefined, idStyle, alias, undefined, false, () => true, + { modelPickerOrder: ["p/b", "p/a", "p/featured"], featured: ["p/featured"] }); + expect(after.filter(row => !row.id.includes("[1m]") && !row.id.endsWith("--fast")).map(row => row.display_name)) + .toEqual(["gpt-5.5 (native)", "featured (p)", "b (p)", "a (p)"]); + expect(after.toSorted((a, b) => a.id.localeCompare(b.id))).toEqual(before.toSorted((a, b) => a.id.localeCompare(b.id))); + const b = after.findIndex(row => row.display_name === "b (p)"); + expect(after.slice(b, b + 3).map(row => row.display_name)).toEqual(["b (p)", "b (p) · 1M", "b (p) · Fast"]); + }); + test("a saved sort never changes the first-wins alias collision mapping", () => { + const models = [{ provider: "p", id: "a" }, { provider: "p", id: "b" }]; + const result = buildAnthropicModelInfos([], models, undefined, "desktop3p", () => "collision", undefined, false, undefined, + { modelPickerOrder: ["p/b", "p/a"] }); + expect(result.map(row => [row.id, row.display_name])).toEqual([["collision", "a (p)"]]); + }); +}); diff --git a/tests/claude-integration/claude-models-discovery.test.ts b/tests/claude-integration/claude-models-discovery.test.ts index 5fd77c96ec..608a1ffa80 100644 --- a/tests/claude-integration/claude-models-discovery.test.ts +++ b/tests/claude-integration/claude-models-discovery.test.ts @@ -141,6 +141,36 @@ test("per-surface id style: ?ids= wins, claude-code UA gets readable, unknown UA } }); +test("Codex discovery bounds proven custom Astra before any disk sync and preserves a gateway namesake", async () => { + const config = configWithStaticModels(); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + liveModels: false, + }; + config.providers.YYLJ = { adapter: "openai-chat", baseUrl: "https://gateway.example.test/v1", liveModels: false, models: ["gpt-6-astra"] }; + config.customModels = ["openai", "YYLJ"].map(provider => ({ + id: `${provider}-astra`, provider, modelId: "gpt-6-astra", + reasoningEfforts: ["none", "minimal", "low"], defaultReasoningEffort: "minimal", + })); + saveConfig(config); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/models?client_version=0.153.4", server.url)); + expect(response.status).toBe(200); + const catalog = await response.json() as { models: Array<{ slug: string; supported_reasoning_levels: Array<{ effort: string }>; default_reasoning_level?: string }> }; + const canonical = catalog.models.find(row => row.slug === "openai/gpt-6-astra"); + expect(canonical?.supported_reasoning_levels.map(level => level.effort)).toEqual(["low"]); + expect(canonical?.default_reasoning_level).toBe("low"); + const gateway = catalog.models.find(row => row.slug === "YYLJ/gpt-6-astra"); + expect(gateway?.supported_reasoning_levels.map(level => level.effort)).toEqual(["none", "minimal", "low", "max", "ultra"]); + expect(gateway?.default_reasoning_level).toBe("minimal"); + } finally { + await server.stop(true); + } +}); + test("OpenAI list shape and Codex catalog shape stay unchanged", async () => { saveConfig(configWithStaticModels()); const server = startServer(0); diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index c8c798ac9a..44cf9333f4 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -5,6 +5,8 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -206,6 +208,47 @@ test("count_tokens passes through with native credentials", async () => { } }); +test("Fable 1M picker alias preserves native passthrough on both Messages endpoints", async () => { + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); + const server = startServer(0); + const pickerModel = "claude-ocx-native--claude-fable-5-1"; + try { + const messagesWithoutMarker = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), model: pickerModel }), + }); + expect(messagesWithoutMarker.status).toBe(200); + await messagesWithoutMarker.text(); + + const messagesWithMarker = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), model: `${pickerModel}[1m]` }), + }); + expect(messagesWithMarker.status).toBe(200); + await messagesWithMarker.text(); + + const countTokens = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ model: `${pickerModel}[1m]`, messages: [{ role: "user", content: "hi" }] }), + }); + expect(countTokens.status).toBe(200); + expect(await countTokens.json()).toEqual({ input_tokens: 4242 }); + + expect(captured).toHaveLength(3); + expect(captured[0]!.body.model).toBe("claude-fable-5-1"); + expect(captured[1]!.body.model).toBe("claude-fable-5-1"); + expect(captured[2]!.body.model).toBe("claude-fable-5-1"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("exposed native passthrough requires dedicated admission and never forwards admission credentials", async () => { const admissionSecret = "sk-ant-api03-key"; const providerBearer = "sk-ant-oat01-provider"; @@ -298,6 +341,56 @@ test("exposed native passthrough requires dedicated admission and never forwards } }); +test.each([false, true])("Desktop mapping errors follow admission on both endpoints (fastRows=%s)", async fastRows => { + const admissionSecret = "desktop-admission-fixture"; + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + const config = cfg(upstream.url.origin); + config.hostname = "0.0.0.0"; + config.fastRows = fastRows; + config.apiKeys = [{ id: "desktop", name: "desktop", key: admissionSecret, createdAt: "2026-09-06" }]; + // Any default-provider fallback is observable at the same upstream as native dispatch. + config.providers.mock!.baseUrl = new URL("/v1", upstream.url).href; + saveConfig(config); + const server = startServer(0); + buildDesktop3pRegistry([], []); + try { + for (const path of ["/v1/messages", "/v1/messages/count_tokens"]) { + for (const model of ["claude-opus-4-8-20260202", "claude-opus-4-8-20260202--fast[1m]", "claude-opus-4-8-zzz"]) { + for (const credential of [undefined, "wrong-admission-fixture", admissionSecret]) { + const headers = new Headers(OAUTH_HEADERS); + if (credential !== undefined) headers.set("x-opencodex-api-key", credential); + const response = await globalThis.fetch(`http://127.0.0.1:${server.port}${path}`, { + method: "POST", headers, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ ...claudeBody(), model }), + }); + const body = await response.json() as { type: string; error: { type: string; code?: string; message: string } }; + expect(body.type).toBe("error"); + if (credential !== admissionSecret) { + expect(response.status).toBe(401); + expect(body.error.type).toBe("authentication_error"); + expect(body.error.code).not.toBe("desktop_model_mapping_unavailable"); + expect(response.headers.get("retry-after")).toBeNull(); + } else if (model === "claude-opus-4-8-zzz") { + expect(response.status).toBe(400); + expect(body.error.type).toBe("invalid_request_error"); + expect(response.headers.get("retry-after")).toBeNull(); + } else { + expect(response.status).toBe(503); + expect(body.error).toMatchObject({ type: "api_error", code: "desktop_model_mapping_unavailable" }); + expect(response.headers.get("retry-after")).toBe("1"); + } + expect(captured).toEqual([]); + } + } + } + } finally { + await server.stop(true); + upstream.stop(true); + buildDesktop3pRegistry([], []); + } +}, { timeout: SERVER_BUDGET_MS }); + test("alias/mapped models and non-anthropic credentials do NOT pass through", async () => { const captured: Captured[] = []; const upstream = mockAnthropicUpstream(captured); @@ -493,3 +586,57 @@ test("P5: Files API image source passes through untouched", async () => { upstream.stop(true); } }); + + +test.each([false, true])("catalog-published native dates retain identity while unknown dates are unavailable (fastRows=%s)", async fastRows => { + const published = "claude-opus-4-8-20260402"; + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + const config = cfg(upstream.url.origin, { desktopNativeModels: false }); + config.fastRows = fastRows; + config.providers.anthropic = { + adapter: "anthropic", baseUrl: upstream.url.origin, apiKey: "test-native-key", + allowPrivateNetwork: true, liveModels: false, models: [published], + }; + saveConfig(config); + buildDesktop3pRegistry([], []); + const server = startServer(0); + try { + // Publish the fixture's genuine identity through the real hub catalog path. + const catalog = await fetch(new URL("/v1/models?ids=desktop", server.url), { + headers: { "anthropic-version": "2023-06-01" }, signal: AbortSignal.timeout(5_000), + }); + expect(catalog.status).toBe(200); + const list = await catalog.json() as { data: Array<{ id: string }> }; + expect(list.data.some(row => row.id === published)).toBe(true); + for (const model of [published, `${published}[1m]`, "claude-opus-4-8", "claude-haiku-4-5"]) { + for (const path of ["/v1/messages", "/v1/messages/count_tokens"]) { + const response = await fetch(new URL(path, server.url), { + method: "POST", headers: OAUTH_HEADERS, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ ...claudeBody(), model }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured.at(-1)!.body.model).toBe(model.replace("[1m]", "")); + expect(captured.at(-1)!.path).toBe(path); + } + } + expect(captured).toHaveLength(8); + for (const path of ["/v1/messages", "/v1/messages/count_tokens"]) { + const response = await fetch(new URL(path, server.url), { + method: "POST", headers: OAUTH_HEADERS, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ ...claudeBody(), model: "claude-opus-4-8-20260403" }), + }); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + expect((await response.json() as { error: { type: string; code: string } }).error).toMatchObject({ + type: "api_error", code: "desktop_model_mapping_unavailable", + }); + } + expect(captured).toHaveLength(8); + } finally { + await server.stop(true); + upstream.stop(true); + buildDesktop3pRegistry([], []); + } +}, { timeout: SERVER_BUDGET_MS }); diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index 41c4c170fd..04be2e4fa6 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -349,10 +349,83 @@ describe("generic OAuth pool-settings contract (#695)", () => { const calls: Captured[] = []; const out = capture(); try { - expect(await cmdAutoSwitch(["google-antigravity", "threshold", "90"], genericDeps(() => ({ json: { ok: true, autoSwitchThreshold: 90 } }), calls))).toBe(0); + expect(await cmdAutoSwitch(["google-antigravity", "threshold", "90"], genericDeps(() => ({ json: { ok: true, autoSwitchThreshold: 90, enabled: true, inert: true } }), calls))).toBe(0); } finally { out.restore(); } expect(calls[0]).toMatchObject({ method: "PUT", path: "/api/oauth/accounts/pool", body: { provider: "google-antigravity", autoSwitchThreshold: 90 } }); - expect(out.lines.join("\n")).toContain("threshold 90%"); + expect(out.lines.join("\n")).toContain("stored threshold 90%"); + expect(out.lines.join("\n")).toContain("inactive"); + expect(out.lines.join("\n")).not.toContain("auto-switch: on"); + }); + + test("generic status preserves configured pool state without claiming an inert threshold is active", async () => { + for (const poolEnabled of [true, false, null]) { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ + json: { kind: "generic", enabled: poolEnabled, autoSwitchThreshold: 90, inert: true }, + }), calls))).toBe(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ method: "GET", path: "/api/oauth/accounts/pool?provider=google-antigravity" }); + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: 90, enabled: false, poolEnabled, inert: true, + }); + } + }); + + test("generic writes report the confirmed DTO, not the requested threshold", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "on", "--json"], genericDeps(() => ({ + json: { ok: true, enabled: null, autoSwitchThreshold: null, inert: true }, + }), calls))).toBe(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toEqual({ provider: "google-antigravity", autoSwitchThreshold: 80 }); + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: null, enabled: false, poolEnabled: null, inert: true, + }); + }); + + test("generic missing or malformed capability stays unknown rather than enabled", async () => { + for (const json of [null, [], {}, { enabled: "true", autoSwitchThreshold: "90", inert: "false" }, + { enabled: true, autoSwitchThreshold: 90 }, { enabled: true, autoSwitchThreshold: 101, inert: false }, + { enabled: true, autoSwitchThreshold: 90, inert: false }]) { + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ json }), []))).toBe(0); + } finally { out.restore(); } + const result = JSON.parse(out.lines.join("\n")); + expect(result.enabled).toBe(false); + expect(result.autoSwitchThreshold === null || result.autoSwitchThreshold === 90).toBe(true); + } + }); + + test("a successful generic write with a null body reports unknown settings", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "off", "--json"], genericDeps(() => ({ json: null }), calls))).toBe(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toEqual({ provider: "google-antigravity", autoSwitchThreshold: 0 }); + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: null, enabled: false, poolEnabled: null, inert: null, + }); + }); + + test("an inert zero threshold remains distinct from an unset threshold", async () => { + for (const autoSwitchThreshold of [0, null]) { + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ + json: { enabled: true, autoSwitchThreshold, inert: true }, + }), []))).toBe(0); + } finally { out.restore(); } + expect(JSON.parse(out.lines.join("\n"))).toMatchObject({ autoSwitchThreshold, enabled: false, inert: true }); + } }); test("api-key providers are still refused before any request", async () => { diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 58146c6383..f6792933f1 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../../src/cli/account"; import type { AccountStdin } from "../../src/cli/account-api"; +import { projectCodexQuotaRefreshOutcome } from "../../src/codex/quota-refresh-outcome"; import { printSubcommandUsage } from "../../src/cli/help"; import { DEFAULT_ACCOUNT_PRIORITY, @@ -584,6 +585,69 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("main quota diagnostics survive opt-in JSON without copying upstream data", async () => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, + quotaRefresh: { status: "http_error", httpStatus: 503, message: RAW_SENTINEL } }]; + const result = await run(["list", "openai", "--quota", "--refresh", "--json"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).accounts[0].quotaRefresh).toEqual({ status: "http_error", httpStatus: 503 }); + expect(result.output).not.toContain(RAW_SENTINEL); + const ordinary = await run(["list", "openai", "--json"]); + expect(JSON.parse(ordinary.stdout).accounts[0]).not.toHaveProperty("quotaRefresh"); + }); + + test.each([ + { status: "private-status-canary" }, + { status: "http_error", httpStatus: "503" }, + { status: "http_error", httpStatus: 999 }, + { status: "http_error", httpStatus: 503.5 }, + null, + ])("invalid quota diagnostic is omitted: %j", async quotaRefresh => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, quotaRefresh }]; + const result = await run(["list", "openai", "--quota", "--json"]); + expect(JSON.parse(result.stdout).accounts[0]).not.toHaveProperty("quotaRefresh"); + expect(result.output).not.toContain("canary"); + }); + + test.each(["ok", "not_reported", "timeout", "network_error", "invalid_response", "internal_error"])( + "quota JSON reconstructs %s without extra fields", async status => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, + quotaRefresh: { status, httpStatus: 503, accountId: RAW_SENTINEL, nested: { token: RAW_SENTINEL } } }]; + const result = await run(["list", "openai", "--quota", "--json"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).accounts[0].quotaRefresh).toEqual({ status }); + expect(result.output).not.toContain(RAW_SENTINEL); + }, + ); + + test.each([ + { value: undefined }, + { value: null }, + { value: [] }, + { value: "private-diagnostic-canary" }, + { value: 0 }, + { value: true }, + { value: {} }, + { value: { status: "private-status-canary" } }, + { value: { status: "http_error" } }, + { value: { status: "http_error", httpStatus: NaN } }, + { value: { status: "http_error", httpStatus: Infinity } }, + { value: { status: "http_error", httpStatus: 99 } }, + { value: { status: "http_error", httpStatus: 600 } }, + { value: { status: "http_error", httpStatus: 403.5 } }, + { value: { status: "http_error", httpStatus: "403" } }, + ])("diagnostic projector rejects invalid values: %j", ({ value }) => { + expect(projectCodexQuotaRefreshOutcome(value)).toBeUndefined(); + }); + + test.each([100, 599])("diagnostic projector bounds HTTP status %s and strips extra fields", httpStatus => { + const source = { status: "http_error", httpStatus, token: RAW_SENTINEL }; + const projected = projectCodexQuotaRefreshOutcome(source); + expect(projected).toEqual({ status: "http_error", httpStatus }); + expect(projected).not.toBe(source); + expect(JSON.stringify(projected)).not.toContain(RAW_SENTINEL); + }); + test("1: list renders all three account families, main alias, and padded columns", async () => { const result = await run(["list"]); diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 88ef3ed85f..f3d102d1b9 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -3,13 +3,14 @@ import { CLI_COMMANDS } from "../../src/cli/registry"; import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideStartWithLiveOwner } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { runGuiCommand } from "../../src/cli/gui"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigDir } from "../../src/config"; import { getAccountSet, removeCredential, saveCredential } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import type { OcxConfig } from "../../src/types"; /** Minimal fake deps. dispatchCommand only touches deps for real command * runners, which these tests never invoke, so an empty object is enough. */ @@ -94,6 +95,80 @@ describe("dispatchCommand exit codes", () => { } }); + test.each(["applied", "catalog-only", "refused"] as const)( + "sync with no live proxy reports Aside unavailability after Codex %s without local fallback", async status => { + const home = mkdtempSync(join(tmpdir(), "ocx-dispatch-aside-offline-")); + const previous = { OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const syncModule = await import("../../src/codex/sync"); + const catalogModule = await import("../../src/integrations/catalog-refresh"); + const livenessModule = await import("../../src/server/proxy-liveness"); + const warnings: string[] = []; + const logs: string[] = []; + const sync = spyOn(syncModule, "syncModelsToCodex").mockResolvedValue({ + status, ok: status !== "refused", added: 0, catalogPath: null, catalogExists: false, + catalogWritten: false, cacheSynced: false, message: "fixture Codex sync result", + }); + // The real Aside helper/runtime client must run. Fence the independent local + // writer and unscoped discovery so this regression cannot reach user files + // or a developer's real proxy if either dispatch boundary regresses. + const localRefresh = spyOn(catalogModule, "refreshOwnedCatalogIntegrations").mockResolvedValue([]); + // A globally discoverable proxy must not override the injected null result. + const unscopedDiscovery = spyOn(livenessModule, "findLiveProxy").mockResolvedValue({ + pid: null, port: 65534, hostname: "127.0.0.1", source: "config", + }); + const http = spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected runtime HTTP request")); + const warn = spyOn(console, "warn").mockImplementation((...args) => { warnings.push(args.map(String).join(" ")); }); + const log = spyOn(console, "log").mockImplementation((...args) => { logs.push(args.map(String).join(" ")); }); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.CODEX_HOME); + const config = { + port: 10100, providers: {}, defaultProvider: "openai", + asideProfileSync: { allProfiles: true, profiles: {} }, + } as OcxConfig; + const configPath = join(home, "config.json"); + const before = JSON.stringify(config); + writeFileSync(configPath, before); + let discoveries = 0; + const args = ["sync"]; + const deps = { + ...fakeDeps, args, loadConfig: () => config, + findLiveProxy: async () => { discoveries += 1; return null; }, + }; + const code = await dispatchCommand({ kind: "command", command: "sync", args }, deps); + // An Aside warning does not change a successful Codex sync's exit code. + expect(code).toBe(status === "refused" ? 1 : 0); + expect(discoveries).toBe(1); + expect(sync).toHaveBeenCalledTimes(1); + expect(unscopedDiscovery).not.toHaveBeenCalled(); + expect(http).not.toHaveBeenCalled(); + expect(localRefresh).not.toHaveBeenCalled(); + if (status === "refused") { + expect(warnings).toEqual([]); + } else { + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Aside profiles were not refreshed:"); + expect(warnings[0]).toContain("Proxy is not running"); + expect(warnings[0]).toContain("ocx start"); + } + expect(logs.join("\n")).not.toContain("integration refreshed"); + expect(readFileSync(configPath, "utf8")).toBe(before); + expect(readdirSync(home).sort()).toEqual(["codex", "config.json"]); + expect(readdirSync(join(home, "codex"))).toEqual([]); + } finally { + sync.mockRestore(); localRefresh.mockRestore(); unscopedDiscovery.mockRestore(); http.mockRestore(); + warn.mockRestore(); log.mockRestore(); error.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }, + ); + test("returns 0 for help forms", async () => { expect(await dispatchCommand({ kind: "help", command: "help", args: ["help"] }, fakeDeps)).toBe(0); expect(await dispatchCommand({ kind: "help", command: "--help", args: ["--help"] }, fakeDeps)).toBe(0); diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 28fce31d44..4c9808677a 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -430,3 +430,116 @@ describe("export row filtering", () => { expect(model?.defaultReasoningEffort).toBe("high"); }); }); + +describe("export allowlist parity", () => { + test("the first export rereads selection completed during model discovery", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const pending = config({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(exportModelsFromProxyRows(rows, pending)).toEqual([]); + let requests = 0; + try { + process.env.OPENCODEX_HOME = home; + writeFileSync(path, JSON.stringify(pending)); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + fetchImpl: async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + requests += 1; + // The server publishes its finalized selection before returning the rows. + writeFileSync(path, JSON.stringify(ready)); + return Response.json(rows); + }, + }); + expect(code).toBe(0); + expect(requests).toBe(1); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + + test("post-discovery filtering retains injected config provenance instead of reading local policy", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const local = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://local.example.test/v1", selectedModels: ["local-only"], + } } }); + const remote = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://remote.example.test/v1", selectedModels: ["remote-only"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } } }); + const ready = structuredClone(remote); + ready.providers.custom!.initialModelSelection!.status = "ready"; + let resolved = remote; + const events: string[] = []; + try { + process.env.OPENCODEX_HOME = home; + const localBytes = JSON.stringify(local); + writeFileSync(path, localBytes); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + configImpl: () => { events.push("config"); return structuredClone(resolved); }, + fetchImpl: async () => { + events.push("fetch"); + resolved = ready; + return Response.json(["local-only", "remote-only"].map(id => ({ provider: "custom", id, namespaced: `custom/${id}` }))); + }, + }); + expect(code).toBe(0); + expect(events).toEqual(["fetch", "config"]); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["custom/remote-only"]); + expect(readFileSync(path, "utf8")).toBe(localBytes); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + + test("filters the full management roster before deduplication and keeps other providers", () => { + const cfg = config(); + cfg.providers.xai = { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", + selectedModels: ["grok-4.6"], + }; + const rows = [ + { provider: "xai", id: "grok-4.5", namespaced: "xai/grok-4.5", disabled: false }, + { provider: "xai", id: "grok-4.6", namespaced: "xai/grok-4.6", disabled: true }, + { provider: "xai", id: "grok-4.6", namespaced: "xai/grok-4.6", reasoningEfforts: ["high"] }, + { provider: "other", id: "model", namespaced: "other/model" }, + ]; + const exported = exportModelsFromProxyRows(rows, cfg); + expect(exported.map(row => row.namespaced)).toEqual(["xai/grok-4.6", "other/model"]); + expect(exported[0]!.reasoningEfforts).toEqual(["high"]); + cfg.disabledModels = ["xai/grok-4.6"]; + expect(exportModelsFromProxyRows(rows, cfg).map(row => row.namespaced)).toEqual(["other/model"]); + }); + + test("uses the catalog's encoded-id selection equivalence", () => { + const cfg = config(); + cfg.providers.slash = { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", selectedModels: ["org-model"], + }; + expect(exportModelsFromProxyRows([ + { provider: "slash", id: "org/model", namespaced: "slash/org-model" }, + { provider: "slash", id: "other", namespaced: "slash/other" }, + ], cfg).map(row => row.namespaced)).toEqual(["slash/org-model"]); + }); +}); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 76fd465199..0a9e90afe4 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -233,6 +233,7 @@ describe("headless GUI parity CLI", () => { // skipping the endpoint. ["/api/github/star", "(none — GUI-only)"], ["/api/oauth", "ocx account"], + ["/api/accounts/events", "(none — dashboard invalidation; ocx account reads current selection)"], ["/api/providers/keys", "ocx account"], ["/api/providers", "ocx provider"], ["/api/provider-", "ocx provider/models"], @@ -952,3 +953,133 @@ describe("#2566 per-account quota in ocx account list", () => { expect(formatAccountTable([row({ quotaUnavailable: true })] as never, true)).toContain("unavailable"); }); }); + +describe("Aside profile integration CLI", () => { + test("scopes status, toggle, history and restore without changing other client routes", async () => { + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(["status", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["disable", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["history", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["restore", "--client", "aside", "--profile", "2", "--op", "op-profile", "--json"], runtime.deps)).toBe(0); + expect(runtime.requests.map(row => row.path)).toEqual([ + "/api/client-integrations/aside/profiles/2", + "/api/client-integrations/aside/profiles/2", + "/api/client-integrations/aside/profiles/2/journal", + "/api/client-integrations/aside/profiles/2/restore", + ]); + expect(runtime.requests[1]!.body).toEqual({ enabled: false }); + expect(runtime.requests[3]!.body).toEqual({ opId: "op-profile", confirmDrift: false }); + }); + + test.each([ + ["enable", "--client", "pi", "--profile", "0"], + ["status", "--profile", "0"], + ["enable", "--client", "aside", "--profile", "../0"], + ["disable", "--client", "aside", "--profile", "01"], + ["restore", "--client", "aside", "--op", "op-profile"], + ].map(args => ({ args })))("rejects unsupported or ambiguous profile selectors before a request: $args", async ({ args }) => { + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(args, runtime.deps)).toBe(2); + expect(runtime.requests).toHaveLength(0); + }); + + test("unqualified Aside enable remains bulk and reports partial failure as nonzero", async () => { + const runtime = fakeRuntime(() => ({ + ok: false, clientId: "aside", message: "one profile refused", + results: [{ profileId: 0, ok: true, message: "updated" }, { profileId: 1, ok: false, message: "conflict" }], + })); + expect(await handleClientIntegrationCommand(["enable", "--client", "aside", "--json"], runtime.deps)).toBe(1); + expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true } }); + }); +}); + +test("Aside status prints the empty-profile diagnostic for humans", async () => { + const runtime = fakeRuntime(() => ({ profiles: [], error: "Open Aside to create a profile" })); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand(["status", "--client", "aside"], runtime.deps)).toBe(0); + expect(log.mock.calls.flat().join("\n")).toContain("Open Aside to create a profile"); + } finally { log.mockRestore(); } +}); + +describe("Aside CLI recovery metadata", () => { + test.each([ + { name: "a long POSIX backup path", snapshotPath: `/tmp/aside-recovery/${"profile-2-snapshot/".repeat(80)}models.json.bak` }, + { name: "a Windows backup path with spaces and Unicode", snapshotPath: String.raw`C:\Aside Recovery\프로필 2\models.json.before-write` }, + { name: "no backup path", snapshotPath: undefined }, + ])("a refused restore preserves recovery guidance after a long message: $name", async ({ snapshotPath }) => { + const message = `Restore failed: ${"the profile file could not be replaced; ".repeat(80)}`; + const runtime = fakeRuntime(() => Response.json({ + ok: false, clientId: "aside", profileId: 2, state: "absent", + message, reason: "write_failed", residual: true, + ...(snapshotPath === undefined ? {} : { snapshotPath }), + }, { status: 500 })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand([ + "restore", "--client", "aside", "--profile", "2", "--op", "op-recovery", + ], runtime.deps)).toBe(1); + const stderr = error.mock.calls.map(call => String(call[0])).join("\n"); + expect(stderr).toContain("Restore failed:"); + // Recovery fields have their own output budget, after the bounded main message. + expect(stderr).not.toContain(message); + expect(stderr.split("\n")).toContain("Automatic recovery did not finish; check the client configuration before retrying."); + if (snapshotPath !== undefined) { + expect(stderr.split("\n")).toContain(`Backup: ${snapshotPath}`); + } else { + expect(stderr).not.toContain("Backup:"); + } + expect(log.mock.calls).toHaveLength(0); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles/2/restore", method: "POST", + body: { opId: "op-recovery", confirmDrift: false }, + }]); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); + + test.each([false, true])("bulk 207 retains each profile's recovery metadata and fails nonzero (json=%s)", async wantsJson => { + const snapshotPath = "/tmp/aside-recovery/profile 2/models.json.before-write"; + const otherSnapshot = String.raw`C:\Aside Recovery\profile 7\models.json.bak`; + const longMessage = `Profile 2 write failed: ${"could not replace models.json; ".repeat(80)}`; + const result = { + ok: false, clientId: "aside", message: "Three profiles could not be updated", + results: [ + { profileId: 0, ok: true, message: "updated" }, + { profileId: 2, ok: false, message: longMessage, reason: "write_failed", snapshotPath, residual: true }, + { profileId: 7, ok: false, message: "Profile 7 is conflicted", reason: "conflict", snapshotPath: otherSnapshot, residual: false }, + { profileId: 9, ok: false, message: "Profile 9 recovery failed", reason: "write_failed", residual: true }, + ], + }; + const runtime = fakeRuntime(() => Response.json(result, { status: 207 })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand([ + "enable", "--client", "aside", ...(wantsJson ? ["--json"] : []), + ], runtime.deps)).toBe(1); + const stdout = log.mock.calls.map(call => String(call[0])).join("\n"); + if (wantsJson) { + expect(JSON.parse(stdout)).toEqual(result); + } else { + // Exact rows catch dropped/truncated paths and metadata leaking to a sibling. + expect(stdout.split("\n")).toEqual([ + "aside:0 updated", + `aside:2 ${longMessage} Recovery did not finish. Backup: ${snapshotPath}`, + `aside:7 Profile 7 is conflicted Backup: ${otherSnapshot}`, + "aside:9 Profile 9 recovery failed Recovery did not finish.", + ]); + } + expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true }, + }]); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); +}); diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index 039cc9cc2f..7b59e5deed 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -1,6 +1,7 @@ -import { beforeAll, describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; import { createServer } from "node:net"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; @@ -11,6 +12,10 @@ import * as statusFacade from "../../src/cli/status"; import * as statusProbes from "../../src/cli/status-probes"; import { findDeadPid } from "../helpers/dead-pid"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { STORE_BUDGET_MS } from "../helpers/test-budget"; +import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../../src/client/state"; +import * as lifecycleLock from "../../src/client/lifecycle-lock"; +import { writeDesktopDisconnectReceipt } from "../../src/claude/desktop-remote-store"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); @@ -25,6 +30,131 @@ function runStatusJson(opencodexHome: string) { }); } +function withRecoveryStatusFixture(work: (fixture: { + home: string; + lockDeps: { lockPath: string }; + tokenPath: string; + backupPath: string; + config: ReturnType; + writeConfig: () => void; +}) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-status-recovery-")); + const previousHome = process.env.OPENCODEX_HOME; + const previousDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + process.env.OPENCODEX_HOME = home; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(home, "desktop"); + const tokenPath = join(home, "service-api-token"); + const config = recoveryStatusConfig(); + const writeConfig = () => writeFileSync(join(home, "config.json"), JSON.stringify(config)); + try { + writeConfig(); + writeFileSync(tokenPath, "status-fixture-token", { mode: 0o600 }); + work({ home, config, writeConfig, tokenPath, backupPath: `${tokenPath}.prev`, + lockDeps: { lockPath: join(home, "locks", "lifecycle.sqlite") } }); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDesktop === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktop; + removeTreeWithRetry(home); + } +} + +function recoveryStatusConfig() { + return { + port: 9, defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + runtimeRole: "client", + client: { + serverUrl: "https://hub.example.test", managementUrl: "https://hub.example.test", + managementTransport: "direct", selectedClients: ["claude"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "status-fixture", tokenFingerprint: createHash("sha256").update("status-fixture-token").digest("hex"), + protocolVersion: 1, connectedAt: "2026-09-06T00:00:00.000Z", + }, + }; +} + +describe("status recovery inspection is read-only unless an orphan needs cleanup", () => { + test.each(["clean", "disconnected", "malformed", "pending", "unsafe-backup", "unsafe-receipt", "unsafe-desktop"])( + "%s observation creates neither lifecycle nor config database", scenario => { + withRecoveryStatusFixture(f => { + if (scenario === "disconnected") writeFileSync(join(f.home, "config.json"), JSON.stringify({ port: 9, providers: {} })); + if (scenario === "malformed") writeFileSync(join(f.home, "config.json"), "{malformed"); + if (scenario === "pending") { + Object.assign(f.config.client, { pendingOperation: { + kind: "rotate", rotationId: "fixture-rotation", newKeyIssuedAt: "2026-09-06T00:00:01.000Z", oldKeyBackupPath: f.backupPath, + } }); + f.writeConfig(); + writeFileSync(f.backupPath, "status-fixture-token", { mode: 0o600 }); + } + if (scenario === "unsafe-backup") mkdirSync(f.backupPath); + if (scenario === "unsafe-receipt" || scenario === "unsafe-desktop") { + mkdirSync(join(f.home, "desktop-remote"), { mode: 0o700 }); + writeFileSync(join(f.home, "desktop-remote", scenario === "unsafe-receipt" ? "disconnect.json" : "state.json"), "{bad", { mode: 0o600 }); + writeFileSync(f.backupPath, "status-fixture-token", { mode: 0o600 }); + } + const before = readdirSync(f.home).sort(); + const configBefore = readFileSync(join(f.home, "config.json"), "utf8"); + const result = inspectClientRotationRecoveryGate(undefined, f.lockDeps); + expect(result.kind).toBe(scenario === "pending" || scenario === "unsafe-desktop" ? "recovery-required" + : scenario.startsWith("unsafe-") ? "unsafe" : "clean"); + expect(readdirSync(f.home).sort()).toEqual(before); + expect(readFileSync(join(f.home, "config.json"), "utf8")).toBe(configBefore); + expect(existsSync(join(f.home, "locks"))).toBe(false); + expect(existsSync(join(f.home, "config-mutation.sqlite"))).toBe(false); + if (scenario === "pending" || scenario.startsWith("unsafe-")) expect(existsSync(f.backupPath)).toBe(true); + }); + }, + ); + + test("only a proven orphan takes L/C; a held L preserves its backup", () => { + withRecoveryStatusFixture(f => { + writeFileSync(f.backupPath, "status-fixture-token", { mode: 0o600 }); + lifecycleLock.withClientLifecycleSync(() => { + expect(inspectClientRotationRecoveryGate(undefined, f.lockDeps)).toEqual({ kind: "recovery-required", reason: "client_lifecycle_busy" }); + expect(existsSync(f.backupPath)).toBe(true); + expect(existsSync(join(f.home, "config-mutation.sqlite"))).toBe(false); + }, f.lockDeps); + expect(inspectClientRotationRecoveryGate(undefined, f.lockDeps)).toEqual({ kind: "orphan-cleaned" }); + expect(existsSync(f.backupPath)).toBe(false); + expect(existsSync(join(f.home, "config-mutation.sqlite"))).toBe(true); + expect(inspectClientRotationRecoveryGate(undefined, f.lockDeps)).toEqual({ kind: "clean" }); + }); + }, STORE_BUDGET_MS); + + test.each(["rotation", "token-changed", "disconnect", "backup-removed"])( + "revalidates %s after acquiring L rather than using the initial observation", transition => { + withRecoveryStatusFixture(f => { + writeFileSync(f.backupPath, "status-fixture-token", { mode: 0o600 }); + const stale = readClientConnectionState(); + const actualLock = lifecycleLock.withClientLifecycleSync; + let entered = false; + const lock = spyOn(lifecycleLock, "withClientLifecycleSync").mockImplementation((work, deps) => actualLock(held => { + entered = true; + if (transition === "rotation") { + Object.assign(f.config.client, { pendingOperation: { + kind: "rotate", rotationId: "fixture-rotation", newKeyIssuedAt: "2026-09-06T00:00:01.000Z", oldKeyBackupPath: f.backupPath, + } }); + f.writeConfig(); + } else if (transition === "token-changed") writeFileSync(f.tokenPath, "replacement-fixture-token"); + else if (transition === "backup-removed") unlinkSync(f.backupPath); + else writeDesktopDisconnectReceipt(held, null, { + version: 1, owner: { serverUrl: f.config.client.serverUrl, apiKeyId: f.config.client.apiKeyId, connectedAt: f.config.client.connectedAt }, + tokenFingerprint: f.config.client.tokenFingerprint, keepCatalog: false, phase: "prepared", + }); + return work(held); + }, deps)); + try { + const result = inspectClientRotationRecoveryGate(stale, f.lockDeps); + expect(entered).toBe(true); + expect(result.kind).toBe(transition === "token-changed" ? "unsafe" : transition === "backup-removed" ? "clean" : "recovery-required"); + expect(existsSync(f.backupPath)).toBe(transition !== "backup-removed"); + } finally { lock.mockRestore(); } + }); + }, STORE_BUDGET_MS, + ); +}); + describe("CLI status JSON", () => { test("Claude client field remains additive for TypeScript consumers", () => { expect(claudeClientFieldIsOptional).toBe(true); diff --git a/tests/cli/uninstall.test.ts b/tests/cli/uninstall.test.ts index 18b5f3e951..d32db3a4bb 100644 --- a/tests/cli/uninstall.test.ts +++ b/tests/cli/uninstall.test.ts @@ -5,6 +5,13 @@ import { } from "../../src/service"; import { pathToFileURL } from "node:url"; import { repoRoot } from "../helpers/repo-root"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeOwnedConfigAfterDesktopCleanup, type UninstallClientStateDeps } from "../../src/cli/uninstall-client-state"; +import { assertClientLifecycleHeld, withClientLifecycle, withClientLifecycleSync, type ClientLifecycleHeld } from "../../src/client/lifecycle-lock"; +import type { UninstallObservation } from "../../src/cli/uninstall-plan"; +import type { DesktopDisconnectReceipt } from "../../src/claude/desktop-remote-store"; const root = pathToFileURL(repoRoot() + "/"); @@ -24,7 +31,8 @@ describe("full uninstall command", () => { expect(cli).toContain("uninstallServiceIfInstalled"); expect(cli).toContain("uninstallCodexShim"); expect(cli).toContain("restoreNativeCodex"); - expect(cli).toContain("removeOwnedConfigState(getConfigDir())"); + expect(cli).toContain("await removeOwnedConfigAfterDesktopCleanup(observed)"); + expect(cli).not.toContain("removeOwnedConfigState(getConfigDir())"); expect(cli).not.toContain("rmSync(getConfigDir()"); }); @@ -259,3 +267,256 @@ describe("uninstall gates shared teardown on a proven service stop", () => { expect(fn).toContain("endpointsToProve(readRuntimePort(), loadConfig())"); expect(fn).toContain("everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname))"); }); + +const safeTeardown: UninstallObservation = { + serviceStop: "stopped", serviceRemoval: "removed", proxyProvenDown: true, respawnWindowVerified: false, +}; +const cleanupOwner = { + serverUrl: "https://hub.example", apiKeyId: "fixture-client", connectedAt: "2026-09-06T00:00:00.000Z", +}; +const connectedFixture: ReturnType = { + kind: "connected", + value: { + ...cleanupOwner, managementUrl: "https://hub.example", managementTransport: "direct", + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + tokenFingerprint: "a".repeat(64), protocolVersion: 1, + }, +}; +function disconnectReceipt(phase: DesktopDisconnectReceipt["phase"], keepCatalog = false): DesktopDisconnectReceipt { + return { version: 1, owner: cleanupOwner, tokenFingerprint: "a".repeat(64), phase, keepCatalog }; +} + +function uninstallFixture() { + const fixtureRoot = mkdtempSync(join(tmpdir(), "ocx-uninstall-client-")); + const configDir = join(fixtureRoot, "config"); + const lockPath = join(fixtureRoot, "runtime", "lifecycle.sqlite"); + mkdirSync(join(configDir, "desktop-remote"), { recursive: true }); + const sentinels = ["config.json", "service-api-token", "desktop-remote/state.json", "desktop-remote/baseline.json", "desktop-remote/disconnect.json"]; + for (const [index, path] of sentinels.entries()) writeFileSync(join(configDir, path), `fixture-sentinel-${index}\n`, { mode: 0o600 }); + const fixture: { + connection: ReturnType; + desktop: ReturnType; + receipt: ReturnType; + beforeFinalLock?: () => void; + beforeDisconnectLock?: () => void; + duringCleanup?: () => Promise; + duringRemove?: () => void; + finishCleanup: boolean; + lease?: ClientLifecycleHeld; + calls: { read: number; cleanup: number; remove: number; finalLock: number }; + cleanupOptions: Array[0]>; + } = { + connection: { kind: "disconnected" }, desktop: { kind: "absent" }, receipt: { kind: "absent" }, + finishCleanup: true, calls: { read: 0, cleanup: 0, remove: 0, finalLock: 0 }, cleanupOptions: [], + }; + const deps: UninstallClientStateDeps = { + readConnection: () => { fixture.calls.read++; return fixture.connection; }, + inspectDesktop: () => fixture.desktop, + readReceipt: () => fixture.receipt, + // This is a callable cleanup seam, with the same REAL SQLite L as the final + // removal. If uninstall incorrectly holds L over disconnect, this fails busy. + disconnect: async options => { + fixture.calls.cleanup++; + fixture.cleanupOptions.push(options); + fixture.beforeDisconnectLock?.(); + await withClientLifecycle(async held => { + assertClientLifecycleHeld(held); + const actualOwner = fixture.connection.kind === "connected" ? fixture.connection.value + : fixture.receipt.kind === "valid" ? fixture.receipt.value.owner : undefined; + if (options?.expectedOwner && (!actualOwner + || actualOwner.apiKeyId !== options.expectedOwner.apiKeyId + || actualOwner.serverUrl !== options.expectedOwner.serverUrl + || actualOwner.connectedAt !== options.expectedOwner.connectedAt)) throw new Error("client_disconnect_expected_owner_changed"); + await fixture.duringCleanup?.(); + if (!fixture.finishCleanup) return; + fixture.connection = { kind: "disconnected" }; + fixture.desktop = { kind: "absent" }; + fixture.receipt = { kind: "valid", value: disconnectReceipt("complete", options?.keepCatalog) }; + }, { lockPath }); + }, + withLifecycle: async work => { + fixture.calls.finalLock++; + fixture.beforeFinalLock?.(); + return withClientLifecycle(async held => { + fixture.lease = held; + try { return await work(held); } + finally { fixture.lease = undefined; } + }, { lockPath }); + }, + remove: () => { + // Real destructive work is confined to this fixture, never getConfigDir(). + assertClientLifecycleHeld(fixture.lease!); + fixture.calls.remove++; + fixture.duringRemove?.(); + rmSync(configDir, { recursive: true }); + return { status: "removed", residualPaths: [] }; + }, + }; + const bytes = () => sentinels.map(path => readFileSync(join(configDir, path), "utf8")); + return { fixtureRoot, configDir, lockPath, fixture, deps, bytes }; +} + +async function withUninstallFixture(work: (f: ReturnType) => Promise) { + const f = uninstallFixture(); + try { await work(f); } + finally { rmSync(f.fixtureRoot, { recursive: true, force: true }); } +} + +describe("uninstall client cleanup before owner-state deletion", () => { + test("teardown refusal occurs before even reading or cleaning client state", async () => { + await withUninstallFixture(async f => { + f.fixture.connection = connectedFixture; + const before = f.bytes(); + await expect(removeOwnedConfigAfterDesktopCleanup({ ...safeTeardown, proxyProvenDown: false }, f.deps)) + .rejects.toThrow("teardown is not proven"); + expect(f.fixture.calls).toEqual({ read: 0, cleanup: 0, remove: 0, finalLock: 0 }); + expect(f.bytes()).toEqual(before); + expect(existsSync(f.lockPath)).toBe(false); + }); + }); + + test.each(["invalid", "mismatched", "unsafe-desktop", "unsafe-receipt", "orphan-active", "orphan-pending", "orphan-restored", "foreign-desktop", "foreign-receipt"] as const)( + "%s refuses cleanup/removal and preserves config, token and journal bytes", async scenario => { + await withUninstallFixture(async f => { + const before = f.bytes(); + if (scenario === "invalid" || scenario === "mismatched") f.fixture.connection = { kind: scenario, reason: "fixture" }; + else if (scenario === "unsafe-desktop") f.fixture.desktop = { kind: "unsafe" }; + else if (scenario === "unsafe-receipt") f.fixture.receipt = { kind: "unsafe" }; + else if (scenario.startsWith("orphan-")) { + f.fixture.desktop = { kind: scenario === "orphan-active" ? "active" : scenario === "orphan-pending" ? "pending" : "restored", owner: cleanupOwner }; + } else { + f.fixture.connection = connectedFixture; + const foreign = { ...cleanupOwner, apiKeyId: "different-client" }; + if (scenario === "foreign-desktop") f.fixture.desktop = { kind: "active", owner: foreign }; + else f.fixture.receipt = { kind: "valid", value: { ...disconnectReceipt("prepared"), owner: foreign } }; + } + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)).rejects.toThrow("Client cleanup refused"); + expect(f.fixture.calls.cleanup).toBe(0); + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + }); + }, + ); + + test("a cleanup exception propagates, preserves sentinels and releases L", async () => { + await withUninstallFixture(async f => { + f.fixture.connection = connectedFixture; + f.fixture.desktop = { kind: "active", owner: cleanupOwner }; + f.fixture.duringCleanup = async () => { throw new Error("fixture cleanup failed"); }; + const before = f.bytes(); + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)).rejects.toThrow("fixture cleanup failed"); + expect(f.fixture.calls.cleanup).toBe(1); + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + withClientLifecycleSync(held => assertClientLifecycleHeld(held), { lockPath: f.lockPath }); + }); + }); + + test("a cleanup that returns while connected does not authorize removal", async () => { + await withUninstallFixture(async f => { + f.fixture.connection = connectedFixture; + f.fixture.finishCleanup = false; + const before = f.bytes(); + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)).rejects.toThrow("changed before removal"); + expect(f.fixture.calls.cleanup).toBe(1); + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + }); + }); + + test("a replacement connection before disconnect claims L survives uninstall", async () => { + await withUninstallFixture(async f => { + f.fixture.connection = connectedFixture; + const replacement = { ...connectedFixture.value, apiKeyId: "replacement-client" }; + f.fixture.beforeDisconnectLock = () => withClientLifecycleSync(() => { + f.fixture.connection = { kind: "connected", value: replacement }; + f.fixture.desktop = { kind: "active", owner: replacement }; + }, { lockPath: f.lockPath }); + const before = f.bytes(); + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)) + .rejects.toThrow("client_disconnect_expected_owner_changed"); + expect(f.fixture.connection).toEqual({ kind: "connected", value: replacement }); + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + expect(f.fixture.cleanupOptions[0]?.expectedOwner).toEqual(cleanupOwner); + }); + }); + + test.each([false, true])("an interrupted disconnect resumes its frozen keepCatalog=%s choice", async keepCatalog => { + await withUninstallFixture(async f => { + f.fixture.receipt = { kind: "valid", value: disconnectReceipt("connection_cleared", keepCatalog) }; + f.fixture.desktop = { kind: "pending", owner: cleanupOwner }; + expect(await removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)).toEqual({ status: "removed", residualPaths: [] }); + expect(f.fixture.cleanupOptions).toEqual([{ keepCatalog, expectedOwner: cleanupOwner }]); + expect(f.fixture.calls.cleanup).toBe(1); + expect(f.fixture.calls.remove).toBe(1); + expect(f.fixture.receipt).toEqual({ kind: "valid", value: disconnectReceipt("complete", keepCatalog) }); + }); + }); + + test.each(["connected", "invalid", "mismatched", "desktop-pending", "unsafe-desktop", "unsafe-receipt", "pending-receipt"] as const)( + "the final L-held recheck refuses racing %s state", async scenario => { + await withUninstallFixture(async f => { + f.fixture.connection = connectedFixture; + const before = f.bytes(); + f.fixture.beforeFinalLock = () => withClientLifecycleSync(held => { + assertClientLifecycleHeld(held); + if (scenario === "connected") f.fixture.connection = connectedFixture; + else if (scenario === "invalid" || scenario === "mismatched") f.fixture.connection = { kind: scenario, reason: "fixture race" }; + else if (scenario === "desktop-pending") f.fixture.desktop = { kind: "pending", owner: cleanupOwner }; + else if (scenario === "unsafe-desktop") f.fixture.desktop = { kind: "unsafe" }; + else if (scenario === "unsafe-receipt") f.fixture.receipt = { kind: "unsafe" }; + else f.fixture.receipt = { kind: "valid", value: disconnectReceipt("prepared") }; + }, { lockPath: f.lockPath }); + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)).rejects.toThrow("changed before removal"); + expect(f.fixture.calls.cleanup).toBe(1); + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + }); + }, + ); + + test.each(["standalone", "connected"] as const)("an already-held real L refuses %s cleanup/removal", async mode => { + await withUninstallFixture(async f => { + if (mode === "connected") f.fixture.connection = connectedFixture; + const before = f.bytes(); + await withClientLifecycle(async () => { + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)).rejects.toThrow("client_lifecycle_busy"); + expect(f.fixture.calls.read).toBe(1); // preflight only + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + }, { lockPath: f.lockPath }); + }); + }); + + test.each(["standalone", "connected", "terminal"] as const)("%s removes exactly once under L and excludes connect/recovery until removal finishes", async mode => { + await withUninstallFixture(async f => { + if (mode === "connected") { + f.fixture.connection = connectedFixture; + f.fixture.desktop = { kind: "active", owner: cleanupOwner }; + } + if (mode === "terminal") f.fixture.receipt = { kind: "valid", value: disconnectReceipt("complete", true) }; + let contendersRan = 0; + let removingLease: ClientLifecycleHeld | undefined; + f.fixture.duringRemove = () => { + removingLease = f.fixture.lease; + assertClientLifecycleHeld(removingLease!); + for (const operation of ["connect", "recovery"]) { + expect(() => withClientLifecycleSync(() => { + contendersRan++; + writeFileSync(join(f.configDir, "service-api-token"), `fixture-${operation}`); + }, { lockPath: f.lockPath })).toThrow("client_lifecycle_busy"); + } + expect(contendersRan).toBe(0); + expect(f.bytes()).toHaveLength(5); + }; + expect(await removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)).toEqual({ status: "removed", residualPaths: [] }); + expect(f.fixture.calls.cleanup).toBe(mode === "connected" ? 1 : 0); + expect(f.fixture.calls.remove).toBe(1); + expect(existsSync(f.configDir)).toBe(false); + expect(existsSync(f.lockPath)).toBe(true); // L is outside the directory being removed. + expect(() => assertClientLifecycleHeld(removingLease!)).toThrow("client_lifecycle_lease_invalid"); + withClientLifecycleSync(held => assertClientLifecycleHeld(held), { lockPath: f.lockPath }); + }); + }); +}); diff --git a/tests/clients/aside-profile-paths.test.ts b/tests/clients/aside-profile-paths.test.ts new file mode 100644 index 0000000000..355a7de50a --- /dev/null +++ b/tests/clients/aside-profile-paths.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, test } from "bun:test"; +import { + existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, + rmSync, symlinkSync, writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, +} from "../../src/clients/aside-profiles"; +import { ClientPathError } from "../../src/clients/config-export"; +import { defaultIntegrationIO } from "../../src/integrations/config-io"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accounts = [{ id: 0, name: "Cloud" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }]; + +function fixture(run: (home: string, root: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-aside-paths-")); + const root = join(home, ".aside"); + try { + for (const { id } of accounts) mkdirSync(join(root, "u", String(id)), { recursive: true }); + manifest(root, { currentAccountId: 0, accounts }); + run(home, root); + } finally { removeTreeWithRetry(home); } +} + +function manifest(root: string, value: unknown): void { + writeFileSync(join(root, "accounts.json"), JSON.stringify(value)); +} + +function ioFor(home: string) { + const store = createIntegrationStateStore(join(home, "integration-store")); + return { store, io: defaultIntegrationIO(store) }; +} + +function directoryLink(target: string, path: string): void { + symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir"); +} + +describe("Aside profile manifest", () => { + test("projects only safe metadata for cloud and local accounts", () => fixture((home, root) => { + manifest(root, { + currentAccountId: 1, + accounts: accounts.map(account => ({ + ...account, session: { token: "fixture-private-value" }, email: "fixture@example.test", userId: "private-user", + })), + profileAccountBindings: [{ accountId: 0, profilePath: join(home, "not-a-target") }], + }); + const profiles = listAsideProfiles({}, home); + expect(profiles).toEqual(accounts.map(account => ({ + ...account, current: account.id === 1, root, + detectDir: join(root, "u", String(account.id)), + configPath: join(root, "u", String(account.id), "models.json"), + }))); + expect(JSON.stringify(profiles)).not.toMatch(/session|token|email|userId|private-value|profilePath/); + })); + + test("supports currentAccountId-only legacy manifests without guessing zero", () => fixture((home, root) => { + manifest(root, { currentAccountId: 2 }); + expect(listAsideProfiles({}, home)).toEqual([{ + id: 2, current: true, root, detectDir: join(root, "u", "2"), configPath: join(root, "u", "2", "models.json"), + }]); + })); + + test("refuses a missing or malformed manifest with safe errors", () => fixture((home, root) => { + rmSync(join(root, "accounts.json")); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + writeFileSync(join(root, "accounts.json"), '{"session":"fixture-private-value",broken'); + try { listAsideProfiles({}, home); throw new Error("expected refusal"); } catch (error) { + expect(error).toBeInstanceOf(ClientPathError); + expect((error as Error).message).not.toContain("fixture-private-value"); + } + })); + + test("rejects malformed identities, duplicates and inconsistent current metadata", () => fixture((home, root) => { + const invalid = [ + null, [], {}, { currentAccountId: "0" }, { currentAccountId: -1 }, + { currentAccountId: 0, accounts: null }, { currentAccountId: 0, accounts: [] }, + { currentAccountId: 0, accounts: [{ id: 0 }, { id: 0 }] }, + { currentAccountId: 3, accounts }, + { currentAccountId: 0, accounts: [{ id: 0, current: false }] }, + { currentAccountId: 0, accounts: [{ id: 0 }, { id: 1, current: true }] }, + ...["1", "../2", -1, 0.5, Number.MAX_SAFE_INTEGER + 1, null].map(id => ({ + currentAccountId: 0, accounts: [{ id: 0 }, { id }], + })), + ]; + for (const value of invalid) { + manifest(root, value); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + } + writeFileSync(join(root, "accounts.json"), '{"currentAccountId":-0}'); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + })); + + test("accepts safe integer IDs and exactly 128 accounts, but never truncates overflow", () => fixture((home, root) => { + manifest(root, { currentAccountId: Number.MAX_SAFE_INTEGER }); + expect(listAsideProfiles({}, home)[0]!.id).toBe(Number.MAX_SAFE_INTEGER); + const bounded = Array.from({ length: 128 }, (_, id) => ({ id })); + manifest(root, { currentAccountId: 0, accounts: bounded }); + expect(listAsideProfiles({}, home)).toHaveLength(128); + manifest(root, { currentAccountId: 0, accounts: [...bounded, { id: 128 }] }); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + })); +}); + +describe("Aside profile filesystem boundary", () => { + test("missing account directories report not installed and cannot be recreated", () => fixture((home, root) => { + rmSync(join(root, "u", "1"), { recursive: true }); + const profiles = listAsideProfiles({}, home); + const profile = profiles[1]!; + assertAsideProfileBoundary(profile, profiles); + const guarded = guardAsideProfileIO(profile, ioFor(home).io, profiles); + expect(guarded.statKind(profile.detectDir)).toBe("missing"); + expect(guarded.readText(profile.configPath)).toEqual({ kind: "missing" }); + expect(() => assertAsideProfileBoundary(profile, profiles, true)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(profile.detectDir)).toThrow(ClientPathError); + expect(() => guarded.writeText(profile.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.removeFile(profile.configPath)).toThrow(ClientPathError); + expect(existsSync(profile.detectDir)).toBe(false); + })); + + test("allows an OS alias before the configured root", () => fixture((home, root) => { + const alias = join(home, "home-alias"); + const actual = join(home, "actual-home"); + mkdirSync(actual); + renameSync(root, join(actual, ".aside")); + directoryLink(actual, alias); + const profiles = listAsideProfiles({}, alias); + const selected = profiles[0]!; + assertAsideProfileBoundary(selected, profiles, true); + guardAsideProfileIO(selected, ioFor(home).io, profiles).writeText(selected.configPath, "{}"); + expect(readFileSync(join(actual, ".aside", "u", "0", "models.json"), "utf8")).toBe("{}"); + })); + + for (const component of ["root", "u", "account"] as const) { + test(`rejects a linked ${component} directory`, () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const path = component === "root" ? root : component === "u" ? join(root, "u") : selected.detectDir; + const moved = join(home, `moved-${component}`); + renameSync(path, moved); + directoryLink(moved, path); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + })); + } + + test("rejects leaf links, including dangling links", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const target = join(root, "u", "1", "models.json"); + symlinkSync(target, selected.configPath, "file"); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[1]!, profiles)).toThrow(ClientPathError); + writeFileSync(target, "{}"); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[1]!, profiles)).toThrow(ClientPathError); + })); + + test("detects sibling directory aliases even when the selected path is safe", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + rmSync(profiles[1]!.detectDir, { recursive: true }); + directoryLink(profiles[0]!.detectDir, profiles[1]!.detectDir); + expect(() => assertAsideProfileBoundary(profiles[0]!, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[0]!)).toThrow(ClientPathError); + expect(existsSync(join(root, "u", "0", "models.json"))).toBe(false); + })); + + test("rejects shared leaf inodes independently of ownership stores", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const a = profiles[0]!; + const b = profiles[1]!; + writeFileSync(a.configPath, "{}"); + linkSync(a.configPath, b.configPath); + expect(() => assertAsideProfileBoundary(a, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(b, [b])).toThrow(ClientPathError); + })); + + test("does not permit caller-supplied or sibling IO paths", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + expect(() => assertAsideProfileBoundary({ ...selected, configPath: profiles[1]!.configPath }, profiles)) + .toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(selected, profiles.slice(1))).toThrow(ClientPathError); + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + expect(() => guarded.writeText(profiles[1]!.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.readText(join(selected.detectDir, "settings.json"))).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.root)).toThrow(ClientPathError); + })); + + for (const component of ["root", "u", "account"] as const) { + test(`rejects a ${component} inode replacement after guard capture`, () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + const path = component === "root" ? root : component === "u" ? join(root, "u") : selected.detectDir; + renameSync(path, join(home, `old-${component}`)); + mkdirSync(selected.detectDir, { recursive: true }); + expect(() => guarded.statKind(selected.detectDir)).toThrow(ClientPathError); + expect(() => guarded.readText(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.detectDir)).toThrow(ClientPathError); + expect(() => guarded.writeText(selected.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(existsSync(selected.configPath)).toBe(false); + })); + } + + test("rechecks leaf collisions immediately before all config mutations", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const sibling = profiles[1]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + writeFileSync(selected.configPath, "original"); + symlinkSync(selected.configPath, sibling.configPath, "file"); + expect(() => guarded.writeText(selected.configPath, "changed")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.detectDir)).toThrow(ClientPathError); + expect(readFileSync(selected.configPath, "utf8")).toBe("original"); + })); + + test("rejects a selected leaf replaced by a link after guard capture", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + writeFileSync(profiles[1]!.configPath, "sibling"); + symlinkSync(profiles[1]!.configPath, selected.configPath, "file"); + expect(() => guarded.readText(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.writeText(selected.configPath, "changed")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(readFileSync(profiles[1]!.configPath, "utf8")).toBe("sibling"); + })); + + test("pins the chosen account across current-account switches and preserves bound IO", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[1]!; + const { store, io } = ioFor(home); + const receiverIO = { + ...io, + now() { expect(this).toBe(receiverIO); return 123; }, + writeText(path: string, text: string) { expect(this).toBe(receiverIO); io.writeText(path, text); }, + }; + const guarded = guardAsideProfileIO(selected, receiverIO, profiles); + manifest(root, { currentAccountId: 2, accounts }); + expect(listAsideProfiles({}, home)[2]!.current).toBe(true); + guarded.mkdirp(selected.detectDir); + guarded.writeText(selected.configPath, "first"); + guarded.writeText(selected.configPath, "second"); + expect(guarded.now()).toBe(123); + expect(readFileSync(selected.configPath, "utf8")).toBe("second"); + expect(existsSync(profiles[2]!.configPath)).toBe(false); + guarded.appendJournal({ + opId: "fixture-op", clientId: "aside", kind: "apply", at: new Date(123).toISOString(), + configPath: selected.configPath, snapshot: { kind: "none" }, resultFingerprint: "fixture-hash", + resultAbsent: false, priorRecord: null, + }); + expect(store.listOperations()).toHaveLength(1); + guarded.putRecord({ + clientId: "aside", configPath: selected.configPath, fileFingerprint: "fixture-file", + blockFingerprint: "fixture-block", fragmentPaths: [], appliedAt: new Date(123).toISOString(), opId: "fixture-op", + }); + expect(store.readRecords().aside?.configPath).toBe(selected.configPath); + guarded.dropRecord("aside"); + expect(store.readRecords().aside).toBeUndefined(); + guarded.removeFile(selected.configPath); + expect(existsSync(selected.configPath)).toBe(false); + })); +}); diff --git a/tests/clients/aside-profile-sync-owner.test.ts b/tests/clients/aside-profile-sync-owner.test.ts new file mode 100644 index 0000000000..093f25dbdf --- /dev/null +++ b/tests/clients/aside-profile-sync-owner.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; +import type { OwnedIntegrationRefreshOutcome } from "../../src/integrations/owned-refresh"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; + +const SYNC_PATH = "/api/client-integrations/aside/sync"; +const CHILD_BUDGET_MS = 20_000; +let root: string; +let home: string; +let configHome: string; +let config: OcxConfig; +let isolation: IsolatedCodexHome; +let priorConfigHome: string | undefined; +let server: ReturnType | undefined; +let baseUrl: string; +let mode: "live" | "missing-route" | "old-response"; +let writes: number[]; +let syncRequests: Array<{ method: string; body: string }>; +const children: Array> = []; + +function bounded(promise: Promise, label: string, ms = CHILD_BUDGET_MS): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +interface ChildMessage { + phase: "ready" | "refreshed" | "refused"; + pid: number; + staleEnabled: boolean; + results?: OwnedIntegrationRefreshOutcome[]; + name?: string; + status?: number; +} + +/** An actual CLI module in a second process, with its own already-loaded config. */ +function startCli() { + const helperUrl = pathToFileURL(repoPath("src", "cli", "aside-profiles.ts")).href; + const configUrl = pathToFileURL(repoPath("src", "config.ts")).href; + const source = ` + import { once } from "node:events"; + import { createInterface } from "node:readline"; + import { loadConfig } from ${JSON.stringify(configUrl)}; + import { refreshAsideProfilesThroughServer } from ${JSON.stringify(helperUrl)}; + const deadline = setTimeout(() => process.exit(124), ${CHILD_BUDGET_MS}); + const stale = loadConfig(); + const enabled = () => stale.asideProfileSync?.profiles?.["1"] ?? stale.asideProfileSync?.allProfiles ?? false; + const emit = value => console.log("ASIDE_SYNC_MESSAGE " + JSON.stringify({ pid: process.pid, staleEnabled: enabled(), ...value })); + const lines = createInterface({ input: process.stdin }); + const gate = once(lines, "line"); + emit({ phase: "ready" }); + const [release] = await gate; + lines.close(); + process.stdin.pause(); + if (release !== "refresh") throw new Error("unexpected parent gate message"); + try { + const results = await refreshAsideProfilesThroughServer({ baseUrl: process.env.ASIDE_SYNC_FIXTURE_URL }); + emit({ phase: "refreshed", results }); + } catch (error) { + emit({ phase: "refused", name: error.name, status: error.status }); + } finally { clearTimeout(deadline); } + `; + const child = spawn(process.execPath, ["--eval", source], { + cwd: repoRoot(), stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, HOME: home, USERPROFILE: home, OPENCODEX_HOME: configHome, + CODEX_HOME: isolation.path, XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_ADMIN_AUTH_TOKEN: "", ASIDE_SYNC_FIXTURE_URL: baseUrl, + }, + }); + let stderr = ""; + child.stderr.on("data", chunk => { stderr = (stderr + String(chunk)).slice(-16_384); }); + child.on("error", error => { stderr += error.message; }); + const exited = new Promise(resolve => child.once("close", resolve)); + const lines = createInterface({ input: child.stdout }); + const iterator = lines[Symbol.asyncIterator](); + const cli = { + async next(): Promise { + return bounded((async () => { + for (;;) { + const line = await iterator.next(); + if (line.done) throw new Error(`CLI exited before its next gate message: ${stderr}`); + if (line.value.startsWith("ASIDE_SYNC_MESSAGE ")) { + return JSON.parse(line.value.slice("ASIDE_SYNC_MESSAGE ".length)) as ChildMessage; + } + } + })(), "CLI gate"); + }, + release() { child.stdin.end("refresh\n"); }, + async finish() { + const code = await bounded(exited, "CLI exit"); + if (code !== 0) throw new Error(`CLI exited with ${code}: ${stderr}`); + }, + async dispose() { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + try { await bounded(exited, "CLI cleanup", 5_000); } finally { lines.close(); } + }, + }; + children.push(cli); + return cli; +} + +function profilePath(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +function profileFiles() { + return [0, 1, 2].map(id => { + const path = profilePath(id); + const stat = lstatSync(path, { bigint: true }); + return { text: readFileSync(path, "utf8"), ino: stat.ino.toString(), mtime: stat.mtimeNs.toString() }; + }); +} +function catalog(id: number): string[] { + const doc = JSON.parse(readFileSync(profilePath(id), "utf8")); + return (doc.providers?.opencodex?.models ?? []).map((model: { id: string }) => model.id) + .filter((id: string) => id.startsWith("fixture/")); +} +function persist(value: OcxConfig = config): void { + writeFileSync(join(configHome, "config.json"), JSON.stringify(value)); +} +async function api(path: string, method = "GET", body?: unknown): Promise { + return fetch(`${baseUrl}${path}`, { + method, headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(5_000), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-sync-owner-")); + home = join(root, "home"); + configHome = join(root, "opencodex"); + mkdirSync(configHome, { recursive: true }); + priorConfigHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configHome; + isolation = installIsolatedCodexHome("ocx-aside-sync-owner-codex-"); + for (const id of [0, 1, 2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(profilePath(id), JSON.stringify({ theme: "keep", providers: {} })); + } + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Cloud" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }], + })); + config = { + port: 10100, hostname: "127.0.0.1", defaultProvider: "fixture", fastRows: false, + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one"] } }, + } as OcxConfig; + // Match the child's default ownership-store location: a local fallback must + // encounter real owned targets, rather than vacuously skip an empty store. + const store = createIntegrationStateStore(join(configHome, "integrations")); + const io = store.io(); + writes = []; + syncRequests = []; + mode = "live"; + setIntegrationPathTestHooks({ home, env: {} }); + setIntegrationMutationFlightTestHooks({ store, io: { + ...io, writeText(path, text) { + const id = [0, 1, 2].find(candidate => profilePath(candidate) === path); + if (id !== undefined) writes.push(id); + io.writeText(path, text); + }, + } }); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + const url = new URL(req.url); + if (url.pathname === SYNC_PATH) { + syncRequests.push({ method: req.method, body: await req.clone().text() }); + if (mode === "missing-route") return Response.json({ error: "endpoint not found" }, { status: 404 }); + if (mode === "old-response") return Response.json({ ok: true }); + } + return await handleManagementAPI(req, url, config, { + saveConfigPreservingClaudeCode: persist, createManagementConvergeCodex: catalogConvergenceFactory(), + }) ?? new Response("Not found", { status: 404 }); + } }); + config.port = server.port!; + baseUrl = `http://127.0.0.1:${server.port}`; + persist(); +}); + +afterEach(async () => { + try { await Promise.all(children.splice(0).map(child => child.dispose())); } + finally { + await server?.stop(true); + server = undefined; + setIntegrationMutationFlightTestHooks(null); + setIntegrationPathTestHooks(null); + isolation.restore(); + if (priorConfigHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorConfigHome; + removeTreeWithRetry(root); + } +}); + +async function enableAll(): Promise { + const response = await api("/api/client-integrations/aside/profiles", "PUT", { enabled: true }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true }); + for (const id of [0, 1, 2]) expect(catalog(id)).toEqual(["fixture/one"]); +} + +test("a stale CLI process refreshes only the profiles still enabled by the live server", async () => { + await enableAll(); + const cli = startCli(); + const ready = await cli.next(); + expect(ready).toMatchObject({ phase: "ready", staleEnabled: true }); + expect(ready.pid).not.toBe(process.pid); + const disabled = await api("/api/client-integrations/aside/profiles/1", "PUT", { enabled: false }); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toMatchObject({ ok: true }); + expect(JSON.parse(readFileSync(join(configHome, "config.json"), "utf8")).asideProfileSync.profiles["1"]).toBe(false); + expect(catalog(1)).toEqual([]); + const disabledFile = profileFiles()[1]; + // Change only the server's catalog fixture: no selection endpoint may refresh + // it before the released child reaches the production sync owner. + config.providers.fixture!.models = ["two"]; + persist(); + writes.length = 0; + cli.release(); + const result = await cli.next(); + await cli.finish(); + expect(result).toMatchObject({ phase: "refreshed", pid: ready.pid, staleEnabled: true }); + expect(result.results).toEqual([ + { client: "aside", profileId: 0, ok: true, changed: true }, + { client: "aside", profileId: 2, ok: true, changed: true }, + ]); + expect(syncRequests).toEqual([{ method: "POST", body: "{}" }]); + expect(writes).toEqual([0, 2]); + for (const id of [0, 2]) expect(catalog(id)).toEqual(["fixture/two"]); + expect(profileFiles()[1]).toEqual(disabledFile); + expect(await (await api("/api/client-integrations/aside/profiles/1")).json()) + .toMatchObject({ profileId: 1, enabled: false, state: "absent" }); +}, 45_000); + +test.each(["missing-route", "old-response", "offline"] as const)( + "CLI refuses %s without falling back to local profile writes", async failure => { + await enableAll(); + const before = profileFiles(); + config.providers.fixture!.models = ["two"]; + persist(); + const configBefore = readFileSync(join(configHome, "config.json"), "utf8"); + if (failure === "offline") { await server!.stop(true); server = undefined; } + else mode = failure; + writes.length = 0; + const cli = startCli(); + expect(await cli.next()).toMatchObject({ phase: "ready", staleEnabled: true }); + cli.release(); + expect(await cli.next()).toMatchObject({ + phase: "refused", name: "RuntimeApiError", status: failure === "offline" ? 503 : failure === "missing-route" ? 404 : 502, + }); + await cli.finish(); + expect(profileFiles()).toEqual(before); + expect(readFileSync(join(configHome, "config.json"), "utf8")).toBe(configBefore); + expect(writes).toEqual([]); + expect(syncRequests).toEqual(failure === "offline" ? [] : [{ method: "POST", body: "{}" }]); + }, 45_000, +); diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts new file mode 100644 index 0000000000..800ef329f2 --- /dev/null +++ b/tests/clients/aside-profiles.test.ts @@ -0,0 +1,447 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { AsideProfileError, type AsideProfilesInput } from "../../src/integrations/aside-profile-context"; +import { getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles } from "../../src/integrations/aside-profiles"; +import { asideOperationMatchesCurrent, deleteAsideOperation, findAsideOperation, listAsideOperations, restoreAsideProfile } from "../../src/integrations/aside-profile-journal"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("Aside profile desired state, ownership and history", () => { + const models: ExportModel[] = [ + { namespaced: "mock/alpha", provider: "mock", id: "alpha", contextWindow: 128_000 }, + { namespaced: "mock/beta", provider: "mock", id: "beta", contextWindow: 64_000 }, + ]; + const original = JSON.stringify({ theme: "dark", providers: { personal: { models: [{ id: "mine" }] } } }); + let root: string; + let home: string; + let store: IntegrationStateStore; + let config: OcxConfig; + let saved: OcxConfig | undefined; + let saves: number; + + function manifest(currentAccountId = 0, ids = [0, 1, 2]): void { + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId, accounts: ids.map(id => ({ id, name: `Profile ${id}` })), + })); + } + function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } + function input(extra: Partial = {}): AsideProfilesInput { + return { config, models, port: 10100, env: {}, home, store, + persistConfig: next => { saved = structuredClone(next); saves += 1; }, ...extra }; + } + function reload(): void { expect(saved).toBeDefined(); config = structuredClone(saved!); } + function seedLegacy(id = 0): string { + manifest(id); + const result = applyIntegration({ ...input(), models, clientId: "aside" }); + expect(result.ok).toBe(true); + manifest(); + return store.readRecords().aside!.opId; + } + function modelIds(id: number): string[] { + const doc = JSON.parse(readFileSync(path(id), "utf8")) as { providers?: { opencodex?: { models: Array<{ id: string }> } } }; + return doc.providers?.opencodex?.models.map(model => model.id) ?? []; + } + function bytes(): string[] { return [0, 1, 2].map(id => readFileSync(path(id), "utf8")); } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-profiles-")); + home = join(root, "home"); + for (const id of [0, 1, 2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(path(id), original); + } + manifest(); + store = createIntegrationStateStore(join(root, "state", "integrations")); + config = { port: 10100, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } } } as OcxConfig; + saved = undefined; + saves = 0; + }); + afterEach(() => removeTreeWithRetry(root)); + + test.each([false, true])("implicit sync stays quiet for unconfigured or disabled Aside (legacy=%s)", async legacy => { + if (legacy) { + seedLegacy(); + config.asideProfileSync = { allProfiles: false }; + } + removeTreeWithRetry(join(home, ".aside")); + let loads = 0; + expect(await refreshAsideProfiles(input({ models: async () => { loads += 1; return models; } }))).toEqual([]); + expect(loads).toBe(0); + expect(saves).toBe(0); + }); + + test("sync retains backup and incomplete-recovery diagnostics for a failed profile", async () => { + seedLegacy(); + const io = store.io(); + let attempts = 0; + const outcomes = await refreshAsideProfiles(input({ models: models.slice(0, 1), + store: { ...store, putRecord() { throw new Error("synthetic ownership failure"); } }, io: { + ...io, + writeText(target, text) { + if (target !== path(0)) return io.writeText(target, text); + attempts += 1; + if (attempts === 1) return io.writeText(target, text); + throw new Error("synthetic write and compensation failure"); + }, + } })); + const failure = outcomes.find(row => row.profileId === 0); + expect(failure).toMatchObject({ ok: false, refusalReason: "write_failed", residual: true }); + expect(failure?.snapshotPath).toBeString(); + expect(existsSync(failure!.snapshotPath!)).toBe(true); + }); + + test("legacy connection defaults all profiles on and refresh shares one catalog load", async () => { + seedLegacy(); + expect((await listAsideProfileStates(input())).enabledCount).toBe(3); + let loads = 0; + const results = await refreshAsideProfiles(input({ models: async () => { loads += 1; return models.slice(0, 1); } })); + expect(results.map(result => [result.profileId, result.ok])).toEqual([[0, true], [1, true], [2, true]]); + expect(loads).toBe(1); + for (const id of [0, 1, 2]) expect(modelIds(id)).toEqual(["mock/alpha"]); + expect(store.readRecords().aside?.configPath).toBe(path(0)); + for (const id of [1, 2]) { + expect(createIntegrationStateStore(join(store.root, "aside-profiles", String(id))).readRecords().aside?.configPath).toBe(path(id)); + expect(JSON.parse(readFileSync(path(id), "utf8"))).toMatchObject(JSON.parse(original)); + } + expect(saves).toBe(0); + }); + + test("disabling legacy profile 0 pins its root and preserves sibling intent after reload", async () => { + seedLegacy(); + expect((await mutateAsideProfiles(input(), { profileId: 0, enabled: false })).ok).toBe(true); + expect(saved?.asideProfileSync).toEqual({ allProfiles: true, profiles: { "0": false }, legacyProfileId: 0 }); + reload(); + const results = await refreshAsideProfiles(input()); + expect(results.map(row => row.profileId)).toEqual([1, 2]); + expect(modelIds(0)).toEqual([]); + expect(modelIds(1)).toEqual(["mock/alpha", "mock/beta"]); + expect(modelIds(2)).toEqual(["mock/alpha", "mock/beta"]); + expect(store.readRecords().aside).toBeUndefined(); + expect((await getAsideProfileState(input(), 0)).enabled).toBe(false); + }); + + test("a disconnected single-profile enable leaves other profiles off", async () => { + expect((await mutateAsideProfiles(input(), { profileId: 1, enabled: true })).ok).toBe(true); + reload(); + expect(config.asideProfileSync).toEqual({ allProfiles: false, profiles: { "1": true }, legacyProfileId: null }); + expect((await refreshAsideProfiles(input())).map(row => row.profileId)).toEqual([1]); + expect(bytes()[0]).toBe(original); + expect(bytes()[2]).toBe(original); + expect((await listAsideProfileStates(input())).enabledCount).toBe(1); + }); + + test("a disconnected implicit refresh loads no catalog and creates no ownership store", async () => { + let loads = 0; + expect(await refreshAsideProfiles(input({ models: async () => { loads += 1; return models; } }))).toEqual([]); + expect(loads).toBe(0); + expect(bytes()).toEqual([original, original, original]); + expect(existsSync(store.root)).toBe(false); + }); + + test("bulk intent clears overrides without conflating desired and actual outcomes", async () => { + await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + await mutateAsideProfiles(input(), { enabled: false }); + expect(saved?.asideProfileSync).toEqual({ allProfiles: false, profiles: {}, legacyProfileId: null }); + expect((await listAsideProfileStates(input())).enabledCount).toBe(0); + expect((await mutateAsideProfiles(input(), { enabled: true })).results).toHaveLength(3); + expect((await listAsideProfileStates(input())).appliedCount).toBe(3); + }); + + test("save failure restores the original in-memory policy before any model or file work", async () => { + seedLegacy(); + const before = bytes(); + const records = store.readRecords(); + const operations = store.listOperations(); + const previous = config.asideProfileSync; + let loads = 0; + await expect(mutateAsideProfiles(input({ + persistConfig: () => { throw new Error("synthetic save failure"); }, + models: async () => { loads += 1; return models; }, + }), { enabled: false })).rejects.toMatchObject({ code: "aside_profile_persist_failed", status: 500 }); + expect(config.asideProfileSync).toBe(previous); + expect(loads).toBe(0); + expect(bytes()).toEqual(before); + expect(store.readRecords()).toEqual(records); + expect(store.listOperations()).toEqual(operations); + expect(existsSync(join(store.root, "aside-profiles"))).toBe(false); + }); + + test("foreign and malformed profiles refuse independently after desired policy is saved", async () => { + const foreign = JSON.stringify({ providers: { opencodex: { models: [{ id: "manual" }] } } }); + writeFileSync(path(1), foreign); + writeFileSync(path(2), "{broken"); + const result = await mutateAsideProfiles(input(), { enabled: true }); + expect(result.ok).toBe(false); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, false]]); + expect(saved?.asideProfileSync?.allProfiles).toBe(true); + expect(readFileSync(path(1), "utf8")).toBe(foreign); + expect(readFileSync(path(2), "utf8")).toBe("{broken"); + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + }); + + test("implicit refresh preserves removed owned blocks and foreign edits", async () => { + await mutateAsideProfiles(input(), { enabled: true }); + writeFileSync(path(1), original); + const drifted = readFileSync(path(2), "utf8").replace("http://127.0.0.1:10100/v1", "http://user.invalid/v1"); + writeFileSync(path(2), drifted); + const result = await refreshAsideProfiles(input({ models: models.slice(0, 1) })); + expect(result[0]).toMatchObject({ profileId: 0, ok: true, changed: true }); + expect(result[1]).toMatchObject({ profileId: 1, ok: true, changed: false }); + expect(result[2]).toMatchObject({ profileId: 2, ok: false }); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(readFileSync(path(2), "utf8")).toBe(drifted); + }); + + test("one profile IO failure does not suppress later writes", async () => { + const io = store.io(); + const result = await mutateAsideProfiles(input({ io: { ...io, writeText: (target, text) => { + if (target === path(1)) throw new Error("synthetic profile write failure"); + io.writeText(target, text); + } } }), { enabled: true }); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, true]]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(modelIds(2)).toEqual(["mock/alpha", "mock/beta"]); + expect(saved?.asideProfileSync?.allProfiles).toBe(true); + }); + + test("missing account directories and aliased child stores are not created or adopted", async () => { + removeTreeWithRetry(join(home, ".aside", "u", "2")); + const children = join(store.root, "aside-profiles"); + mkdirSync(children, { recursive: true }); + const external = join(root, "external-store"); + mkdirSync(external); + symlinkSync(external, join(children, "1"), process.platform === "win32" ? "junction" : "dir"); + const result = await mutateAsideProfiles(input(), { enabled: true }); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, false]]); + expect(existsSync(join(external, "records.json"))).toBe(false); + expect(existsSync(join(home, ".aside", "u", "2"))).toBe(false); + }); + + test("an account switch during persistence does not retarget a selected write", async () => { + const result = await mutateAsideProfiles(input({ persistConfig: next => { + saved = structuredClone(next); manifest(2); + } }), { profileId: 1, enabled: true }); + expect(result.ok).toBe(true); + expect(modelIds(1)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(0), "utf8")).toBe(original); + expect(readFileSync(path(2), "utf8")).toBe(original); + }); + + test("enable then Undo stays off through reload and sync", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const enabledResult = enabled.results[0]!; + if (!enabledResult.ok) throw new Error("fixture enable failed"); + const opId = enabledResult.opId!; + const row = findAsideOperation(input(), opId, 1)!; + expect(asideOperationMatchesCurrent(input(), row)).toBe(true); + expect((await restoreAsideProfile(input(), { opId, profileId: 1 })).ok).toBe(true); + reload(); + expect(await refreshAsideProfiles(input())).toEqual([]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); + }); + + test("disable then Undo restores target intent without changing sibling overrides", async () => { + seedLegacy(); + await mutateAsideProfiles(input(), { profileId: 2, enabled: false }); + const disabled = await mutateAsideProfiles(input(), { profileId: 0, enabled: false }); + const result = disabled.results[0]!; + expect(result.ok).toBe(true); + if (!result.ok) return; + expect((await restoreAsideProfile(input(), { opId: result.opId!, profileId: 0 })).ok).toBe(true); + reload(); + expect(config.asideProfileSync).toEqual({ allProfiles: true, legacyProfileId: 0, profiles: { "0": true, "2": false } }); + expect((await refreshAsideProfiles(input())).map(row => row.profileId)).toEqual([0, 1]); + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(2), "utf8")).toBe(original); + }); + + test("Undo of explicit overwrite restores a foreign block and leaves its profile off", async () => { + const foreign = JSON.stringify({ providers: { opencodex: { models: [{ id: "user-owned" }] } } }); + writeFileSync(path(1), foreign); + const overwritten = await mutateAsideProfiles(input(), { profileId: 1, enabled: true, overwriteConflict: true }); + const result = overwritten.results[0]!; + if (!result.ok) throw new Error("fixture overwrite failed"); + expect((await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1 })).ok).toBe(true); + reload(); + expect(await refreshAsideProfiles(input())).toEqual([]); + expect(readFileSync(path(1), "utf8")).toBe(foreign); + expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); + }); + + test("restore preflight refuses drift and expired snapshots before saving intent", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const result = enabled.results[0]!; + if (!result.ok) throw new Error("fixture enable failed"); + const row = findAsideOperation(input(), result.opId!, 1)!; + writeFileSync(path(1), original); + const beforeSaves = saves; + expect(asideOperationMatchesCurrent(input(), row)).toBe(false); + expect(await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1 })) + .toMatchObject({ ok: false, reason: "drift_requires_confirm" }); + expect(saves).toBe(beforeSaves); + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + removeTreeWithRetry(snapshot.path); + expect(await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1, confirmDrift: true })) + .toMatchObject({ ok: false, reason: "snapshot_expired" }); + expect(saves).toBe(beforeSaves); + }); + + test("mixed legacy history imports a sibling snapshot without clobbering the root owner", async () => { + const siblingOp = seedLegacy(1); + const ownerOp = seedLegacy(0); + const owner = store.readRecords().aside; + expect(listAsideOperations(input(), 1).map(row => row.entry.opId)).toEqual([siblingOp]); + expect(listAsideOperations(input(), 0).map(row => row.entry.opId)).toEqual([ownerOp]); + expect((await restoreAsideProfile(input(), { opId: siblingOp })).ok).toBe(true); + expect(store.readRecords().aside).toEqual(owner); + expect(store.findOperation(siblingOp)).not.toBeNull(); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + expect(child.findOperation(siblingOp)).toEqual(store.findOperation(siblingOp)); + expect(listAsideOperations(input(), 1).filter(row => row.entry.opId === siblingOp)).toHaveLength(1); + const latest = listAsideOperations(input(), 1)[0]!; + await expect(deleteAsideOperation(input(), { opId: latest.entry.opId, profileId: 1 })) + .rejects.toMatchObject({ code: "integration_journal_newest_protected", status: 409 }); + expect((await deleteAsideOperation(input(), { opId: siblingOp, profileId: 1 })).ok).toBe(true); + expect(child.findOperation(siblingOp)).toBeNull(); + expect(store.findOperation(siblingOp)).toBeNull(); + expect(store.readRecords().aside).toEqual(owner); + }); + + test.each(["child", "legacy"] as const)("history restores from the remaining copy when %s retention expires", async expired => { + const opId = seedLegacy(1); + seedLegacy(0); + const rootOwner = store.readRecords().aside; + const applied = readFileSync(path(1), "utf8"); + expect((await restoreAsideProfile(input(), { opId })).ok).toBe(true); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = store.findOperation(opId)!; + expect(child.findOperation(opId)).toEqual(entry); + const expiredStore = expired === "child" ? child : store; + const remainingStore = expired === "child" ? store : child; + const snapshot = expiredStore.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + removeTreeWithRetry(snapshot.path); + expect(expiredStore.readSnapshot(entry).kind).toBe("expired"); + expect(remainingStore.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + const selected = findAsideOperation(input(), opId, 1)!; + expect(selected.store.root).toBe(remainingStore.root); + expect(listAsideOperations(input(), 1).filter(row => row.entry.opId === opId)).toHaveLength(1); + // Recreate the operation's result so ordinary Undo needs no drift override. + writeFileSync(path(1), applied); + expect(asideOperationMatchesCurrent(input(), selected)).toBe(true); + expect((await restoreAsideProfile(input(), { opId, profileId: 1 })).ok).toBe(true); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(child.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + expect(child.listOperations("aside").filter(row => row.opId === opId)).toHaveLength(1); + expect(store.listOperations("aside").filter(row => row.opId === opId)).toHaveLength(1); + expect(store.readRecords().aside).toEqual(rootOwner); + if (expired === "legacy") expect(store.readSnapshot(entry).kind).toBe("expired"); + }); + + test("conflicting available snapshot copies refuse lookup and restore before saving or writing", async () => { + const opId = seedLegacy(1); + seedLegacy(0); + expect((await restoreAsideProfile(input(), { opId })).ok).toBe(true); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = store.findOperation(opId)!; + const snapshot = child.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + writeFileSync(snapshot.path, JSON.stringify({ theme: "conflicting-copy" })); + expect(child.findOperation(opId)).toEqual(entry); + const before = bytes(); + const beforeSaves = saves; + const beforeHistory = child.listOperations("aside"); + expect(() => findAsideOperation(input(), opId, 1)).toThrow("conflicting snapshot copies"); + expect(() => listAsideOperations(input(), 1)).toThrow("conflicting snapshot copies"); + await expect(restoreAsideProfile(input(), { opId, profileId: 1, confirmDrift: true })) + .rejects.toMatchObject({ code: "aside_operation_ambiguous", status: 409 }); + expect(saves).toBe(beforeSaves); + expect(bytes()).toEqual(before); + expect(child.listOperations("aside")).toEqual(beforeHistory); + expect(store.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + }); + + test("unknown profile selectors and unregistered historical targets are not retargeted", async () => { + await expect(getAsideProfileState(input(), 9)).rejects.toMatchObject({ code: "aside_profile_not_found", status: 404 }); + await expect(mutateAsideProfiles(input(), { profileId: -1, enabled: true })).rejects.toBeInstanceOf(AsideProfileError); + const opId = seedLegacy(1); + manifest(0, [0, 2]); + expect(() => findAsideOperation(input(), opId)).toThrow("no longer registered"); + expect(saves).toBe(0); + }); + + test("default status is safe when discovery fails and non-Aside history stays available", async () => { + const opId = seedLegacy(); + const aside = store.findOperation(opId)!; + store.appendJournal({ ...aside, opId: "mcode-history", clientId: "mcode" }); + writeFileSync(join(home, ".aside", "accounts.json"), "{invalid"); + const state = await listAsideProfileStates(input()); + expect(state).toMatchObject({ clientId: "aside", profiles: [], installed: false, state: "unsafe", total: 0 }); + expect(state.error).toBeDefined(); + expect(findAsideOperation(input(), "mcode-history")).toBeNull(); + expect(listAsideOperations(input())).toEqual([]); + await expect(mutateAsideProfiles(input(), { profileId: 0, enabled: true })) + .rejects.toMatchObject({ code: "aside_profiles_unavailable", status: 409 }); + expect(saves).toBe(0); + }); + + test("restore save failure leaves history, bytes and existing policy unchanged", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const result = enabled.results[0]!; + if (!result.ok) throw new Error("fixture enable failed"); + const previous = config.asideProfileSync; + const before = bytes(); + const rows = listAsideOperations(input(), 1).map(row => row.entry); + await expect(restoreAsideProfile(input({ persistConfig: async () => { throw new Error("save unavailable"); } }), { opId: result.opId! })) + .rejects.toMatchObject({ code: "aside_profile_persist_failed" }); + expect(config.asideProfileSync).toBe(previous); + expect(bytes()).toEqual(before); + expect(listAsideOperations(input(), 1).map(row => row.entry)).toEqual(rows); + }); + + test("Undo never enables policy from a snapshot whose prior owner names another profile", async () => { + seedLegacy(); + const disabled = await mutateAsideProfiles(input(), { profileId: 0, enabled: false }); + const result = disabled.results[0]!; + if (!result.ok) throw new Error("fixture disable failed"); + const entry = store.findOperation(result.opId!)!; + store.appendJournal({ ...entry, opId: "wrong-owner", snapshot: { kind: "none" }, + priorRecord: { ...entry.priorRecord!, configPath: path(1) } }); + const before = bytes(); + const beforeSaves = saves; + await expect(restoreAsideProfile(input(), { opId: "wrong-owner", profileId: 0 })) + .rejects.toMatchObject({ code: "aside_operation_invalid", status: 409 }); + expect(saves).toBe(beforeSaves); + expect(bytes()).toEqual(before); + }); + + test("one flight covers save and writes across different profile scopes", async () => { + let release!: () => void; + let observe!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { observe = resolve; }); + const first = mutateAsideProfiles(input({ persistConfig: async next => { + observe(); await gate; saved = structuredClone(next); + } }), { profileId: 0, enabled: true }); + try { + await started; + await expect(mutateAsideProfiles(input(), { profileId: 1, enabled: true })) + .rejects.toMatchObject({ code: "integration_mutation_busy", status: 409 }); + await expect(refreshAsideProfiles(input())).rejects.toMatchObject({ code: "integration_mutation_busy" }); + await expect(refreshAsideProfiles(input({ store: createIntegrationStateStore(join(root, "other-state")) }))) + .rejects.toMatchObject({ code: "integration_mutation_busy" }); + expect(bytes()).toEqual([original, original, original]); + } finally { release(); await first; } + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect((await mutateAsideProfiles(input(), { profileId: 1, enabled: true })).ok).toBe(true); + }); +}); diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 05dff4a708..66bf5d4bf9 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -1,10 +1,9 @@ import { describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; import { downloadClientCatalog, exchangeConnectPairingGrant, @@ -14,8 +13,96 @@ import { } from "../../src/client/hub-client"; import { handleConnectCommand } from "../../src/cli/connect"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot as findRepoRoot } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; -const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); +const repoRoot = findRepoRoot(); + +const CLIENT_FIXTURE_FAILURE_CATEGORIES = ["module_load", "config_setup", "desktop_setup", "scenario", "child_failed"] as const; +type ClientFixtureFailureCategory = typeof CLIENT_FIXTURE_FAILURE_CATEGORIES[number]; + +class ClientStateProbeError extends Error { + constructor( + readonly pid: number, + readonly status: number | null, + readonly signal: NodeJS.Signals | null, + readonly timedOut: boolean, + readonly failureCategory?: ClientFixtureFailureCategory, + ) { + // Do not include the child script, environment, stdout or stderr in failure output. + super(`Client state probe ${timedOut ? "timed out" : "failed"} (status=${status}, signal=${signal}${failureCategory ? `, category=${failureCategory}` : ""})`); + this.name = "ClientStateProbeError"; + } +} + +async function readStateProbe(script: string, home: string, timeoutMs = INTERNAL_DEADLINE_MS) { + const maxCaptureBytes = 1024 * 1024; + const cleanupMs = 1_000; + const result = await new Promise<{ stdout: string; pid: number; status: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + let child: ReturnType; + try { + child = spawn(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop") }, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { reject(new ClientStateProbeError(0, null, null, false)); return; } + const chunks: Buffer[] = []; + let bytes = 0; + let failed = false; + let timedOut = false; + let settled = false; + let status: number | null = null; + let signal: NodeJS.Signals | null = null; + let deadline: ReturnType | undefined; + let cleanup: ReturnType | undefined; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(deadline); + clearTimeout(cleanup); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + const pid = child.pid ?? 0; + if (failed || status !== 0 || signal !== null) reject(new ClientStateProbeError(pid, status, signal, timedOut)); + else resolve({ stdout: Buffer.concat(chunks).toString("utf8"), pid, status, signal }); + }; + const boundCleanup = () => { + if (settled) return; + cleanup ??= setTimeout(() => { failed = true; finish(); }, cleanupMs); + }; + const stop = () => { + if (settled || failed) return; + failed = true; + clearTimeout(deadline); + boundCleanup(); + try { child.kill("SIGKILL"); } catch { /* Preserve observed exit metadata, never the OS error text. */ } + }; + const capture = (chunk: Buffer, stdout: boolean) => { + if (settled || failed) return; + bytes += chunk.length; + if (bytes > maxCaptureBytes) { stop(); return; } + if (stdout) chunks.push(chunk); + }; + child.stdout?.on("data", chunk => capture(chunk, true)); + child.stderr?.on("data", chunk => capture(chunk, false)); + child.stdout?.on("error", stop); + child.stderr?.on("error", stop); + child.on("error", stop); + child.once("exit", (code, exitSignal) => { + status = code; signal = exitSignal; + // A descendant retaining a pipe must not turn successful exit into an unbounded wait. + boundCleanup(); + }); + child.once("close", (code, exitSignal) => { status = code; signal = exitSignal; finish(); }); + deadline = setTimeout(() => { timedOut = true; stop(); }, timeoutMs); + }); + try { return JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } + catch { + throw new ClientStateProbeError(result.pid, result.status, result.signal, false); + } +} function readyBody(protocol = 1, minimumClientProtocol = 1) { return { @@ -32,7 +119,7 @@ function readyBody(protocol = 1, minimumClientProtocol = 1) { } describe("remote hub client boundary", () => { - test("runtimeRole=hub without client state reads as disconnected so the hub can start", () => { + test("runtimeRole=hub without client state reads as disconnected so the hub can start", async () => { // First clisu-oracle dogfood boot: the hub role refused 'ocx start' because the // client-state reader classified role=hub (no client block) as mismatched. A hub // is a server; without client state it is simply not a connected client. @@ -41,21 +128,49 @@ describe("remote hub client boundary", () => { console.log(JSON.stringify(readClientConnectionState())); `; const home = mkdtempSync(join(tmpdir(), "ocx-hub-role-")); - const readState = () => { - const child = spawnSync(process.execPath, ["--eval", readScript], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: home }, - encoding: "utf8", - }); - return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); - }; - writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub" })); - expect(readState().kind).toBe("disconnected"); - // Hub role WITH a client block stays mismatched (the honest conflict). - writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub", client: { serverUrl: "https://hub.example.test" } })); - expect(readState().kind).toBe("mismatched"); - removeTreeWithRetry(home); - }); + try { + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub" })); + expect((await readStateProbe(readScript, home)).kind).toBe("disconnected"); + // Hub role WITH a client block stays mismatched (the honest conflict). + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub", client: { serverUrl: "https://hub.example.test" } })); + expect((await readStateProbe(readScript, home)).kind).toBe("mismatched"); + } finally { + removeTreeWithRetry(home); + } + }, 35_000); // Two 15s child deadlines plus bounded 1s cleanup each, below the CI 60s cap. + + test("state probe kills a stalled child before parsing its output", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-state-probe-stall-")); + const startedPath = join(home, "probe-started"); + const script = ` + const fs = require("node:fs"); + fs.writeFileSync(require("node:path").join(process.env.OPENCODEX_HOME, "probe-started"), String(process.pid)); + fs.writeSync(1, "not-json"); + setInterval(() => {}, 1000); + `; + try { + const startedAt = performance.now(); + let failure: unknown; + try { await readStateProbe(script, home, 2_000); } + catch (error) { failure = error; } + expect(performance.now() - startedAt).toBeLessThan(10_000); + expect(failure).toBeInstanceOf(ClientStateProbeError); + if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected bounded child failure"); + expect(failure.timedOut).toBe(true); + expect(failure.status).toBeNull(); + expect(failure.signal).toBe("SIGKILL"); + expect(failure.message).not.toContain("not-json"); + expect(Number(readFileSync(startedPath, "utf8"))).toBe(failure.pid); + // The async probe must reap this exact child, not merely return while it remains alive. + let exitCode: string | undefined; + try { process.kill(failure.pid, 0); } + catch (error) { exitCode = (error as NodeJS.ErrnoException).code; } + expect(exitCode).toBe("ESRCH"); + } finally { + removeTreeWithRetry(home); + } + }, 10_000); + test("canonicalizes origin and terminal /v1 only", () => { expect(normalizeHubOrigin("https://hub.example.test/v1")).toBe("https://hub.example.test"); expect(normalizeHubOrigin("https://hub.example.test/v1/")).toBe("https://hub.example.test"); @@ -201,7 +316,7 @@ describe("remote hub client boundary", () => { /** A catalog the user already had before ever connecting. */ const PRIOR_CATALOG_BYTES = '{"models":[{"slug":"local/only-model"}]}'; -function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog") { +function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog" | "coordinator") { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-codex-")); const configPath = join(opencodexHome, "config.json"); @@ -216,7 +331,7 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co if (stage === "prior-catalog") { writeFileSync(join(codexHome, "opencodex-catalog.json"), PRIOR_CATALOG_BYTES, "utf8"); } - if (stage === "commit") { + if (stage === "coordinator") { const { mkdirSync } = require("node:fs") as typeof import("node:fs"); mkdirSync(join(opencodexHome, "config-mutation.sqlite")); } @@ -228,6 +343,8 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co const { serviceApiTokenFilePath } = require("./src/lib/service-secrets"); const { DEFAULT_CATALOG_PATH } = require("./src/codex/paths"); const stage = ${JSON.stringify(stage)}; + const { setPersistedConfigMutationBeforeCommitForTests } = require("./src/config"); + let commitFaultTriggered = false; const catalog = '{"models":[]}'; const etag = '"sha256-' + createHash("sha256").update(catalog).digest("base64url") + '"'; const calls = []; @@ -259,7 +376,13 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co selectedClients: ["claude"], managementTransport: "direct", noSync: true, - }, { fetchImpl, now: () => new Date("2026-08-28T00:00:00.000Z") }); + }, { fetchImpl, now: () => { + if (stage === "commit") setPersistedConfigMutationBeforeCommitForTests(() => { + commitFaultTriggered = true; + throw new Error("fixture_final_client_commit_failed"); + }); + return new Date("2026-08-28T00:00:00.000Z"); + }, lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } const beforeDisconnect = readClientConnectionState(); const artifacts = { @@ -268,14 +391,14 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co credentialZeroed: credential.every(value => value === 0), }; let disconnected = null; - if ((stage === "success" || stage === "prior-catalog") && connected) disconnected = await disconnectClient(); + if ((stage === "success" || stage === "prior-catalog") && connected) disconnected = await disconnectClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); const catalogAfter = existsSync(DEFAULT_CATALOG_PATH) ? readFileSync(DEFAULT_CATALOG_PATH, "utf8") : null; - console.log(JSON.stringify({ connected, error, beforeDisconnect, artifacts, disconnected, catalogAfter, after: readClientConnectionState(), calls })); + console.log(JSON.stringify({ connected, error, beforeDisconnect, artifacts, disconnected, catalogAfter, after: readClientConnectionState(), calls, commitFaultTriggered })); })(); `; const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome }, + env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, encoding: "utf8", }); const output = result.stdout.trim().split("\n").at(-1) ?? "{}"; @@ -293,6 +416,15 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co } describe("connect transaction and offline disconnect", () => { + test("an unavailable config coordinator refuses before issuing any hub key", () => { + const run = runTransactionScenario("coordinator"); + try { + expect(run.status).toBe(0); + expect(run.parsed.connected).toBeNull(); + expect(run.parsed.calls).toEqual([]); + expect(run.parsed.artifacts).toEqual({ token: false, catalog: false, credentialZeroed: true }); + } finally { run.cleanup(); } + }); test("commits key id/state last, zeroes authority, and disconnects with the hub offline", () => { const run = runTransactionScenario("success"); try { @@ -343,6 +475,10 @@ describe("connect transaction and offline disconnect", () => { expect(run.parsed.artifacts.catalog).toBe(false); expect(run.parsed.artifacts.credentialZeroed).toBe(true); expect(run.parsed.calls.some((call: any) => call.method === "DELETE")).toBe(true); + if (stage === "commit") { + expect(run.parsed.commitFaultTriggered).toBe(true); + expect(run.parsed.calls.some((call: any) => call.method === "POST" && call.url.endsWith("/api/keys"))).toBe(true); + } expect(run.configBytes).not.toContain("issued-id"); expect(`${run.parsed.error} ${run.stderr}`).not.toContain(`ocx_data_${"d".repeat(40)}`); } finally { run.cleanup(); } @@ -417,8 +553,9 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c let result = null; let error = null; try { - if (mode === "disconnect-conflict" || mode === "disconnect-process-journal") result = await disconnectClient(); + if (mode === "disconnect-conflict" || mode === "disconnect-process-journal") result = await disconnectClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); else result = await syncConnectedClient({}, { + lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }, fetchImpl: async () => Response.json({ error: "fixture" }, { status: mode === "sync-401" ? 401 : 503 }), }); } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } @@ -433,7 +570,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c `; const child = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome }, + env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, encoding: "utf8", }); const parsed = JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}") as Record; @@ -556,7 +693,7 @@ describe("recoverable connected key rotation", () => { }; (async () => { const credential = new TextEncoder().encode("ocx_admin_rotation_test"); - const result = await rotateConnectedClientKey({ credential: { kind: "admin", value: credential } }, { fetchImpl }); + const result = await rotateConnectedClientKey({ credential: { kind: "admin", value: credential } }, { fetchImpl, lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); console.log(JSON.stringify({ result, state: readClientConnectionState(), @@ -569,7 +706,7 @@ describe("recoverable connected key rotation", () => { `; const child = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome }, + env: { ...process.env, OPENCODEX_HOME: opencodexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, encoding: "utf8", }); try { @@ -602,11 +739,13 @@ describe("recoverable connected key rotation", () => { writeFileSync(join(home, "service-api-token"), `${token}\n`, { mode: 0o600 }); writeFileSync(join(home, "service-api-token.prev"), `${token}\n`, { mode: 0o600 }); const previous = process.env.OPENCODEX_HOME; + const previousDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; process.env.OPENCODEX_HOME = home; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(home, "desktop"); try { const errors: string[] = []; const spy = spyOn(console, "error").mockImplementation(value => errors.push(String(value))); - try { expect(await handleConnectCommand(["status", "--json"])).toBe(0); } + try { expect(await handleConnectCommand(["status", "--json"], { lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") } })).toBe(0); } finally { spy.mockRestore(); } expect(existsSync(join(home, "service-api-token.prev"))).toBe(false); expect(readFileSync(join(home, "service-api-token"), "utf8").trim()).toBe(token); @@ -614,7 +753,377 @@ describe("recoverable connected key rotation", () => { } finally { if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + if (previousDesktop === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktop; removeTreeWithRetry(home); } }); }); + + +/** Real per-process files and SQLite; only hub HTTP is substituted. Never return credential bytes. */ +function runDesktopLifecycleScenario(mode: string) { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-lifecycle-client-")); + const script = ` + const fs = require("node:fs"), path = require("node:path"), crypto = require("node:crypto"); + const { Readable } = require("node:stream"); + const { spyOn } = require("bun:test"); + const configApi = require("./src/config"); + const connectApi = require("./src/client/connect"); + const stateApi = require("./src/client/state"); + const store = require("./src/claude/desktop-remote-store"); + const locks = require("./src/client/lifecycle-lock"); + const { handleConnectCommand } = require("./src/cli/connect"); + const { DEFAULT_CATALOG_PATH } = require("./src/codex/paths"); + const mode = ${JSON.stringify(mode)}; + const home = process.env.OPENCODEX_HOME, desktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + for (const dir of [home, desktop, process.env.CODEX_HOME]) fs.mkdirSync(dir, { recursive: true }); + const lockDeps = { lockPath: path.join(home, "fixture-lifecycle.sqlite") }; + const oldKey = "ocx_data_" + "1".repeat(40), newKey = "ocx_data_" + "2".repeat(40); + const hash = value => crypto.createHash("sha256").update(value).digest("hex"); + const oldHash = hash(oldKey), newHash = hash(newKey); + const owner = { serverUrl: "https://hub.example.test", apiKeyId: "fixture-key", connectedAt: "2026-09-06T00:00:00.000Z" }; + const catalog = '{"models":[{"slug":"hub/model"}]}', prior = '{"models":[{"slug":"prior/model"}]}'; + const config = { port: 10100, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + defaultProvider: "openai", runtimeRole: "client", client: { + ...owner, managementUrl: owner.serverUrl, managementTransport: "direct", selectedClients: ["claude"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", tokenFingerprint: oldHash, protocolVersion: 1, + catalogFingerprint: crypto.createHash("sha256").update(catalog).digest("base64url"), + priorCatalog: Buffer.from(prior).toString("base64"), + } }; + fixtureFailurePhase = "config_setup"; + configApi.saveConfig(config); + if (configApi.readConfigDiagnostics().source !== "file" || stateApi.readClientConnectionState().kind !== "connected") { + throw new Error("fixture_config_invalid"); + } + const tokenPath = path.join(home, "service-api-token"), backupPath = tokenPath + ".prev"; + fs.writeFileSync(tokenPath, oldKey, { mode: 0o600 }); + fs.mkdirSync(path.dirname(DEFAULT_CATALOG_PATH), { recursive: true }); + fs.writeFileSync(DEFAULT_CATALOG_PATH, catalog); + const profilePath = path.join(desktop, "fixture.json"); + const baselinePath = path.join(home, "desktop-remote", "baseline.json"); + const unused = mode.startsWith("status-") || mode === "disconnect-expected-owner"; + fixtureFailurePhase = "desktop_setup"; + if (!unused) { + fs.writeFileSync(path.join(desktop, "_meta.json"), JSON.stringify({ appliedId: "fixture", entries: [{ id: "fixture", name: "opencodex" }], foreignMeta: "preserve" })); + fs.writeFileSync(profilePath, JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: mode === "disconnect-legacy" ? owner.serverUrl : "http://127.0.0.1:10100", + inferenceGatewayApiKey: mode === "disconnect-legacy" ? oldKey : "fixture-local-key", + modelDiscoveryEnabled: false, inferenceModels: [], foreignTheme: "preserve", + })); + if (mode !== "disconnect-legacy") { + const applied = locks.withClientLifecycleSync(held => store.applyRemoteDesktopStore(held, { + owner, expectedTokenFingerprint: oldHash, baseUrl: owner.serverUrl, apiKey: oldKey, mode: "static", + models: [{ name: "claude-opus-4-8-20260101", labelOverride: "Fixture", anthropicFamilyTier: "opus" }], + }), lockDeps); + if (!applied.ok) throw new Error("fixture_desktop_apply_failed"); + } + } + const baselineBefore = fs.existsSync(baselinePath) ? hash(fs.readFileSync(baselinePath)) : null; + const desktopValue = () => fs.existsSync(profilePath) ? JSON.parse(fs.readFileSync(profilePath, "utf8")) : {}; + const saveClient = change => { const c = configApi.loadConfig(); change(c.client, c); configApi.saveConfig(c); }; + const pending = { kind: "rotate", rotationId: "fixture-rotation", newKeyIssuedAt: "2026-09-06T00:00:01.000Z", oldKeyBackupPath: backupPath }; + const prepared = () => locks.withClientLifecycleSync(held => store.writeDesktopDisconnectReceipt(held, null, { + version: 1, owner, tokenFingerprint: oldHash, keepCatalog: false, phase: "prepared", + }), lockDeps); + const recovery = mode.startsWith("recover-") || mode.startsWith("same-old"); + if (recovery) { + saveClient(client => { client.pendingOperation = pending; }); + fs.writeFileSync(backupPath, oldKey, { mode: 0o600 }); + fs.writeFileSync(tokenPath, mode.startsWith("same-old") ? oldKey : newKey, { mode: 0o600 }); + } + let commits = 0, aborts = 0, desktopBeforeCommit = false, committed = mode === "recover-current", aborted = false; + let guardSeen = false, writesAfterGuard = 0, codexSawPrepared = false, codexOutsideL = false; + const fetchImpl = async (input, init = {}) => { + const url = String(input); + if (url.endsWith("/api/keys/rotate") && init.method === "POST") return Response.json({ + id: owner.apiKeyId, name: "fixture", key: newKey, createdAt: pending.newKeyIssuedAt, + rotationId: pending.rotationId, expiresAt: "2026-09-06T00:10:01.000Z", + }, { status: 201 }); + if (url.endsWith("/api/keys/rotate/commit")) { + commits++; + desktopBeforeCommit = desktopValue().inferenceGatewayApiKey === newKey; + committed = true; + if (mode === "commit-lost" && commits === 1) throw new Error("dropped fixture response"); + return Response.json({ ok: true }); + } + if (url.endsWith("/api/keys/rotate") && init.method === "DELETE") { + aborts++; + if (mode === "same-old-abort-failure") return Response.json({ error: "unavailable" }, { status: 503 }); + aborted = true; + return Response.json({ ok: true }); + } + if (url.endsWith("/v1/catalog")) { + if (mode === "sync-claim") { prepared(); return Response.json({ models: [{ slug: "new/model" }] }); } + if (mode === "sync-generation-change") { + saveClient(client => { client.tokenFingerprint = newHash; }); fs.writeFileSync(tokenPath, newKey); + return Response.json({ models: [{ slug: "new/model" }] }); + } + if (mode === "sync-queued-guard") return Response.json({ models: [{ slug: "new/model" }] }); + if (mode === "recover-probe-error") throw new Error("fixture probe unavailable"); + const value = new Headers(init.headers).get("x-opencodex-api-key"); + const oldAdmitted = !committed; + const newAdmitted = !aborted && !["recover-backup", "recover-backup-cli", "rollback"].includes(mode); + const admitted = mode !== "recover-neither" && ((value === oldKey && oldAdmitted) || (value === newKey && newAdmitted)); + return admitted ? new Response(catalog, { headers: { "Content-Type": "application/json", "X-OpenCodex-Key-Id": owner.apiKeyId } }) + : Response.json({ error: "unauthorized" }, { status: 401 }); + } + throw new Error("unexpected fixture request"); + }; + fixtureFailurePhase = "scenario"; + await (async () => { + let result = null, error = null, second = null, statusInside = null, statusOutside = null, cliRotation = null; + const credential = new TextEncoder().encode("ocx_admin_fixture"); + const deps = { fetchImpl, lifecycleLockDeps: lockDeps }; + try { + if (mode.startsWith("disconnect")) { + let journalSpy; + if (mode === "disconnect-expected-owner") { + saveClient(client => { client.apiKeyId = "new-fixture-key"; client.connectedAt = "2026-09-06T02:00:00.000Z"; client.tokenFingerprint = newHash; }); + fs.writeFileSync(tokenPath, newKey); + } + if (mode === "disconnect-order") { + saveClient(client => { client.selectedClients = ["codex"]; }); + const journal = require("./src/codex/journal"); + fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), 'model_provider = "opencodex"'); + fs.writeFileSync(journal.JOURNAL_PATH, JSON.stringify({ version: 1, + originalConfig: Buffer.from('model_provider = "openai"').toString("base64"), originalProfile: null, + owner: { kind: "client", apiKeyId: owner.apiKeyId }, + })); + const actualRestore = journal.restoreJournalState; + journalSpy = spyOn(journal, "restoreJournalState").mockImplementation(() => { + const r = store.readDesktopDisconnectReceipt(); + codexSawPrepared = r.kind === "valid" && r.value.phase === "prepared"; + codexOutsideL = locks.withClientLifecycleSync(() => true, lockDeps); + return actualRestore(); + }); + } + if (mode === "disconnect-foreign") { + const v = desktopValue(); v.userAdded = "preserved"; fs.writeFileSync(profilePath, JSON.stringify(v)); + } + if (mode === "disconnect-protected") { + const v = desktopValue(); v.inferenceModels = []; fs.writeFileSync(profilePath, JSON.stringify(v)); + } + if (mode === "disconnect-resume" || mode === "disconnect-after-clear") { + locks.withClientLifecycleSync(held => { + let r = { version: 1, owner, tokenFingerprint: oldHash, keepCatalog: false, phase: "prepared" }; + store.writeDesktopDisconnectReceipt(held, null, r); + const restored = store.restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [oldHash] }); + if (!restored.ok) throw new Error("fixture_restore_failed"); + const advance = (phase, fields = {}) => { const next = { ...r, ...fields, phase }; store.writeDesktopDisconnectReceipt(held, r, next); r = next; }; + advance("desktop_restored", restored.fingerprint ? { desktopAfterFingerprint: restored.fingerprint } : {}); + fs.writeFileSync(DEFAULT_CATALOG_PATH, prior); + advance("catalog_settled", { catalogAfter: { kind: "file", fingerprint: hash(prior) } }); + advance("removing_token"); fs.unlinkSync(tokenPath); + if (mode === "disconnect-after-clear") { advance("token_removed"); advance("clearing_connection"); stateApi.clearClientConnection(owner); } + }, lockDeps); + } + try { + result = await connectApi.disconnectClient(mode === "disconnect-expected-owner" ? { expectedOwner: owner } : {}, deps); + second = await connectApi.disconnectClient({}, deps); + } finally { journalSpy?.mockRestore(); } + } else if (mode === "sync-claim" || mode === "sync-queued-guard" || mode === "sync-generation-change") { + let spy; + if (mode === "sync-queued-guard") { + saveClient(client => { client.selectedClients = ["codex"]; }); + const inject = require("./src/codex/inject"); + spy = spyOn(inject, "injectCodexConfig").mockImplementation(async (_port, _config, options) => { + guardSeen = typeof options.beforeClientWrite === "function"; + prepared(); + options.beforeClientWrite?.(); + writesAfterGuard++; + return { success: true, status: "applied", message: "fixture" }; + }); + } + try { result = await connectApi.syncConnectedClient({}, deps); } finally { spy?.mockRestore(); } + } else if (mode === "status-lock" || mode === "status-receipt") { + fs.writeFileSync(backupPath, oldKey, { mode: 0o600 }); + if (mode === "status-receipt") prepared(); + statusInside = await locks.withClientLifecycle(async () => ({ + result: stateApi.inspectClientRotationRecoveryGate(stateApi.readClientConnectionState(), lockDeps), + backupPresent: fs.existsSync(backupPath), + }), lockDeps); + statusOutside = stateApi.inspectClientRotationRecoveryGate(stateApi.readClientConnectionState(), lockDeps); + } else if (mode === "clear-owner-change") { + saveClient(client => { client.connectedAt = "2026-09-06T02:00:00.000Z"; }); + result = stateApi.clearClientConnection(owner); + } else if (mode === "recover-backup-cli") { + const logs = [], errors = []; + const log = spyOn(console, "log").mockImplementation(value => logs.push(String(value))); + const err = spyOn(console, "error").mockImplementation(value => errors.push(String(value))); + try { + const exitCode = await handleConnectCommand(["rotate", "--admin-token-stdin", "--json"], { + ...deps, stdinImpl: Readable.from(["ocx_admin_fixture\\n"]), + }); + cliRotation = { exitCode, value: logs.length ? JSON.parse(logs.at(-1)) : null, revokedClaim: logs.some(x => x.includes("previous key is no longer admitted")) }; + } finally { log.mockRestore(); err.mockRestore(); } + } else if (recovery) result = await connectApi.recoverPendingClientRotation({ credential: { kind: "admin", value: credential } }, deps); + else result = await connectApi.rotateConnectedClientKey({ credential: { kind: "admin", value: credential } }, deps); + } catch (cause) { error = cause instanceof Error ? cause.message : "fixture operation failed"; } + const d = desktopValue(), state = stateApi.readClientConnectionState(); + const token = fs.existsSync(tokenPath) ? fs.readFileSync(tokenPath, "utf8").trim() : null; + const r = store.readDesktopDisconnectReceipt(); + console.log(JSON.stringify({ fixtureResult: { + result, error, second, commits, aborts, desktopBeforeCommit, guardSeen, writesAfterGuard, cliRotation, codexSawPrepared, codexOutsideL, + stateKind: state.kind, pending: state.kind === "connected" && !!state.value.pendingOperation, + persistedOutcome: state.kind === "connected" && Object.hasOwn(state.value, "rotationOutcome"), + tokenIsOld: token === oldKey, tokenIsNew: token === newKey, tokenAbsent: token === null, + desktopIsOld: d.inferenceGatewayApiKey === oldKey, desktopIsNew: d.inferenceGatewayApiKey === newKey, + desktopIsLocal: d.inferenceGatewayApiKey === "fixture-local-key", desktopHasKey: Object.hasOwn(d, "inferenceGatewayApiKey"), + foreignPreserved: unused || d.foreignTheme === "preserve", userAddedPreserved: d.userAdded === "preserved", + backupPresent: fs.existsSync(backupPath), + baselineUnchanged: baselineBefore !== null && fs.existsSync(baselinePath) && hash(fs.readFileSync(baselinePath)) === baselineBefore, + catalogUnchanged: fs.existsSync(DEFAULT_CATALOG_PATH) && fs.readFileSync(DEFAULT_CATALOG_PATH, "utf8") === catalog, + catalogPrior: fs.existsSync(DEFAULT_CATALOG_PATH) && fs.readFileSync(DEFAULT_CATALOG_PATH, "utf8") === prior, + receiptPhase: r.kind === "valid" ? r.value.phase : r.kind, + statusInside, statusOutside, credentialZeroed: credential.every(byte => byte === 0), + }})); + })(); + `; + // spyOn is a test-runner API; execute this synthetic scenario as a real test, + // not bare --eval. Resolve repository imports independently of its temporary path. + const resolvedScript = script.replace(/require\("(\.\/src\/[^"\n]+)"\)/g, + (_match, relative: string) => `require(${JSON.stringify(join(repoRoot, relative))})`); + const fixturePath = join(root, "client-lifecycle-fixture.test.ts"); + writeFileSync(fixturePath, `import { test } from "bun:test"; + test("isolated client lifecycle scenario", async () => { + let fixtureFailurePhase = "module_load"; + try { + ${resolvedScript} + } catch { + console.log(JSON.stringify({ fixtureFailure: fixtureFailurePhase })); + throw new Error("client_fixture_" + fixtureFailurePhase); + } + }, { timeout: ${INTERNAL_DEADLINE_MS} }); + `); + // Keep the canonical guard/preload, but avoid the repository's root="tests" + // discovery restriction for this generated temporary test file. + const child = spawnSync(process.execPath, ["test", "--preload", join(repoRoot, "tests/preload.ts"), fixturePath], { + cwd: root, + env: { ...process.env, OPENCODEX_HOME: join(root, "ocx"), CODEX_HOME: join(root, "codex"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(root, "desktop") }, + encoding: "utf8", timeout: INTERNAL_DEADLINE_MS, killSignal: "SIGKILL", + }); + try { + const marker = child.stdout.trim().split("\n").reverse().find(line => + line.startsWith('{"fixtureResult":') || line.startsWith('{"fixtureFailure":')); + let envelope: { fixtureFailure?: unknown; fixtureResult?: unknown } | undefined; + try { if (marker) envelope = JSON.parse(marker); } catch { /* fixed category below */ } + if (child.error || child.status !== 0 || child.signal || !envelope?.fixtureResult) { + const category = CLIENT_FIXTURE_FAILURE_CATEGORIES.find(value => value === envelope?.fixtureFailure) ?? "child_failed"; + throw new ClientStateProbeError(child.pid, child.status, child.signal, (child.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", category); + } + return envelope.fixtureResult as Record; + } finally { removeTreeWithRetry(root); } +} + +describe("Desktop copy coherence across client lifecycle", () => { + test.each(["rotate", "recover-both", "recover-current", "commit-lost"])("%s settles Desktop before reporting committed", mode => { + const r = runDesktopLifecycleScenario(mode); + expect(r.error).toBeNull(); + expect(r.result.rotationOutcome).toBe("committed"); + expect(r.tokenIsNew && r.desktopIsNew).toBe(true); + expect(r.pending || r.backupPresent || r.persistedOutcome).toBe(false); + expect(r.foreignPreserved && r.baselineUnchanged && r.credentialZeroed).toBe(true); + if (r.commits > 0) expect(r.desktopBeforeCommit).toBe(true); + }); + test.each(["recover-backup", "same-old"])("%s returns rolled_back without a false commit", mode => { + const r = runDesktopLifecycleScenario(mode); + expect(r.error).toBeNull(); + expect(r.result.rotationOutcome).toBe("rolled_back"); + expect(r.commits).toBe(0); + expect(r.aborts).toBe(1); + expect(r.tokenIsOld && r.desktopIsOld).toBe(true); + expect(r.pending || r.backupPresent || r.persistedOutcome).toBe(false); + expect(r.baselineUnchanged && r.credentialZeroed).toBe(true); + }); + test("normal failed candidate rolls both local copies back before returning failure", () => { + const r = runDesktopLifecycleScenario("rollback"); + expect(r.error).not.toBeNull(); + expect(r.commits).toBe(0); + expect(r.tokenIsOld && r.desktopIsOld).toBe(true); + expect(r.pending || r.backupPresent).toBe(false); + }); + test.each(["same-old-abort-failure", "recover-neither", "recover-probe-error"])("%s preserves recovery evidence", mode => { + const r = runDesktopLifecycleScenario(mode); + expect(r.result).toBeNull(); + expect(r.error).not.toBeNull(); + expect(r.commits).toBe(0); + expect(r.pending && r.backupPresent && r.credentialZeroed).toBe(true); + }); + test("CLI reports a recovered rollback honestly", () => { + const r = runDesktopLifecycleScenario("recover-backup-cli"); + expect(r.cliRotation.exitCode).toBe(0); + expect(r.cliRotation.value.rotation).toBe("rolled_back"); + expect(r.cliRotation.revokedClaim).toBe(false); + expect(r.desktopIsOld && r.tokenIsOld).toBe(true); + }); + test.each(["disconnect", "disconnect-foreign", "disconnect-resume", "disconnect-after-clear"])("%s restores projection and retries idempotently", mode => { + const r = runDesktopLifecycleScenario(mode); + expect(r.error).toBeNull(); + expect(r.stateKind).toBe("disconnected"); + expect(r.tokenAbsent && r.desktopIsLocal && r.foreignPreserved && r.catalogPrior).toBe(true); + expect(r.receiptPhase).toBe("complete"); + expect(r.second.tokenRemoved).toBe(false); + if (mode === "disconnect-foreign") expect(r.userAddedPreserved).toBe(true); + }); + test("legacy current-hub profile disconnects via labeled standard fallback", () => { + const r = runDesktopLifecycleScenario("disconnect-legacy"); + expect(r.error).toBeNull(); + expect(r.result.desktopRestoration).toBe("standard_fallback"); + expect(r.desktopHasKey).toBe(false); + expect(r.tokenAbsent && r.foreignPreserved).toBe(true); + }); + test("protected Desktop edits block destructive disconnect", () => { + const r = runDesktopLifecycleScenario("disconnect-protected"); + expect(r.error).not.toBeNull(); + expect(r.tokenIsOld && r.desktopIsOld).toBe(true); + expect(r.stateKind).toBe("connected"); + }); + test("post-await sync cannot overwrite a prepared disconnect", () => { + const r = runDesktopLifecycleScenario("sync-claim"); + expect(r.error).toBe("client_disconnect_pending"); + expect(r.catalogUnchanged).toBe(true); + expect(r.receiptPhase).toBe("prepared"); + }); + test("disconnect expectedOwner refuses a newly connected owner before claiming or deleting state", () => { + const r = runDesktopLifecycleScenario("disconnect-expected-owner"); + expect(r.result).toBeNull(); + expect(r.error).toBe("client_disconnect_expected_owner_changed"); + expect(r.stateKind).toBe("connected"); + expect(r.tokenIsNew && r.catalogUnchanged).toBe(true); + expect(r.receiptPhase).toBe("absent"); + }); + test("disconnect claims its receipt before Codex-only restoration outside L", () => { + const r = runDesktopLifecycleScenario("disconnect-order"); + expect(r.error).toBeNull(); + expect(r.codexSawPrepared && r.codexOutsideL).toBe(true); + expect(r.receiptPhase).toBe("complete"); + }); + test("sync CAS preserves a newer token generation and leaves catalog bytes unchanged", () => { + const r = runDesktopLifecycleScenario("sync-generation-change"); + expect(r.error).toBe("client_connection_changed"); + expect(r.tokenIsNew && r.catalogUnchanged).toBe(true); + }); + test("full-owner clear cannot delete a newer connection with the same key id", () => { + const r = runDesktopLifecycleScenario("clear-owner-change"); + expect(r.result).toBe("conflict"); + expect(r.stateKind).toBe("connected"); + expect(r.tokenIsOld).toBe(true); + }); + test("sync supplies the read-only guard at the actual injection seam", () => { + const r = runDesktopLifecycleScenario("sync-queued-guard"); + expect(r.guardSeen).toBe(true); + expect(r.writesAfterGuard).toBe(0); + expect(r.error).toBe("client_disconnect_pending"); + }); + test.each(["status-lock", "status-receipt"])("%s cannot discard another operation's backup", mode => { + const r = runDesktopLifecycleScenario(mode); + expect(r.error).toBeNull(); + expect(r.statusInside.result.kind).toBe("recovery-required"); + expect(r.statusInside.backupPresent).toBe(true); + expect(r.statusOutside.kind).toBe(mode === "status-lock" ? "orphan-cleaned" : "recovery-required"); + expect(r.backupPresent).toBe(mode === "status-receipt"); + }); +}); diff --git a/tests/clients/client-hub-relay.test.ts b/tests/clients/client-hub-relay.test.ts index 442bc91810..9baab569fc 100644 --- a/tests/clients/client-hub-relay.test.ts +++ b/tests/clients/client-hub-relay.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { HUB_RELAY_REQUEST_BODY_MAX_BYTES, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, + HUB_RELAY_DEFAULT_TIMEOUT_MS, relayHubManagementRequest, validateHubRelayRequestHeaders, } from "../../src/client/hub-relay"; @@ -144,4 +145,128 @@ describe("fixed-target hub management relay", () => { await reader.cancel(); expect(cancelled).toBe(true); }); + + test("established account SSE outlives the handshake deadline and still cancels on client abort", async () => { + const deadline = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); + const browser = new AbortController(); + let upstreamSignal!: AbortSignal; + let upstreamController!: ReadableStreamDefaultController; + let cancelled = false; + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await relayHubManagementRequest(relayRequest("/api/accounts/events", { signal: browser.signal }), "/api/accounts/events", target, { + fetchImpl: (async (_input, init) => { + upstreamSignal = init!.signal!; + return new Response(new ReadableStream({ + start(controller) { upstreamController = controller; controller.enqueue(new TextEncoder().encode("event: ready\n\n")); }, + cancel() { cancelled = true; }, + }), { headers: { "Content-Type": "text/event-stream; charset=utf-8" } }); + }) as typeof fetch, + }); + expect(timeout).toHaveBeenCalledWith(HUB_RELAY_DEFAULT_TIMEOUT_MS); + reader = response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); + deadline.abort(new DOMException("Handshake deadline", "TimeoutError")); + expect(upstreamSignal.aborted).toBe(false); + expect(cancelled).toBe(false); + upstreamController.enqueue(new TextEncoder().encode("event: account-selection\n\n")); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("account-selection"); + const pending = reader.read(); + browser.abort(); + await pending.catch(() => undefined); + expect(upstreamSignal.aborted).toBe(true); + expect(cancelled).toBe(true); + } finally { + await reader?.cancel().catch(() => undefined); + browser.abort(); + timeout.mockRestore(); + } + }); + + test.each([ + ["/api/config", "GET", 200, "application/json"], + ["/api/config", "GET", 200, "text/event-stream"], + ["/api/accounts/events", "POST", 200, "text/event-stream"], + ["/api/accounts/events", "GET", 201, "text/event-stream"], + ["/api/accounts/events", "GET", 401, "text/event-stream"], + ["/api/accounts/events", "GET", 200, "application/json"], + ["/api/accounts/events", "GET", 200, "text/event-streamish"], + ["/api/accounts/events?other=1", "GET", 200, "text/event-stream"], + ] as const)("relay keeps the total deadline for %s %s %i %s", async (path, method, status, contentType) => { + const deadline = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); + let upstreamSignal!: AbortSignal; + let cancelled = false; + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await relayHubManagementRequest(relayRequest(path, { method }), path, target, { + fetchImpl: (async (_input, init) => { + upstreamSignal = init!.signal!; + return new Response(new ReadableStream({ cancel() { cancelled = true; } }), { + status, headers: { "Content-Type": contentType }, + }); + }) as typeof fetch, + }); + reader = response.body!.getReader(); + const pending = reader.read(); + deadline.abort(new DOMException("Total deadline", "TimeoutError")); + await pending.catch(() => undefined); + expect(upstreamSignal.aborted).toBe(true); + expect(cancelled).toBe(true); + } finally { + await reader?.cancel().catch(() => undefined); + timeout.mockRestore(); + } + }); + + test("selection SSE still has a handshake deadline and a response body cap", async () => { + const deadline = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); + let handshakeStarted!: () => void; + const started = new Promise(resolve => { handshakeStarted = resolve; }); + try { + const pending = relayHubManagementRequest(relayRequest("/api/accounts/events"), "/api/accounts/events", target, { + fetchImpl: (async (_input, init) => new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + handshakeStarted(); + })) as typeof fetch, + }); + await started; + deadline.abort(new DOMException("Handshake deadline", "TimeoutError")); + expect((await pending).status).toBe(502); + } finally { timeout.mockRestore(); } + + let cancelled = false; + const response = await relayHubManagementRequest(relayRequest("/api/accounts/events"), "/api/accounts/events", target, { + fetchImpl: (async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(HUB_RELAY_RESPONSE_BODY_MAX_BYTES + 1)); }, + cancel() { cancelled = true; }, + }), { headers: { "Content-Type": "text/event-stream" } })) as typeof fetch, + }); + await expect(response.arrayBuffer()).rejects.toThrow("response body too large"); + expect(cancelled).toBe(true); + }); + + test.each(["complete", "cancel"] as const)("relay detaches deadline and client listeners after body %s", async disposition => { + const deadline = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); + const browser = new AbortController(); + let upstreamSignal!: AbortSignal; + try { + const response = await relayHubManagementRequest(relayRequest("/api/config", { signal: browser.signal }), "/api/config", target, { + fetchImpl: (async (_input, init) => { + upstreamSignal = init!.signal!; + return new Response(new ReadableStream({ + start(controller) { if (disposition === "complete") controller.close(); }, + }), { headers: { "Content-Type": "application/json" } }); + }) as typeof fetch, + }); + if (disposition === "complete") await response.text(); + else await response.body!.cancel(); + deadline.abort(); + browser.abort(); + expect(upstreamSignal.aborted).toBe(false); + } finally { timeout.mockRestore(); } + }); }); diff --git a/tests/clients/client-lifecycle-lock.test.ts b/tests/clients/client-lifecycle-lock.test.ts new file mode 100644 index 0000000000..1ca622e414 --- /dev/null +++ b/tests/clients/client-lifecycle-lock.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + assertClientLifecycleHeld, withClientLifecycle, withClientLifecycleSync, + type ClientLifecycleHeld, +} from "../../src/client/lifecycle-lock"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; + +const PROCESS_TIMEOUT = 120_000; +const lockModule = pathToFileURL(repoPath("src/client/lifecycle-lock.ts")).href; +let root = ""; +let lockPath = ""; +const children = new Set>(); + +beforeEach(() => { + root = mkdtempSync(join(import.meta.dir, ".tmp-client-lifecycle-")); + lockPath = join(root, "lock.sqlite"); +}); + +afterEach(async () => { + for (const child of children) { + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + } + children.clear(); + removeTreeWithRetry(root); +}); + +function spawn(source: string, env: Record = {}) { + const child = Bun.spawn([process.execPath, "-e", source], { + cwd: repoRoot(), env: { ...process.env, ...env }, + stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + children.add(child); + return child; +} + +async function exited(child: ReturnType) { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + child.exited, + new Promise((_, reject) => { + timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Lifecycle child timed out")); }, 100_000); + }), + ]); + } finally { clearTimeout(timer); } +} + +async function ready(child: ReturnType, marker: string): Promise { + const deadline = performance.now() + 100_000; + while (!existsSync(marker)) { + if (child.exitCode !== null) throw new Error(`Holder exited: ${await new Response(child.stderr).text()}`); + if (performance.now() >= deadline) throw new Error("Lifecycle holder did not acquire its lock"); + // Poll the actual acquisition marker, never infer readiness from elapsed time. + await Bun.sleep(10); + } +} + +function holder(path = lockPath) { + const marker = join(root, "holder-ready"); + const release = join(root, "holder-release"); + const child = spawn(` + import { existsSync, writeFileSync } from "node:fs"; + import { withClientLifecycle, assertClientLifecycleHeld } from ${JSON.stringify(lockModule)}; + await withClientLifecycle(async held => { + assertClientLifecycleHeld(held); + writeFileSync(${JSON.stringify(marker)}, "held"); + while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(10); + assertClientLifecycleHeld(held); + }, { lockPath: ${JSON.stringify(path)} }); + `); + return { child, marker, release }; +} + +function contender(path: string, sync: boolean) { + return spawn(` + import { withClientLifecycle, withClientLifecycleSync, assertClientLifecycleHeld } from ${JSON.stringify(lockModule)}; + let ran = false; + try { + const work = held => { assertClientLifecycleHeld(held); ran = true; }; + ${sync ? "withClientLifecycleSync(work," : "await withClientLifecycle(async held => work(held),"} + { lockPath: ${JSON.stringify(path)} }); + console.log(JSON.stringify({ ran, acquired: true })); + } catch (error) { console.log(JSON.stringify({ ran, code: error.code })); } + `); +} + +test("async and sync leases are valid only during their own callback", async () => { + let asyncLease!: ClientLifecycleHeld; + const answer = await withClientLifecycle(async held => { + asyncLease = held; + assertClientLifecycleHeld(held); + await Promise.resolve(); + assertClientLifecycleHeld(held); + expect(() => assertClientLifecycleHeld({ ...held })).toThrow("client_lifecycle_lease_invalid"); + expect(() => assertClientLifecycleHeld(Object.create(held))).toThrow("client_lifecycle_lease_invalid"); + return 42; + }, { lockPath }); + expect(answer).toBe(42); + expect(() => assertClientLifecycleHeld(asyncLease)).toThrow("client_lifecycle_lease_invalid"); + let syncLease!: ClientLifecycleHeld; + expect(withClientLifecycleSync(held => { + syncLease = held; + assertClientLifecycleHeld(held); + expect(() => assertClientLifecycleHeld(asyncLease)).toThrow("client_lifecycle_lease_invalid"); + return "sync"; + }, { lockPath })).toBe("sync"); + expect(() => assertClientLifecycleHeld(syncLease)).toThrow("client_lifecycle_lease_invalid"); + expect(withClientLifecycleSync(() => undefined, { lockPath })).toBeUndefined(); + expect(await withClientLifecycle(async () => undefined, { lockPath })).toBeUndefined(); +}, PROCESS_TIMEOUT); + +test("forged and non-object lease values fail without creating a lock file", () => { + for (const value of [{}, Object.freeze({}), null, undefined, true, 1, "held", () => {}]) { + try { + assertClientLifecycleHeld(value as ClientLifecycleHeld); + throw new Error("forged lease accepted"); + } catch (error) { + expect(error).toMatchObject({ code: "client_lifecycle_lease_invalid", message: "client_lifecycle_lease_invalid" }); + } + } + expect(existsSync(lockPath)).toBe(false); +}); + +test.each([undefined, null, false, 0, "failure", new Error("primary")])( + "both wrappers preserve thrown value %s and invalidate the lease", async failure => { + for (const sync of [false, true]) { + let held!: ClientLifecycleHeld; + let caught = false; + try { + const work = (lease: ClientLifecycleHeld): never => { held = lease; throw failure; }; + if (sync) withClientLifecycleSync(work, { lockPath }); + else await withClientLifecycle(async lease => work(lease), { lockPath }); + } catch (error) { caught = true; expect(error).toBe(failure); } + expect(caught).toBe(true); + expect(() => assertClientLifecycleHeld(held)).toThrow("client_lifecycle_lease_invalid"); + expect(withClientLifecycleSync(() => "reacquired", { lockPath })).toBe("reacquired"); + } + }, PROCESS_TIMEOUT, +); + +test("sync rejects object/function thenables without calling them and revokes async continuations", async () => { + let called = false; + const then = () => { called = true; }; + for (const value of [{ then }, Object.assign(() => {}, { then })]) { + let held!: ClientLifecycleHeld; + expect(() => withClientLifecycleSync(lease => { held = lease; return value; }, { lockPath })) + .toThrow("client_lifecycle_async_callback"); + expect(() => assertClientLifecycleHeld(held)).toThrow("client_lifecycle_lease_invalid"); + } + expect(called).toBe(false); + let continuation!: Promise; + let refused = false; + expect(() => withClientLifecycleSync(held => { + continuation = Promise.resolve().then(() => { + try { assertClientLifecycleHeld(held); } + catch { refused = true; } + throw undefined; + }); + return continuation; + }, { lockPath })).toThrow("client_lifecycle_async_callback"); + await continuation.catch(() => undefined); + expect(refused).toBe(true); + expect(withClientLifecycleSync(() => true, { lockPath })).toBe(true); +}, PROCESS_TIMEOUT); + +test("a throwing then getter preserves its thrown value and revokes the sync lease", () => { + let held!: ClientLifecycleHeld; + let caught = false; + try { + withClientLifecycleSync(lease => { + held = lease; + return { get then(): never { throw undefined; } }; + }, { lockPath }); + } catch (error) { caught = true; expect(error).toBeUndefined(); } + expect(caught).toBe(true); + expect(() => assertClientLifecycleHeld(held)).toThrow("client_lifecycle_lease_invalid"); + expect(withClientLifecycleSync(() => true, { lockPath })).toBe(true); +}, PROCESS_TIMEOUT); + +test("same-process recursive acquisition is busy and independent namespaces remain usable", async () => { + await withClientLifecycle(async held => { + expect(() => withClientLifecycleSync(() => { throw new Error("must not enter"); }, { lockPath })) + .toThrow("client_lifecycle_busy"); + await expect(withClientLifecycle(async () => { throw new Error("must not enter"); }, { lockPath })) + .rejects.toMatchObject({ code: "client_lifecycle_busy" }); + assertClientLifecycleHeld(held); + expect(withClientLifecycleSync(inner => { assertClientLifecycleHeld(inner); return 9; }, { + lockPath: join(root, "independent.sqlite"), + })).toBe(9); + }, { lockPath }); +}, PROCESS_TIMEOUT); + +test("real contender processes cannot enter a held async SQLite transaction", async () => { + const held = holder(); + await ready(held.child, held.marker); + try { + for (const sync of [false, true]) { + const child = contender(lockPath, sync); + expect(await exited(child)).toBe(0); + expect(JSON.parse(await new Response(child.stdout).text())).toEqual({ ran: false, code: "client_lifecycle_busy" }); + } + const independent = contender(join(root, "other.sqlite"), false); + expect(await exited(independent)).toBe(0); + expect(JSON.parse(await new Response(independent.stdout).text())).toEqual({ ran: true, acquired: true }); + } finally { + writeFileSync(held.release, "release"); + expect(await exited(held.child)).toBe(0); + } + const after = contender(lockPath, true); + expect(await exited(after)).toBe(0); + expect(JSON.parse(await new Response(after.stdout).text())).toEqual({ ran: true, acquired: true }); +}, PROCESS_TIMEOUT); + +test("SIGKILL releases the OS lock without deleting or reclaiming the database", async () => { + const held = holder(); + await ready(held.child, held.marker); + const before = lstatSync(lockPath, { bigint: true }); + held.child.kill("SIGKILL"); + await exited(held.child); + expect(existsSync(lockPath)).toBe(true); + const after = contender(lockPath, false); + expect(await exited(after)).toBe(0); + expect(JSON.parse(await new Response(after.stdout).text())).toEqual({ ran: true, acquired: true }); + const reopened = lstatSync(lockPath, { bigint: true }); + expect(reopened.dev).toBe(before.dev); + expect(reopened.ino).toBe(before.ino); +}, PROCESS_TIMEOUT); + +test("default namespace uses OS identity despite home overrides (isolated resolver observation)", async () => { + const identityModule = pathToFileURL(repoPath("src/codex/user-identity.ts")).href; + const child = spawn(` + import { mock } from "bun:test"; + import { existsSync } from "node:fs"; + const identity = await import(${JSON.stringify(identityModule)}); + const expected = identity.resolveEffectiveUserIdentity(); + const observations = []; + // Redirect only the OS runtime root in this isolated child, so testing the + // default resolver never opens the real user's lock. SQLite is NOT mocked. + mock.module(${JSON.stringify(identityModule)}, () => ({ + ...identity, + resolveEffectiveUserRuntimeRoot(user) { + observations.push(user); + return ${JSON.stringify(root)}; + }, + })); + const { withClientLifecycle, withClientLifecycleSync } = await import(${JSON.stringify(lockModule)}); + for (const suffix of ["one", "two"]) { + for (const key of ["HOME", "USERPROFILE", "LOCALAPPDATA", "TMPDIR", "TMP", "TEMP", "OPENCODEX_HOME", "CODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR"]) { + process.env[key] = ${JSON.stringify(root)} + "/" + suffix; + } + await withClientLifecycle(async () => { + try { withClientLifecycleSync(() => { throw new Error("unexpected entry"); }); } + catch (error) { if (error.code === "client_lifecycle_busy") return; throw error; } + throw new Error("split default namespace"); + }); + } + console.log(JSON.stringify({ expected, observations, + created: existsSync(${JSON.stringify(join(root, "client-desktop-lifecycle.sqlite"))}) })); + `); + expect(await exited(child)).toBe(0); + const result = JSON.parse(await new Response(child.stdout).text()); + expect(result.created).toBe(true); + expect(result.observations).toHaveLength(4); + expect(result.observations.every((value: unknown) => JSON.stringify(value) === JSON.stringify(result.expected))).toBe(true); +}, PROCESS_TIMEOUT); + +test("release failures close real SQLite handles, revoke leases and preserve even thrown undefined", async () => { + const child = spawn(` + import { Database } from "bun:sqlite"; + import { withClientLifecycle, withClientLifecycleSync, assertClientLifecycleHeld } from ${JSON.stringify(lockModule)}; + const deps = { lockPath: ${JSON.stringify(lockPath)} }; + const exec = Database.prototype.exec; + const close = Database.prototype.close; + const results = []; + for (const sync of [false, true]) for (const primary of [false, true]) for (const fault of ["rollback", "close"]) { + let lease; + let caught = false; + let primaryPreserved = false; + let releaseReported = false; + const work = held => { + lease = held; + Database.prototype.exec = function(sql, ...args) { + if (fault === "rollback" && sql === "ROLLBACK") throw undefined; + return exec.call(this, sql, ...args); + }; + Database.prototype.close = function(...args) { + const value = close.apply(this, args); + if (fault === "close") throw undefined; + return value; + }; + if (primary) throw undefined; + return 1; + }; + try { + if (sync) withClientLifecycleSync(work, deps); + else await withClientLifecycle(async held => work(held), deps); + } catch (error) { + caught = true; + primaryPreserved = primary && error === undefined; + releaseReported = !primary && error?.code === "client_lifecycle_lock_failed" && Object.hasOwn(error, "cause"); + } finally { Database.prototype.exec = exec; Database.prototype.close = close; } + let expired = false; + try { assertClientLifecycleHeld(lease); } catch (error) { expired = error.code === "client_lifecycle_lease_invalid"; } + const reacquired = withClientLifecycleSync(() => true, deps); + results.push(caught && (primaryPreserved || releaseReported) && expired && reacquired); + } + console.log(JSON.stringify(results)); + `); + expect(await exited(child)).toBe(0); + expect(JSON.parse(await new Response(child.stdout).text())).toEqual(Array(8).fill(true)); +}, PROCESS_TIMEOUT); + +test.skipIf(process.platform === "win32")("POSIX paths are private and links are refused before changing their targets", () => { + withClientLifecycleSync(() => {}, { lockPath }); + expect(lstatSync(root).mode & 0o777).toBe(0o700); + expect(lstatSync(lockPath).mode & 0o777).toBe(0o600); + const target = join(root, "target"); + writeFileSync(target, "untouched", { mode: 0o644 }); + const linked = join(root, "symlink.sqlite"); + symlinkSync(target, linked); + expect(() => withClientLifecycleSync(() => {}, { lockPath: linked })).toThrow("client_lifecycle_lock_failed"); + expect(readFileSync(target, "utf8")).toBe("untouched"); + expect(lstatSync(target).mode & 0o777).toBe(0o644); + const hardlink = join(root, "hardlink.sqlite"); + linkSync(target, hardlink); + expect(() => withClientLifecycleSync(() => {}, { lockPath: hardlink })).toThrow("client_lifecycle_lock_failed"); + const directory = join(root, "not-a-database"); + mkdirSync(directory); + expect(() => withClientLifecycleSync(() => {}, { lockPath: directory })).toThrow("client_lifecycle_lock_failed"); +}); diff --git a/tests/clients/desktop-3p-guard.test.ts b/tests/clients/desktop-3p-guard.test.ts index 8f8092dc62..a56842bff6 100644 --- a/tests/clients/desktop-3p-guard.test.ts +++ b/tests/clients/desktop-3p-guard.test.ts @@ -68,7 +68,9 @@ test("the guard rejects an over-long label", () => { test("writeDesktop3pConfig emits a config whose model list passes the guard end to end", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-guard-")); const prev = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const previousHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; + process.env.OPENCODEX_HOME = join(dir, "ocx"); try { const result = writeDesktop3pConfig( 10100, @@ -76,6 +78,9 @@ test("writeDesktop3pConfig emits a config whose model list passes the guard end [{ provider: "kimi", id: "k3[1m]", contextWindow: 1_048_576 }], "test-key", "static", + undefined, + undefined, + { lockPath: join(dir, "lifecycle.sqlite") }, ); expect(result.written).toBe(true); const written = JSON.parse(readFileSync(result.path, "utf8")) as { @@ -87,6 +92,8 @@ test("writeDesktop3pConfig emits a config whose model list passes the guard end } finally { if (prev === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = prev; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; removeTreeWithRetry(dir); } }); diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index d06b2b74b8..0d2dec2d42 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -1,4 +1,7 @@ -import { expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { saveConfig, readConfigDiagnostics } from "../../src/config"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -6,9 +9,40 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { inspectDesktop3pConfigLibrary, - removeDesktop3pStandardPivot, + removeDesktop3pStandardPivot as removeDesktop3pStandardPivotProduction, } from "../../src/claude/desktop-3p"; +let previousHome: string | undefined; +let fixtureHome: string; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + fixtureHome = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-home-")); + process.env.OPENCODEX_HOME = fixtureHome; + saveConfig({ port: 10100, defaultProvider: "test", providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, clientIntegrations: { "claude-desktop": false } } as OcxConfig); + expect(readConfigDiagnostics().source).toBe("file"); + expect(readConfigDiagnostics().config.clientIntegrations?.["claude-desktop"]).toBe(false); +}); +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(fixtureHome); +}); + +function removeDesktop3pStandardPivot(options: NonNullable[0]> = {}) { + const library = options.env?.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + if (!library) throw new Error("Desktop removal fixture must supply its library path"); + const previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + try { + return removeDesktop3pStandardPivotProduction({ + ...options, lifecycleLockDeps: { lockPath: join(fixtureHome, "locks", "desktop.sqlite") }, + }); + } finally { + if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + } +} + function envFor(path: string): NodeJS.ProcessEnv { return { ...process.env, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: path }; } diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index 4fae0f4253..e2e9796e08 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -1,5 +1,10 @@ +import { saveConfig, readConfigDiagnostics } from "../../src/config"; +import { writeServiceApiTokenFile } from "../../src/lib/service-secrets"; +import { withClientLifecycleSync } from "../../src/client/lifecycle-lock"; +import { applyRemoteDesktopStore } from "../../src/claude/desktop-remote-store"; +import type { OcxConfig } from "../../src/types"; import { describe, expect, spyOn, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; import { @@ -10,16 +15,129 @@ import { generateDesktop3pConfig, generateDesktop3pModels, legacyDesktop3pAlias, + isUnresolvedDesktop3pAlias, + isKnownDesktop3pModelId, parseDesktop3pModeArgs, resolveDesktop3pConfigLibraryPath, resolveDesktop3pAlias, writeDesktop3pConfig, + writeRemoteDesktop3pConfig, + type Desktop3pModelEntry, } from "../../src/claude/desktop-3p"; import { moveDesktopRoute, reconcileDesktopProfile, setDesktopFamilyDefault } from "../../src/claude/desktop-profile"; import { resolveInboundModel } from "../../src/claude/inbound"; import { removeTreeWithRetry } from "../helpers/remove-tree"; describe("Claude Desktop 3P models", () => { + test("replaces native exemptions together with the registry on either install path", () => { + const dated = "claude-opus-4-8-20260304"; + try { + buildDesktop3pRegistry([], [{ provider: "anthropic", id: dated }]); + expect(resolveDesktop3pAlias(dated)).toBeNull(); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(false); + generateDesktop3pModels(["gpt-5.6-sol"], []); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(true); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-8-ncb")).toBe(false); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-ncb")).toBe(false); + generateDesktop3pModels([], [{ provider: "anthropic", id: dated }]); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(false); + buildDesktop3pRegistry([], []); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(true); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-8-ncb")).toBe(true); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-ncb")).toBe(true); + for (const id of ["claude-opus-4-8", "claude-haiku-4-5", "claude-opus-4-8-20250201", "claude-ocx-native--claude-fable-5-1"]) { + expect(isUnresolvedDesktop3pAlias(id)).toBe(false); + } + } finally { buildDesktop3pRegistry([], []); } + }); + + test("remote apply preserves exact hub entries and foreign keys without installing aliases", () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "ocx-desktop-remote-"))); + const previous = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(dir, "ocx"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; + const models: Desktop3pModelEntry[] = [{ + name: "claude-opus-4-8-20260304", labelOverride: "Hub model", + anthropicFamilyTier: "fable", isFamilyDefault: true, supports1m: true, prefer1m: true, + }]; + try { + saveConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, defaultProvider: "test", port: 4096 } as OcxConfig); + expect(readConfigDiagnostics().source).toBe("file"); + const local = writeDesktop3pConfig(4096, ["gpt-5.6-sol"], [], "old-key", "static", undefined, undefined, { lockPath: join(dir, "locks", "desktop.sqlite") }); + expect(local.written).toBe(true); + const prior = JSON.parse(readFileSync(local.path, "utf8")); + writeFileSync(local.path, JSON.stringify({ ...prior, foreignSetting: { retained: true } })); + const token = writeServiceApiTokenFile("remote-fixture-key"); + const owner = { serverUrl: "https://hub.example.test", apiKeyId: "desktop-fixture", connectedAt: "2026-09-06T00:00:00.000Z" }; + saveConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, defaultProvider: "test", port: 4096, runtimeRole: "client", client: { + ...owner, managementUrl: owner.serverUrl, managementTransport: "direct", selectedClients: ["claude"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", tokenFingerprint: token.fingerprint, protocolVersion: 1, + } } as OcxConfig); + expect(readConfigDiagnostics().source).toBe("file"); + for (const mode of ["static", "hybrid", "discovery"] as const) { + const result = withClientLifecycleSync(held => applyRemoteDesktopStore(held, { + owner, expectedTokenFingerprint: token.fingerprint, + baseUrl: owner.serverUrl, apiKey: "remote-fixture-key", mode, models, + }), { lockPath: join(dir, "locks", "desktop.sqlite") }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.reason); + expect(result.path).toBe(local.path); + const written = JSON.parse(readFileSync(result.path!, "utf8")); + expect(written.inferenceGatewayBaseUrl).toBe("https://hub.example.test"); + expect(written.inferenceGatewayApiKey).toBe("remote-fixture-key"); + expect(written.modelDiscoveryEnabled).toBe(mode !== "static"); + expect(written.inferenceModels).toEqual(mode === "discovery" ? undefined : models); + expect(written.foreignSetting).toEqual({ retained: true }); + expect(resolveDesktop3pAlias(models[0]!.name)).toBeNull(); + expect(resolveDesktop3pAlias("claude-opus-4-8-ncb")).toBe("native/gpt-5.6-sol"); + } + } finally { + if (previous === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previous; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + buildDesktop3pRegistry([], []); + removeTreeWithRetry(dir); + } + }); + + test("local generation and unbound remote failures retain result semantics and existing file bytes", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-generation-")); + const previous = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(dir, "ocx"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; + try { + saveConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, defaultProvider: "test", port: 4096 } as OcxConfig); + expect(readConfigDiagnostics().source).toBe("file"); + const initial = writeDesktop3pConfig(4096, [], [{ provider: "test", id: "valid" }], undefined, "static", undefined, undefined, { lockPath: join(dir, "locks", "desktop.sqlite") }); + expect(initial.written).toBe(true); + const before = readFileSync(initial.path, "utf8"); + const beforeMeta = readFileSync(join(dir, "_meta.json"), "utf8"); + const local = writeDesktop3pConfig(4096, [], [{ provider: "test", id: "x".repeat(90) }], undefined, "static", undefined, undefined, { lockPath: join(dir, "locks", "desktop.sqlite") }); + const remote = writeRemoteDesktop3pConfig({ + baseUrl: "https://hub.example.test", apiKey: "fixture-key", mode: "static", + lifecycleLockDeps: { lockPath: join(dir, "locks", "desktop.sqlite") }, + models: [{ name: "invalid", labelOverride: "Hub", anthropicFamilyTier: "opus" }], + }); + expect(local.written).toBe(false); + expect(local.path).toBe(initial.path); + expect(local.reason).toContain("exceeds 80 chars"); + // An unbound caller is refused before selecting or touching a Desktop file. + expect(remote).toMatchObject({ written: false, path: "", reason: "desktop_remote_connection_required" }); + expect(readFileSync(initial.path, "utf8")).toBe(before); + expect(readFileSync(join(dir, "_meta.json"), "utf8")).toBe(beforeMeta); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previous; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + buildDesktop3pRegistry([], []); + removeTreeWithRetry(dir); + } + }); + test("resolves the actual cross-platform Claude Desktop config library (#539)", () => { // Claude Desktop appends "-3p" to its userData root (app.asar `GE()`), so the // suffix-less path is one Desktop never reads. Branch-by-branch coverage lives in @@ -279,8 +397,12 @@ describe("Claude Desktop 3P models", () => { test("re-applying an owned profile preserves foreign profile keys", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-merge-")); const previous = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(dir, "ocx"); process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; try { + saveConfig({ providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, defaultProvider: "test", port: 4096 } as OcxConfig); + expect(readConfigDiagnostics().source).toBe("file"); const id = "owned-profile"; mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "_meta.json"), JSON.stringify({ @@ -297,7 +419,7 @@ describe("Claude Desktop 3P models", () => { foreignDeploymentSetting: { allowed: true }, })); - const written = writeDesktop3pConfig(4096, ["gpt-5.6-sol"], [], "new-key"); + const written = writeDesktop3pConfig(4096, ["gpt-5.6-sol"], [], "new-key", "static", undefined, undefined, { lockPath: join(dir, "locks", "desktop.sqlite") }); expect(written.written).toBe(true); const profile = JSON.parse(readFileSync(join(dir, `${id}.json`), "utf8")); expect(profile.foreignDeploymentSetting).toEqual({ allowed: true }); @@ -306,6 +428,8 @@ describe("Claude Desktop 3P models", () => { } finally { if (previous === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previous; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; removeTreeWithRetry(dir); } }); @@ -331,3 +455,21 @@ describe("Claude Desktop 3P models", () => { } }); }); + + +test("Desktop FAST base validation preserves exact catalog IDs and clears stale exemptions", () => { + const unknown = "claude-opus-4-8-20260202--fast"; + const registry = buildDesktop3pRegistry([], [{ provider: "routed", id: "model-one" }]); + const known = registry.keys().next().value!; + try { + expect(isKnownDesktop3pModelId(known)).toBe(true); + expect(isUnresolvedDesktop3pAlias(known + "--fast")).toBe(false); + expect(isUnresolvedDesktop3pAlias(unknown)).toBe(true); + generateDesktop3pModels([], [{ provider: "anthropic", id: unknown }]); + expect(isKnownDesktop3pModelId(unknown)).toBe(true); + expect(isUnresolvedDesktop3pAlias(unknown)).toBe(false); + buildDesktop3pRegistry([], []); + expect(isKnownDesktop3pModelId(unknown)).toBe(false); + expect(isUnresolvedDesktop3pAlias(unknown)).toBe(true); + } finally { buildDesktop3pRegistry([], []); } +}); diff --git a/tests/clients/desktop-profile.test.ts b/tests/clients/desktop-profile.test.ts index f002183850..2f20f4513a 100644 --- a/tests/clients/desktop-profile.test.ts +++ b/tests/clients/desktop-profile.test.ts @@ -7,6 +7,7 @@ import { reconcileDesktopProfile, renderDesktopProfile, setDesktopFamilyDefault, + validDateAlias, type DesktopProfileModel, } from "../../src/claude/desktop-profile"; @@ -17,6 +18,31 @@ const models: DesktopProfileModel[] = [ ]; describe("Claude Desktop profile", () => { + test("recognizes only valid dates in the emitted managed namespace", () => { + expect(validDateAlias("claude-opus-4-8-20260101")).toBe(true); + expect(validDateAlias("claude-opus-4-8-20261231")).toBe(true); + for (const id of ["claude-opus-4-8-20260229", "claude-opus-4-8-20261301", "claude-opus-4-8-20250101", "claude-haiku-4-5-20260101"]) { + expect(validDateAlias(id)).toBe(false); + } + }); + + test("keeps every hidden assignment and reserves its date for newly added routes", () => { + const assignments: ReturnType["assignments"] = {}; + for (let day = 1; day <= 364; day++) { + const date = new Date(Date.UTC(2026, 0, day)).toISOString().slice(0, 10).replaceAll("-", ""); + assignments[`hidden/model-${day}`] = { family: "opus", alias: `claude-opus-4-8-${date}` }; + } + const profile = parseDesktopProfile({ + version: 1, + assignments, + defaults: { opus: "hidden/model-1", fable: null, sonnet: null, haiku: null }, + }); + const next = reconcileDesktopProfile(profile, [{ route: "new/model", label: "New" }]); + for (const [route, assignment] of Object.entries(assignments)) expect(next.assignments[route]).toEqual(assignment); + expect(next.assignments["new/model"]!.alias).toBe("claude-opus-4-8-20261231"); + expect(profile.assignments["new/model"]).toBeUndefined(); + }); + test("reconciles new routes into Opus with stable unique date aliases", () => { const first = reconcileDesktopProfile(undefined, models); const second = reconcileDesktopProfile(first, [...models].reverse()); diff --git a/tests/clients/desktop-remote-store.test.ts b/tests/clients/desktop-remote-store.test.ts new file mode 100644 index 0000000000..3ec619f65f --- /dev/null +++ b/tests/clients/desktop-remote-store.test.ts @@ -0,0 +1,362 @@ +import { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../src/claude/desktop-3p"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import * as configIO from "../../src/config"; +import { saveConfig, loadConfig, readConfigDiagnostics, atomicWriteFile } from "../../src/config"; +import { withClientLifecycleSync, type ClientLifecycleHeld } from "../../src/client/lifecycle-lock"; +import { serviceApiTokenFingerprint, writeServiceApiTokenFile, replaceServiceApiTokenFile, writeTokenBackup, serviceApiTokenBackupPath, removeServiceApiTokenFileIfOwned } from "../../src/lib/service-secrets"; +import { + applyRemoteDesktopStore, replaceRemoteDesktopCredential, restoreRemoteDesktopStore, finishRemoteDesktopCleanup, + inspectRemoteDesktopStore, inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt, writeDesktopDisconnectReceipt, + type DesktopDisconnectReceipt, type DesktopRemoteOwner, +} from "../../src/claude/desktop-remote-store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; + +const owner: DesktopRemoteOwner = { serverUrl: "https://hub.example.test", apiKeyId: "fixture-key", connectedAt: "2026-09-06T00:00:00.000Z" }; +const token = "ocx_data_store_fixture_current"; +const fingerprint = serviceApiTokenFingerprint(token); +const hash = (text: string) => serviceApiTokenFingerprint(text); +let dir: string, library: string, previousHome: string | undefined, previousLibrary: string | undefined; +function locked(work: (held: ClientLifecycleHeld) => T): T { + return withClientLifecycleSync(work, { lockPath: join(dir, "locks", "lifecycle.sqlite") }); +} +function read(path: string): Record { return JSON.parse(readFileSync(path, "utf8")); } +function profile() { return read(join(library, "original.json")); } +function initial(value: Record, selected = "original"): void { + atomicWriteFile(join(library, "original.json"), JSON.stringify(value)); + atomicWriteFile(join(library, "foreign.json"), JSON.stringify({ foreign: true })); + atomicWriteFile(join(library, "_meta.json"), JSON.stringify({ + appliedId: selected, customMetadata: "keep", entries: [{ id: "original", name: "opencodex", custom: true }, { id: "foreign", name: "Personal" }], + })); +} +function remote(key = token) { + return { inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: owner.serverUrl, + inferenceGatewayApiKey: key, modelDiscoveryEnabled: false, inferenceModels: [], custom: "preserved" }; +} +function apply() { + return locked(held => applyRemoteDesktopStore(held, { + owner, expectedTokenFingerprint: fingerprint, baseUrl: owner.serverUrl, apiKey: token, mode: "static", + models: [{ name: "claude-opus-4-8-20260304", labelOverride: "Fixture", anthropicFamilyTier: "fable" }], + })); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + dir = realpathSync(mkdtempSync(join(tmpdir(), "ocx-desktop-store-"))); + library = join(dir, "desktop"); + process.env.OPENCODEX_HOME = join(dir, "ocx"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + saveConfig({ port: 10100, defaultProvider: "test", providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, runtimeRole: "client", client: { + ...owner, managementUrl: owner.serverUrl, managementTransport: "direct", selectedClients: ["claude"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", tokenFingerprint: fingerprint, protocolVersion: 1, + } } as OcxConfig); + writeServiceApiTokenFile(token); + expect(readConfigDiagnostics().source).toBe("file"); + expect(loadConfig().client?.apiKeyId).toBe(owner.apiKeyId); + // Only the fixture creates its Desktop root; read-only store calls never do. + mkdirSync(library, { recursive: true }); +}); + + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + removeTreeWithRetry(dir); +}); + +describe("connection-owned Desktop projection store", () => { + test("facade dependencies use the real fixture lock and release it before the next call", () => { + const config = loadConfig(); delete config.client; config.runtimeRole = "standalone"; saveConfig(config); + initial({ custom: true }); + const deps = { lockPath: join(dir, "locks", "facade.sqlite") }; + const before = hash(readFileSync(join(library, "original.json"), "utf8")); + withClientLifecycleSync(() => { + expect(writeDesktop3pConfig(10100, [], [], undefined, "static", undefined, undefined, deps).written).toBe(false); + expect(removeDesktop3pStandardPivot({ lifecycleLockDeps: deps }).ok).toBe(false); + expect(hash(readFileSync(join(library, "original.json"), "utf8"))).toBe(before); + }, deps); + expect(writeDesktop3pConfig(10100, [], [], undefined, "static", undefined, undefined, deps).written).toBe(true); + config.clientIntegrations = { "claude-desktop": false }; saveConfig(config); + expect(removeDesktop3pStandardPivot({ lifecycleLockDeps: deps }).ok).toBe(true); + }); + + test("local facade mutations respect the fresh opposite desired state", () => { + const config = loadConfig(); delete config.client; config.runtimeRole = "standalone"; + config.clientIntegrations = { "claude-desktop": true }; saveConfig(config); + initial(remote()); + const before = hash(readFileSync(join(library, "original.json"), "utf8")); + const beforeMeta = hash(readFileSync(join(library, "_meta.json"), "utf8")); + const deps = { lockPath: join(dir, "locks", "desired.sqlite") }; + expect(removeDesktop3pStandardPivot({ lifecycleLockDeps: deps })).toMatchObject({ ok: false, changed: false, reason: "desired_state_changed" }); + config.clientIntegrations = { "claude-desktop": false }; saveConfig(config); + expect(writeDesktop3pConfig(10100, [], [], undefined, "static", undefined, undefined, deps)).toMatchObject({ written: false, reason: "desired_state_changed" }); + expect(hash(readFileSync(join(library, "original.json"), "utf8"))).toBe(before); + expect(hash(readFileSync(join(library, "_meta.json"), "utf8"))).toBe(beforeMeta); + }); + + test("invalid or mismatched client config cannot fall back to local facade mutation", () => { + initial(remote()); + const before = hash(readFileSync(join(library, "original.json"), "utf8")); + const beforeMeta = hash(readFileSync(join(library, "_meta.json"), "utf8")); + const deps = { lockPath: join(dir, "locks", "facade.sqlite") }; + for (const malformed of [ + { runtimeRole: "client", client: {} }, + { runtimeRole: "client" }, + { runtimeRole: "standalone", client: {} }, + ]) { + atomicWriteFile(join(dir, "ocx", "config.json"), JSON.stringify(malformed)); + expect(removeDesktop3pStandardPivot({ lifecycleLockDeps: deps })).toMatchObject({ ok: false, changed: false, kind: "unsafe" }); + expect(writeDesktop3pConfig(10100, [], [], undefined, "static", undefined, undefined, deps).written).toBe(false); + expect(hash(readFileSync(join(library, "original.json"), "utf8"))).toBe(before); + expect(hash(readFileSync(join(library, "_meta.json"), "utf8"))).toBe(beforeMeta); + } + }); + + test("forged lease rejects before creating storage; unused inspect has no footprint", () => { + expect(inspectRemoteDesktopStore(owner).kind).toBe("absent"); + expect(() => restoreRemoteDesktopStore({} as ClientLifecycleHeld, { owner, knownTokenFingerprints: [fingerprint] })).toThrow("client_lifecycle_lease_invalid"); + expect(existsSync(join(dir, "ocx", "desktop-remote"))).toBe(false); + }); + + test("keeps first local baseline across apply and preserves later foreign fields/selection on restore", () => { + initial({ ...remote("local-profile-key"), inferenceGatewayBaseUrl: "http://127.0.0.1:10100", custom: "old" }); + expect(apply().ok).toBe(true); + const baselinePath = join(dir, "ocx", "desktop-remote", "baseline.json"); + const originalBaselineHash = hash(readFileSync(baselinePath, "utf8")); + if (process.platform !== "win32") expect(statSync(baselinePath).mode & 0o777).toBe(0o600); + expect(apply().ok).toBe(true); + expect(hash(readFileSync(baselinePath, "utf8"))).toBe(originalBaselineHash); + const current = profile(); current.custom = "new-user-value"; atomicWriteFile(join(library, "original.json"), JSON.stringify(current)); + const meta = read(join(library, "_meta.json")); meta.appliedId = "foreign"; meta.newForeignMetadata = true; + atomicWriteFile(join(library, "_meta.json"), JSON.stringify(meta)); + const restored = locked(held => restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [fingerprint] })); + expect(restored).toMatchObject({ ok: true, status: "restored", restoration: "selection_preserved" }); + expect(hash(String(profile().inferenceGatewayApiKey))).toBe(hash("local-profile-key")); + expect(profile().custom).toBe("new-user-value"); + expect(read(join(library, "_meta.json")).appliedId).toBe("foreign"); + expect(read(join(library, "_meta.json")).newForeignMetadata).toBe(true); + expect(existsSync(join(library, "original.json.bak"))).toBe(false); + }); + + test("legacy direct disconnect creates a labeled standard fallback and sanitizes known-key backup", () => { + initial(remote()); + atomicWriteFile(join(library, "original.json.bak"), JSON.stringify(remote())); + expect(inspectRemoteDesktopStore(owner).kind).toBe("legacy_current_connection"); + const result = locked(held => restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [fingerprint] })); + expect(result).toMatchObject({ ok: true, baselineKind: "standard_fallback", restoration: "standard_fallback" }); + expect(profile().inferenceProvider).toBeUndefined(); + expect(profile().custom).toBe("preserved"); + expect(read(join(library, "original.json.bak")).inferenceGatewayApiKey).toBeUndefined(); + const baseline = readFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "utf8"); + const state = readFileSync(join(dir, "ocx", "desktop-remote", "state.json"), "utf8"); + expect(baseline.includes(token)).toBe(false); + expect(state.includes(token)).toBe(false); + expect(read(join(library, "_meta.json")).appliedId).toBe("original"); + }); + + test("rotation changes only the key and keeps fallback baseline immutable", () => { + initial(remote()); + expect(apply().ok).toBe(true); + const baseline = hash(readFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "utf8")); + const before = profile(); const beforeMeta = hash(readFileSync(join(library, "_meta.json"), "utf8")); + writeTokenBackup(fingerprint); + const replacementKey = "ocx_data_store_fixture_replacement"; + const pending = loadConfig(); + pending.client!.pendingOperation = { kind: "rotate", rotationId: "fixture-rotation", newKeyIssuedAt: owner.connectedAt, oldKeyBackupPath: serviceApiTokenBackupPath() }; + saveConfig(pending); + replaceServiceApiTokenFile(replacementKey); + const updated = locked(held => replaceRemoteDesktopCredential(held, { owner, expectedTokenFingerprint: fingerprint, replacementKey })); + expect(updated.ok).toBe(true); + const after = profile(); + expect(hash(String(after.inferenceGatewayApiKey))).toBe(hash(replacementKey)); + delete before.inferenceGatewayApiKey; delete after.inferenceGatewayApiKey; + expect(after).toEqual(before); + expect(hash(readFileSync(join(library, "_meta.json"), "utf8"))).toBe(beforeMeta); + expect(hash(readFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "utf8"))).toBe(baseline); + expect(existsSync(join(library, "original.json.bak"))).toBe(false); + replaceServiceApiTokenFile(token); + const rollback = locked(held => replaceRemoteDesktopCredential(held, { owner, expectedTokenFingerprint: hash(replacementKey), replacementKey: token })); + expect(rollback.ok).toBe(true); + expect(hash(String(profile().inferenceGatewayApiKey))).toBe(fingerprint); + }); + + test("managed edits and missing committed baseline fail closed", () => { + initial({ custom: true }); expect(apply().ok).toBe(true); + const changed = profile(); changed.inferenceGatewayBaseUrl = "https://other.example.test"; + atomicWriteFile(join(library, "original.json"), JSON.stringify(changed)); + const before = hash(readFileSync(join(library, "original.json"), "utf8")); + expect(locked(held => restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [fingerprint] }))).toMatchObject({ ok: false, reason: "conflict" }); + expect(hash(readFileSync(join(library, "original.json"), "utf8"))).toBe(before); + writeFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "{}", { mode: 0o600 }); + expect(inspectRemoteDesktopCleanup().kind).toBe("unsafe"); + }); + + test("replays a profile-written metadata-failed apply without replacing the first baseline", () => { + initial({ custom: true }, "foreign"); + const realWrite = configIO.atomicWriteFile; + const failure = spyOn(configIO, "atomicWriteFile").mockImplementation((path, content, io, hooks) => { + if (path === join(library, "_meta.json")) throw new Error("injected metadata failure"); + return realWrite(path, content, io, hooks); + }); + try { expect(apply()).toMatchObject({ ok: false, changed: true, reason: "recovery_required" }); } + finally { failure.mockRestore(); } + const baseline = hash(readFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "utf8")); + expect(apply().ok).toBe(true); + expect(read(join(library, "_meta.json")).appliedId).toBe("original"); + expect(hash(readFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "utf8"))).toBe(baseline); + }); + + test("direct restore retains new-target creation evidence after metadata failures", () => { + atomicWriteFile(join(library, "foreign.json"), JSON.stringify({ foreign: "preserve" })); + atomicWriteFile(join(library, "_meta.json"), JSON.stringify({ + appliedId: "foreign", entries: [{ id: "foreign", name: "Personal" }], foreignMetadata: true, + })); + const realWrite = configIO.atomicWriteFile; + const failMetadata = () => spyOn(configIO, "atomicWriteFile").mockImplementation((path, content, io, hooks) => { + if (path === join(library, "_meta.json")) throw new Error("injected new-row metadata failure"); + return realWrite(path, content, io, hooks); + }); + const applyFailure = failMetadata(); + try { expect(apply()).toMatchObject({ ok: false, changed: true, reason: "recovery_required" }); } + finally { applyFailure.mockRestore(); } + const statePath = join(dir, "ocx", "desktop-remote", "state.json"); + const interrupted = read(statePath); + const targetId = String(interrupted.targetId); + const targetPath = join(library, `${targetId}.json`); + expect(existsSync(targetPath)).toBe(true); + expect((read(join(library, "_meta.json")).entries as Array<{ id: string }>).some(entry => entry.id === targetId)).toBe(false); + const baselineHash = hash(readFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "utf8")); + const receipt: DesktopDisconnectReceipt = { version: 1, owner, tokenFingerprint: fingerprint, keepCatalog: false, phase: "prepared" }; + locked(held => writeDesktopDisconnectReceipt(held, null, receipt)); + // A real prepared disconnect already bars reapply; recovery must use restore. + expect(apply()).toMatchObject({ ok: false, reason: "conflict" }); + const restoreFailure = failMetadata(); + try { + expect(locked(held => restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [fingerprint] }))) + .toMatchObject({ ok: false, changed: true, reason: "recovery_required" }); + } finally { restoreFailure.mockRestore(); } + expect((read(statePath).pending as { kind: string }).kind).toBe("restore"); + expect(read(statePath).lastProjectionHash).toBe(interrupted.lastProjectionHash); + expect(read(targetPath).inferenceGatewayApiKey).toBeUndefined(); + expect(locked(held => restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [fingerprint] }))) + .toMatchObject({ ok: true, status: "restored", restoration: "selection_preserved" }); + expect(read(statePath).pending).toBeUndefined(); + expect(inspectRemoteDesktopStore(owner).kind).toBe("restored"); + expect(read(join(library, "_meta.json")).appliedId).toBe("foreign"); + expect(read(join(library, "_meta.json")).foreignMetadata).toBe(true); + expect(hash(readFileSync(join(dir, "ocx", "desktop-remote", "baseline.json"), "utf8"))).toBe(baselineHash); + }); + + test.each([false, true])("metadata capacity permits only an existing owned target (reuse=%s)", reuseOwned => { + const entries = Array.from({ length: 256 }, (_, index) => ({ + id: `entry-${index}`, name: reuseOwned && index === 0 ? "opencodex" : `Personal ${index}`, + })); + for (const entry of entries) writeFileSync(join(library, `${entry.id}.json`), JSON.stringify({ foreign: entry.id })); + atomicWriteFile(join(library, "_meta.json"), JSON.stringify({ appliedId: "entry-0", entries })); + const beforeMeta = hash(readFileSync(join(library, "_meta.json"), "utf8")); + const beforeTarget = hash(readFileSync(join(library, "entry-0.json"), "utf8")); + const files = readdirSync(library).sort(); + const result = apply(); + expect(result.ok).toBe(reuseOwned); + expect((read(join(library, "_meta.json")).entries as unknown[]).length).toBe(256); + expect(readdirSync(library).sort()).toEqual(files); + if (reuseOwned) { + expect(hash(String(read(join(library, "entry-0.json")).inferenceGatewayApiKey))).toBe(fingerprint); + expect(inspectRemoteDesktopStore(owner).kind).toBe("active"); + } else { + expect(result).toMatchObject({ ok: false, changed: false, reason: "conflict" }); + expect(hash(readFileSync(join(library, "_meta.json"), "utf8"))).toBe(beforeMeta); + expect(hash(readFileSync(join(library, "entry-0.json"), "utf8"))).toBe(beforeTarget); + expect(existsSync(join(dir, "ocx", "desktop-remote", "baseline.json"))).toBe(false); + expect(existsSync(join(dir, "ocx", "desktop-remote", "state.json"))).toBe(false); + } + }); + + test.each([false, true])("connected remover refuses another library before remote mutation (tracked=%s)", tracked => { + initial(remote()); + if (tracked) expect(apply().ok).toBe(true); + const otherLibrary = join(dir, "other-desktop"); + mkdirSync(otherLibrary, { recursive: true }); + atomicWriteFile(join(otherLibrary, "other.json"), JSON.stringify(remote())); + atomicWriteFile(join(otherLibrary, "_meta.json"), JSON.stringify({ appliedId: "other", entries: [{ id: "other", name: "opencodex" }] })); + const config = loadConfig(); config.clientIntegrations = { "claude-desktop": false }; saveConfig(config); + const paths = [join(library, "original.json"), join(library, "_meta.json"), join(otherLibrary, "other.json"), join(otherLibrary, "_meta.json")]; + const before = paths.map(path => hash(readFileSync(path, "utf8"))); + const statePath = join(dir, "ocx", "desktop-remote", "state.json"); + const beforeState = existsSync(statePath) ? hash(readFileSync(statePath, "utf8")) : null; + const result = removeDesktop3pStandardPivot({ + env: { ...process.env, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: otherLibrary }, + lifecycleLockDeps: { lockPath: join(dir, "locks", "mismatch.sqlite") }, + }); + expect(result).toMatchObject({ ok: false, changed: false, reason: "desktop_library_identity_changed" }); + expect(paths.map(path => hash(readFileSync(path, "utf8")))).toEqual(before); + expect(existsSync(statePath) ? hash(readFileSync(statePath, "utf8")) : null).toBe(beforeState); + }); + + test("invalid remote entries fail before storing any baseline or rewriting the profile", () => { + initial({ custom: true }); + const before = hash(readFileSync(join(library, "original.json"), "utf8")); + const result = locked(held => applyRemoteDesktopStore(held, { + owner, expectedTokenFingerprint: fingerprint, baseUrl: owner.serverUrl, apiKey: token, mode: "static", + models: [{ name: "invalid", labelOverride: "Fixture", anthropicFamilyTier: "opus" }], + })); + expect(result).toMatchObject({ ok: false, changed: false, reason: "unsafe" }); + expect(hash(readFileSync(join(library, "original.json"), "utf8"))).toBe(before); + expect(existsSync(join(dir, "ocx", "desktop-remote", "baseline.json"))).toBe(false); + }); + + test("unsafe metadata IDs and duplicate rows never create a baseline", () => { + initial(remote()); + const metaPath = join(library, "_meta.json"); + const meta = read(metaPath); + meta.entries = [{ id: "original", name: "opencodex" }, { id: "original", name: "Other" }]; + atomicWriteFile(metaPath, JSON.stringify(meta)); + expect(apply()).toMatchObject({ ok: false, reason: "unsafe" }); + expect(existsSync(join(dir, "ocx", "desktop-remote", "baseline.json"))).toBe(false); + meta.appliedId = "../escape"; meta.entries = [{ id: "../escape", name: "opencodex" }]; + atomicWriteFile(metaPath, JSON.stringify(meta)); + expect(inspectRemoteDesktopStore(owner).kind).toBe("unsafe"); + }); + + test("an interrupted prepared baseline can resume only while original bytes still match", () => { + initial({ custom: true }); + const realWrite = configIO.atomicWriteFile; + const failure = spyOn(configIO, "atomicWriteFile").mockImplementation((path, content, io, hooks) => { + if (path.endsWith("/desktop-remote/baseline.json") || path.endsWith("\\desktop-remote\\baseline.json")) throw new Error("injected baseline failure"); + return realWrite(path, content, io, hooks); + }); + try { expect(apply()).toMatchObject({ ok: false, changed: true }); } + finally { failure.mockRestore(); } + expect(inspectRemoteDesktopStore(owner).kind).toBe("pending"); + expect(profile().inferenceProvider).toBeUndefined(); + expect(apply().ok).toBe(true); + expect(inspectRemoteDesktopStore(owner).kind).toBe("active"); + }); + + test("receipt CAS and final cleanup require actual disconnected state and absent token", () => { + initial(remote()); expect(apply().ok).toBe(true); + const first: DesktopDisconnectReceipt = { version: 1, owner, tokenFingerprint: fingerprint, keepCatalog: false, phase: "prepared" }; + locked(held => writeDesktopDisconnectReceipt(held, null, first)); + expect(() => locked(held => writeDesktopDisconnectReceipt(held, null, first))).toThrow("desktop_disconnect_receipt_conflict"); + expect(locked(held => restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [fingerprint] })).ok).toBe(true); + let current = first; + for (const phase of ["desktop_restored", "catalog_settled", "removing_token", "token_removed", "clearing_connection", "connection_cleared"] as const) { + const next = { ...current, phase }; + locked(held => writeDesktopDisconnectReceipt(held, current, next)); current = next; + } + expect(locked(held => finishRemoteDesktopCleanup(held, owner)).ok).toBe(false); + removeServiceApiTokenFileIfOwned(fingerprint); + const config = loadConfig(); delete config.client; config.runtimeRole = "standalone"; saveConfig(config); + expect(locked(held => finishRemoteDesktopCleanup(held, owner)).ok).toBe(true); + expect(existsSync(join(dir, "ocx", "desktop-remote", "baseline.json"))).toBe(false); + expect(inspectRemoteDesktopCleanup().kind).toBe("pending"); + const complete = { ...current, phase: "complete" as const }; + locked(held => writeDesktopDisconnectReceipt(held, current, complete)); + expect(readDesktopDisconnectReceipt()).toMatchObject({ kind: "valid", value: { phase: "complete" } }); + expect(inspectRemoteDesktopCleanup().kind).toBe("absent"); + }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 54ab80de12..872e9b3824 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -401,6 +401,25 @@ describe("classifier unit behavior", () => { expect(parseConfig("{{{", "json")).toBe(PARSE_FAILED); }); + test("parseConfig refuses typed TOML dates before a JSON clone can turn them into strings", () => { + for (const literal of [ + "2026-09-05T10:00:00Z", + "2026-09-05T10:00:00-07:00", + "2026-09-05T10:00:00.123456", + "2026-09-05", + "10:00:00.123456", + ]) { + for (const text of [ + `expires = ${literal}\n`, + `[user]\nexpires = ${literal}\n`, + `items = [{ expires = ${literal} }]\n`, + ]) { + expect(parseConfig(text, "toml")).toBe(PARSE_FAILED); + } + expect(parseConfig(`expires = "${literal}"\n`, "toml")).toEqual({ expires: literal }); + } + }); + test("parseConfig refuses json number literals a rewrite would change", () => { // Overflow to Infinity — a rewrite would bake in null. expect(parseConfig("{\"a\": 1e999}", "json")).toBe(PARSE_FAILED); diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index 0bf81fdb54..de2f164710 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -141,6 +141,24 @@ function reverseJsonObjectKeys(value: unknown): unknown { } describe("apply", () => { + test("refuses Kimi TOML date rewrites without changing the file or ownership store", () => { + const spec = INTEGRATION_CLIENTS.kimi; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + const original = "[user]\nexpires = 2026-09-05T10:00:00Z\n"; + writeFileSync(configPath, original); + const request = input({ clientId: "kimi" }); + + expect(readIntegrationState(request).state).toBe("unsafe"); + const result = applyIntegration(request); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(store.listOperations()).toHaveLength(0); + expect(store.readRecords().kimi).toBeUndefined(); + }); + test("refuses a client that is not installed, and writes nothing", () => { const result = applyIntegration(input()); expect(result.ok).toBe(false); diff --git a/tests/clients/remote-catalog.test.ts b/tests/clients/remote-catalog.test.ts index b38e0c4587..78ade3d974 100644 --- a/tests/clients/remote-catalog.test.ts +++ b/tests/clients/remote-catalog.test.ts @@ -1,12 +1,254 @@ import { describe, expect, test } from "bun:test"; -import { downloadClientCatalog, HubClientError } from "../../src/client/hub-client"; +import { downloadClientCatalog, downloadDesktop3pModels, HubClientError } from "../../src/client/hub-client"; const JSON_HEADERS = { "Content-Type": "application/json", ETag: '"catalog-v1"' }; +const desktopModel = { + name: "claude-opus-4-8-20260101", + labelOverride: "Remote model", + anthropicFamilyTier: "opus" as const, +}; + function response(body: string, headers: HeadersInit = JSON_HEADERS): Response { return new Response(body, { headers }); } +describe("remote Desktop snapshot consumer", () => { + test.each(["https://hub.example.test", "http://127.0.0.1:2345", "http://localhost:2345", "http://[::1]:2345"])( + "uses authenticated opt-in discovery at permitted origin %s", async origin => { + const result = await downloadDesktop3pModels(origin, "ocx_data_test", { + fetchImpl: async (input, init) => { + expect(String(input)).toBe(`${origin}/v1/models?ids=desktop&format=desktop-config`); + expect(init?.method).toBe("GET"); + expect(init?.redirect).toBe("manual"); + const headers = new Headers(init?.headers); + expect(headers.get("anthropic-version")).toBe("2023-06-01"); + expect(headers.get("x-opencodex-api-key")).toBe("ocx_data_test"); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.has("if-none-match")).toBe(false); + return response(JSON.stringify({ version: 1, models: [desktopModel] })); + }, + }); + expect(result).toEqual({ version: 1, models: [desktopModel] }); + }, + ); + + test("refuses insecure transport before constructing credential headers or fetching", async () => { + let calls = 0; + await expect(downloadDesktop3pModels("http://hub.example.test", "invalid\nheader", { + fetchImpl: async () => { calls++; return response("{}"); }, + })).rejects.toMatchObject({ code: "insecure_http_refused" }); + expect(calls).toBe(0); + }); + + test("does not follow redirects or reflect their destination/body", async () => { + let calls = 0; + await expect(downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async (_input, init) => { + calls++; + expect(init?.redirect).toBe("manual"); + return new Response("response-marker", { status: 302, headers: { Location: "https://destination-marker.test" } }); + }, + })).rejects.toMatchObject({ code: "redirect_refused", message: "Hub Desktop model snapshot request failed" }); + expect(calls).toBe(1); + }); + + test.each([304, 401, 403, 404, 500])("refuses HTTP %s with a fixed error", async status => { + await expect(downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => new Response(status === 304 ? null : "remote-body-marker", { status }), + })).rejects.toMatchObject({ code: `desktop_snapshot_http_${status}`, message: "Hub Desktop model snapshot request failed" }); + }); + + test.each([ + ["old catalog", { data: [] }, "desktop_snapshot_unsupported"], + ["future version", { version: 2, models: [] }, "desktop_snapshot_unsupported"], + ["null", null, "desktop_snapshot_invalid"], + ["array envelope", [], "desktop_snapshot_invalid"], + ["missing models", { version: 1 }, "desktop_snapshot_invalid"], + ["object models", { version: 1, models: {} }, "desktop_snapshot_invalid"], + ["null row", { version: 1, models: [null] }, "desktop_snapshot_invalid"], + ["array row", { version: 1, models: [[]] }, "desktop_snapshot_invalid"], + ["missing name", { version: 1, models: [{ labelOverride: "Label", anthropicFamilyTier: "opus" }] }, "desktop_snapshot_invalid"], + ["bad label type", { version: 1, models: [{ ...desktopModel, labelOverride: 1 }] }, "desktop_snapshot_invalid"], + ["bad name", { version: 1, models: [{ ...desktopModel, name: "remote-marker" }] }, "desktop_snapshot_invalid"], + ["duplicate", { version: 1, models: [desktopModel, desktopModel] }, "desktop_snapshot_invalid"], + ["bracket label", { version: 1, models: [{ ...desktopModel, labelOverride: "remote-marker[1m]" }] }, "desktop_snapshot_invalid"], + ["long label", { version: 1, models: [{ ...desktopModel, labelOverride: "x".repeat(81) }] }, "desktop_snapshot_invalid"], + ["bad family", { version: 1, models: [{ ...desktopModel, anthropicFamilyTier: "remote-marker" }] }, "desktop_snapshot_invalid"], + ["bad default", { version: 1, models: [{ ...desktopModel, isFamilyDefault: 1 }] }, "desktop_snapshot_invalid"], + ["false supports1m", { version: 1, models: [{ ...desktopModel, supports1m: false }] }, "desktop_snapshot_invalid"], + ["false prefer1m", { version: 1, models: [{ ...desktopModel, prefer1m: false }] }, "desktop_snapshot_invalid"], + ["null flag", { version: 1, models: [{ ...desktopModel, supports1m: null }] }, "desktop_snapshot_invalid"], + ] as const)("rejects %s without reflecting remote values", async (_label, value, code) => { + let caught: unknown; + try { + await downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => response(JSON.stringify(value)), + }); + } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(HubClientError); + expect((caught as HubClientError).code).toBe(code); + expect((caught as Error).cause).toBeUndefined(); + expect(String(caught)).not.toContain("remote-marker"); + expect(String(caught)).not.toContain("secret-marker"); + }); + + test("projects only known fields while keeping valid capability flags and an 80-character label", async () => { + const known = { ...desktopModel, labelOverride: "x".repeat(80), isFamilyDefault: false, supports1m: true, prefer1m: true }; + const result = await downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + fetchImpl: async () => response(JSON.stringify({ version: 1, models: [{ ...known, apiKey: "remote-marker", endpoint: "http://wrong.test" }], unknown: 1 })), + }); + expect(result).toEqual({ version: 1, models: [known] }); + }); + + test("accepts empty snapshots and 2000 rows, refuses 2001", async () => { + for (const count of [0, 2000, 2001]) { + const models = Array.from({ length: count }, (_, index) => ({ ...desktopModel, name: `claude-test-${index}` })); + const pending = downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + fetchImpl: async () => response(JSON.stringify({ version: 1, models })), + }); + if (count <= 2000) expect((await pending).models).toEqual(models); + else await expect(pending).rejects.toMatchObject({ code: "desktop_snapshot_invalid" }); + } + }); + + test("enforces the 1 MiB streamed cap even without or with a forged content-length", async () => { + const prefix = '{"version":1,"models":[]}'; + for (const extra of [0, 1]) { + for (const declared of [undefined, "1"]) { + const body = prefix + " ".repeat(1024 * 1024 - prefix.length + extra); + const pending = downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + const bytes = new TextEncoder().encode(body); + controller.enqueue(bytes.subarray(0, 512 * 1024)); + controller.enqueue(bytes.subarray(512 * 1024)); + controller.close(); + }, + }), { headers: { "Content-Type": "application/json", ...(declared ? { "Content-Length": declared } : {}) } }), + }); + if (extra === 0) expect(await pending).toEqual({ version: 1, models: [] }); + else await expect(pending).rejects.toMatchObject({ code: "body_too_large" }); + } + } + }); + + test("rejects wrong content type, malformed JSON and unsafe error causes", async () => { + for (const [body, type] of [["{remote-marker", "application/json"], ['{"version":1,"models":[]}', "text/html"]]) { + let caught: unknown; + try { + await downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => response(body!, { "Content-Type": type! }), + }); + } catch (error) { caught = error; } + expect(caught).toMatchObject({ code: "desktop_snapshot_invalid", message: "Hub Desktop model snapshot was invalid" }); + expect((caught as Error).cause).toBeUndefined(); + } + let caught: unknown; + try { + await downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => { throw new Error("secret-marker remote-marker"); }, + }); + } catch (error) { caught = error; } + expect(caught).toMatchObject({ code: "unreachable", message: "Hub Desktop model snapshot request failed" }); + expect((caught as Error).cause).toBeUndefined(); + }); + + test("normalizes a /v1 URL and accepts JSON-compatible content types", async () => { + expect(await downloadDesktop3pModels("https://hub.example.test/v1/", "ocx_data_test", { + fetchImpl: async input => { + expect(String(input)).toBe("https://hub.example.test/v1/models?ids=desktop&format=desktop-config"); + return response('{"version":1,"models":[]}', { "Content-Type": "application/vnd.opencodex+json; charset=utf-8" }); + }, + })).toEqual({ version: 1, models: [] }); + }); + + test("enforces the Desktop total deadline even while a loopback body keeps progressing", async () => { + const timeoutMs = 1000; + const observed: { + headers: boolean; + chunks: number; + bytes: number; + chunksAtDeadline: number; + signal?: AbortSignal; + } = { headers: false, chunks: 0, bytes: 0, chunksAtDeadline: 0 }; + let timer: ReturnType | undefined; + let finishedNaturally = false; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"version":1,"models":[]}')); + let ticks = 0; + // Progress is twenty times more frequent than the inactivity deadline. The + // whole valid body is under 100 bytes, so neither inactivity nor size is + // the intended rejection. A broken total deadline would finish successfully. + timer = setInterval(() => { + controller.enqueue(new Uint8Array([0x20])); + if (++ticks === 40) { + clearInterval(timer); + finishedNaturally = true; + controller.close(); + } + }, 50); + }, + cancel() { clearInterval(timer); }, + }), { headers: JSON_HEADERS }); + }, + }); + try { + await expect(downloadDesktop3pModels(`http://127.0.0.1:${server.port}`, "ocx_data_test", { + timeoutMs, + fetchImpl: async (input, init) => { + observed.signal = init?.signal ?? undefined; + observed.signal?.addEventListener("abort", () => { + observed.chunksAtDeadline = observed.chunks; + }, { once: true }); + const received = await fetch(input, init); + observed.headers = true; + // Count bytes actually delivered to the consumer, not merely server enqueues. + const body = received.body!.pipeThrough(new TransformStream({ + transform(chunk, controller) { + observed.chunks++; + observed.bytes += chunk.byteLength; + controller.enqueue(chunk); + }, + })); + return new Response(body, { status: received.status, headers: received.headers }); + }, + })).rejects.toMatchObject({ code: "unreachable" }); + expect(observed.headers).toBe(true); + expect(observed.chunksAtDeadline).toBeGreaterThanOrEqual(2); + expect(observed.signal?.aborted).toBe(true); + expect(observed.bytes).toBeGreaterThan(0); + expect(observed.bytes).toBeLessThan(100); + expect(finishedNaturally).toBe(false); + } finally { + clearInterval(timer); + server.stop(true); + } + }); + + test("bounds stalled response headers and streamed bodies without exposing their errors", async () => { + await expect(downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + timeoutMs: 25, + fetchImpl: async (_input, init) => new Promise((_resolve, reject) => { + const signal = init!.signal!; + if (signal.aborted) reject(signal.reason); + else signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + })).rejects.toMatchObject({ code: "unreachable" }); + await expect(downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + timeoutMs: 25, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('{"version":1,"models":[')); }, + }), { headers: JSON_HEADERS }), + })).rejects.toMatchObject({ code: "unreachable" }); + }); +}); + describe("remote catalog adversarial consumer", () => { test("allows a catalog download to exceed five seconds while bytes keep arriving", async () => { const chunks = ['{"models":[', '{"slug":"provider/model"}', ']}']; diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index e02d50af8b..5661373642 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -1,16 +1,22 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { ExportModel } from "../../src/clients/config-export"; +import type { writeDesktop3pConfig } from "../../src/claude/desktop-3p"; +import { desktopVisibleNativeSlugs, type CatalogModel } from "../../src/codex/catalog"; import { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } from "../../src/codex/desired-state"; import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; -import { IntegrationMutationBusyError, runIntegrationMutationFlight } from "../../src/integrations/mutation-flight"; +import { IntegrationMutationBusyError, runIntegrationMutationFlight, setIntegrationMutationFlightTestHook } from "../../src/integrations/mutation-flight"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import * as asideProfiles from "../../src/integrations/aside-profiles"; import { refreshOwnedIntegration } from "../../src/integrations/owned-refresh"; +import * as ownedRefresh from "../../src/integrations/owned-refresh"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import type { IntegrationWriterLockSeams } from "../../src/integrations/writer-lock"; import { applyIntegration, disableIntegrationCoordinated } from "../../src/integrations/writer"; import type { OcxConfig } from "../../src/types"; +import { syncEnabledClientIntegrations } from "../../src/server/management/config-routes"; import { removeTreeWithRetry } from "../helpers/remove-tree"; /** @@ -59,22 +65,144 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('clientId: "mcode"'); - expect(fn).toContain("refreshOwnedIntegration"); - // One catch per client: a broken client file is a warning, not a 500 on a command whose - // main job (the Codex catalog) succeeded. - expect(fn.match(/catch \(error\)/g)?.length).toBe(3); + expect(fn).toContain('["mcode", "pi", "aside"]'); + expect(fn).toContain("refreshOwnedCatalogIntegrations"); + // Native clients keep their catches; the owned catalog helper isolates file clients. + expect(fn.match(/catch \(error\)/g)?.length).toBe(2); // The Desktop write gets the native context limits, same as every other Desktop // call site. 8b672205e threaded `nativeContextLimits` through those writers and // left this assertion naming the retired `providerContextCap` spelling, so the // source-shape check failed against the very change it is meant to pin. - expect(fn).toContain("nativeContextLimits(config)"); + expect(fn).toContain("nativeContextLimits(latest)"); // A client that is off is omitted rather than reported: the caller has to be able to // tell "left alone" from "tried and failed", so there is no skipped state to emit. expect(fn).not.toContain('"skipped"'); }); }); +describe("Desktop sync rechecks persisted state after discovery", () => { + let root: string; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-desktop-sync-refresh-")); + process.env.OPENCODEX_HOME = root; + }); + + afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(root); + }); + + for (const outcome of ["off", "refresh", "refusal"] as const) { + test(`${outcome} during discovery preserves fresh Desktop state and MCode fan-out`, async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "mock", + clientIntegrations: { grok: false }, + providers: { + mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["keep", "hidden"] }, + openai: { adapter: "openai-responses", baseUrl: "https://example.test/v1", contextWindow: 400_000 }, + }, + apiKeys: [{ id: "sync-key", name: "fixture", key: "ocx_old_sync_fixture", createdAt: "2026-01-01T00:00:00.000Z" }], + providerContextCaps: { openai: 272_000 }, + claudeCode: { desktopProfile: { + version: 1, + assignments: { "mock/hidden": { family: "opus", alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "mock/hidden", fable: null, sonnet: null, haiku: null }, + } }, + }; + const nativeToDisable = desktopVisibleNativeSlugs(config)[0]; + expect(nativeToDisable).toBeDefined(); + writeFileSync(join(root, "config.json"), JSON.stringify(config)); + const models: CatalogModel[] = [ + { provider: "mock", id: "keep", contextWindow: 123_000 }, + { provider: "mock", id: "hidden", contextWindow: 456_000 }, + ]; + let releaseDiscovery!: () => void; + let announceDiscovery!: () => void; + const discoveryGate = new Promise(resolve => { releaseDiscovery = resolve; }); + const discoveryStarted = new Promise(resolve => { announceDiscovery = resolve; }); + const writes: Parameters[] = []; + // The shared refresh now also receives Pi. Stub only MCode so peers retain + // their real unowned-client behavior instead of manufacturing MCode results. + const realRefresh = ownedRefresh.refreshOwnedIntegration; + const refresh = spyOn(ownedRefresh, "refreshOwnedIntegration").mockImplementation((input, options) => + input.clientId === "mcode" + ? Promise.resolve({ client: "mcode", ok: true, changed: true }) + : realRefresh(input, options)); + const aside = spyOn(asideProfiles, "refreshAsideProfiles"); + const sync = syncEnabledClientIntegrations(12345, config, { + fetchAllModels: async () => { + announceDiscovery(); + await discoveryGate; + return models; + }, + writeDesktop3pConfig: (...args) => { + writes.push(args); + return outcome === "refusal" + ? { written: false, path: "fixture", reason: "desktop_remote_store_active" } + : { written: true, path: "fixture" }; + }, + }); + try { + await Promise.race([ + discoveryStarted, + sync.then(() => { throw new Error("sync ended without entering Desktop discovery"); }), + ]); + const latest = structuredClone(config); + latest.clientIntegrations = { grok: false, "claude-desktop": outcome !== "off" }; + latest.disabledModels = ["mock/hidden", nativeToDisable!]; + latest.apiKeys![0]!.key = "ocx_new_sync_fixture"; + latest.providerContextCaps = { openai: 922_000 }; + latest.providers.openai!.contextWindow = 1_000_000; + latest.claudeCode!.desktopProfile = { + version: 1, + assignments: { "mock/keep": { family: "sonnet", alias: "claude-opus-4-8-20260202" } }, + defaults: { opus: null, fable: null, sonnet: "mock/keep", haiku: null }, + }; + writeFileSync(join(root, "config.json"), JSON.stringify(latest)); + releaseDiscovery(); + const results = await sync; + const mcodeCalls = refresh.mock.calls.filter(([input]) => input.clientId === "mcode"); + expect(mcodeCalls).toHaveLength(1); + expect(mcodeCalls[0]![0]).toMatchObject({ clientId: "mcode", port: 12345 }); + expect(refresh.mock.calls.filter(([input]) => input.clientId === "pi")).toHaveLength(1); + expect(aside).toHaveBeenCalledTimes(1); + expect(aside.mock.calls[0]![0]).toMatchObject({ config, port: 12345 }); + expect(await aside.mock.results[0]!.value).toEqual([]); + expect(results.filter(result => result.client === "mcode")) + .toEqual([{ client: "mcode", ok: true, changed: true }]); + expect(results.filter(result => result.client === "pi" || result.client === "aside")).toEqual([]); + if (outcome === "off") { + expect(writes).toHaveLength(0); + expect(results).toEqual([{ client: "mcode", ok: true, changed: true }]); + } else { + expect(writes).toHaveLength(1); + const [port, natives, routed, key, mode, profile, limits] = writes[0]!; + expect(port).toBe(12345); + expect(natives).not.toContain(nativeToDisable); + expect(routed).toEqual([{ provider: "mock", id: "keep", contextWindow: 123_000 }]); + expect(key).toBe("ocx_new_sync_fixture"); + expect(mode).toBe("static"); + expect(profile).toEqual(latest.claudeCode!.desktopProfile); + expect(limits).toEqual({ cap: 922_000, providerWindow: 1_000_000 }); + expect(results.find(result => result.client === "claude-desktop")).toEqual(outcome === "refusal" + ? { client: "claude-desktop", ok: false, reason: "desktop_remote_store_active" } + : { client: "claude-desktop", ok: true, changed: true }); + } + } finally { + releaseDiscovery(); + await sync.catch(() => undefined); + refresh.mockRestore(); + aside.mockRestore(); + } + }); + } +}); + describe("ocx sync refreshes an already-owned MCode integration", () => { const env = {} as NodeJS.ProcessEnv; const config = { @@ -160,6 +288,23 @@ describe("ocx sync refreshes an already-owned MCode integration", () => { expect(store.listOperations("mcode")).toHaveLength(0); }); + test("retains recovery details when refresh bookkeeping and compensation both fail", async () => { + expect(applyIntegration(input(oldModels)).ok).toBe(true); + const io = store.io(); + let writes = 0; + const result = await refreshOwnedIntegration({ ...input(newModels), io: { + ...io, + writeText(path, text) { + if (path === configPath && ++writes > 1) throw new Error("synthetic rollback failure"); + io.writeText(path, text); + }, + putRecord() { throw new Error("synthetic ownership failure"); }, + } }); + expect(result).toMatchObject({ client: "mcode", ok: false, refusalReason: "write_failed", residual: true }); + expect(result?.snapshotPath).toBeString(); + expect(result?.reason).toContain("could not be rolled back"); + }); + test("refuses a foreign edit without changing bytes or appending a journal row", async () => { expect(applyIntegration(input(oldModels)).ok).toBe(true); const recordBefore = JSON.stringify(store.readRecords().mcode); @@ -302,17 +447,222 @@ describe("ocx sync refreshes an already-owned MCode integration", () => { }); }); -test("the direct ocx sync command refreshes MCode instead of relying on /api/sync", async () => { +describe("owned Pi/Aside catalogs follow filtered model selections", () => { + const clients = ["pi", "aside"] as const; + const env: NodeJS.ProcessEnv = {}; + const config = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as OcxConfig; + const oldModels: ExportModel[] = [ + { namespaced: "mock/visible", provider: "mock", id: "visible", contextWindow: 128_000 }, + { namespaced: "mock/hidden", provider: "mock", id: "hidden", contextWindow: 64_000 }, + ]; + const filteredModels = oldModels.slice(0, 1); + const sibling = { baseUrl: "http://user.invalid/v1", models: [{ id: "personal" }] }; + let root: string; + let home: string; + let store: IntegrationStateStore; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-owned-catalog-refresh-")); + home = join(root, "home"); + store = createIntegrationStateStore(join(root, "state", "integrations")); + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 0 })); + for (const client of clients) { + mkdirSync(INTEGRATION_CLIENTS[client].detectDir(env, home), { recursive: true }); + mkdirSync(dirname(INTEGRATION_CLIENTS[client].configPath(env, home)), { recursive: true }); + writeFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), JSON.stringify({ + theme: "dark", providers: { personal: sibling }, + })); + } + }); + + afterEach(() => { + removeTreeWithRetry(root); + }); + + function input(models: readonly ExportModel[] | (() => Promise)) { + return { models, config, port: 10100, env, home, store }; + } + + function document(client: typeof clients[number]) { + return JSON.parse(readFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), "utf8")) as { + theme: string; + providers: { + personal: typeof sibling; + opencodex?: { baseUrl: string; api: string; apiKey: string; models: Array<{ id: string }> }; + }; + }; + } + + test("refreshes both owned catalogs from one lazy load and preserves unrelated settings", async () => { + for (const clientId of clients) { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)) + .toEqual(["mock/hidden", "mock/visible"]); + } + let loads = 0; + const outcomes = await refreshOwnedCatalogIntegrations(input(async () => { + loads += 1; + return filteredModels; + })); + expect(outcomes).toEqual(clients.map(client => ({ client, ok: true, changed: true, ...(client === "aside" ? { profileId: 0 } : {}) }))); + expect(loads).toBe(1); + for (const client of clients) { + expect(document(client)).toMatchObject({ theme: "dark", providers: { personal: sibling } }); + expect(document(client).providers.opencodex).toMatchObject({ + baseUrl: "http://127.0.0.1:10100/v1", api: "openai-completions", apiKey: "opencodex-loopback", + }); + expect(document(client).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations(client).map(row => row.kind)).toEqual(["refresh", "apply"]); + } + }); + + test("never loads or writes unowned manual catalogs", async () => { + const before = JSON.stringify({ providers: { personal: sibling, opencodex: { models: [{ id: "manual" }] } } }); + for (const client of clients) writeFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), before); + let loads = 0; + const outcomes = await refreshOwnedCatalogIntegrations(input(async () => { + loads += 1; + return filteredModels; + })); + expect(outcomes).toEqual([]); + expect(loads).toBe(0); + for (const client of clients) { + expect(readFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), "utf8")).toBe(before); + } + expect(store.readRecords()).toEqual({}); + expect(store.listOperations()).toEqual([]); + expect(existsSync(store.root)).toBe(false); + }); + + test.each(clients)("does not reconnect a removed %s block", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const before = JSON.stringify({ theme: "dark", providers: { personal: sibling } }); + const path = INTEGRATION_CLIENTS[clientId].configPath(env, home); + writeFileSync(path, before); + expect(await refreshOwnedCatalogIntegrations(input(filteredModels))).toEqual([{ + client: clientId, ok: true, changed: false, + ...(clientId === "aside" ? { profileId: 0 } : {}), + reason: "managed block is absent; refresh did not reconnect it", + }]); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test.each(clients)("does not recreate an uninstalled %s client", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const detectDir = INTEGRATION_CLIENTS[clientId].detectDir(env, home); + removeTreeWithRetry(detectDir); + const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels)); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); + expect(outcomes[0]?.reason).toContain(`${clientId} is not installed`); + expect(existsSync(detectDir)).toBe(false); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test.each(clients)("preserves a drifted %s provider and its ownership record", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const edited = document(clientId); + edited.providers.opencodex!.baseUrl = "http://user-edited.invalid/v1"; + const before = JSON.stringify(edited); + const path = INTEGRATION_CLIENTS[clientId].configPath(env, home); + writeFileSync(path, before); + const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels)); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); + expect(outcomes[0]?.reason).toContain("changed after opencodex wrote it"); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test("a thrown Pi filesystem error does not prevent the owned Aside refresh", async () => { + for (const clientId of clients) expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const path = INTEGRATION_CLIENTS.pi.configPath(env, home); + const before = readFileSync(path, "utf8"); + const recordBefore = store.readRecords().pi; + const io = store.io(); + const outcomes = await refreshOwnedCatalogIntegrations({ + ...input(filteredModels), + io: { ...io, statKind: candidate => { + if (candidate === path) throw new Error("synthetic Pi stat failure"); + return io.statKind(candidate); + } }, + }); + expect(outcomes).toEqual([ + { client: "pi", ok: false, reason: "synthetic Pi stat failure" }, + { client: "aside", profileId: 0, ok: true, changed: true }, + ]); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords().pi).toEqual(recordBefore); + expect(store.listOperations("pi").map(row => row.kind)).toEqual(["apply"]); + expect(document("aside").providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations("aside").map(row => row.kind)).toEqual(["refresh", "apply"]); + }); + + test.each(clients)("overlapping %s selections report busy and a later retry applies the new roster", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const nextModels = oldModels.slice(1); + let release!: () => void; + let observeFirst!: () => void; + let observeSecond!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { observeFirst = resolve; }); + const contended = new Promise(resolve => { observeSecond = resolve; }); + setIntegrationMutationFlightTestHook(async operation => { + observeFirst(); + await gate; + return operation(); + }); + const first = refreshOwnedCatalogIntegrations(input(filteredModels), [clientId]); + let second: ReturnType | undefined; + try { + await started; + second = refreshOwnedCatalogIntegrations({ + ...input(nextModels), + io: { ...store.io(), now: () => { observeSecond(); return Date.now(); } }, + }, [clientId]); + await contended; + release(); + expect(await first).toEqual([{ client: clientId, ok: true, changed: true, ...(clientId === "aside" ? { profileId: 0 } : {}) }]); + expect(await second).toEqual([{ client: clientId, ok: false, reason: "integration_mutation_busy" }]); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "apply"]); + } finally { + release(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + setIntegrationMutationFlightTestHook(null); + } + expect(await refreshOwnedCatalogIntegrations(input(nextModels), [clientId])) + .toEqual([{ client: clientId, ok: true, changed: true, ...(clientId === "aside" ? { profileId: 0 } : {}) }]); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/hidden"]); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "refresh", "apply"]); + }); +}); + +test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relying on /api/sync", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); - expect(command).toContain("refreshOwnedIntegration"); - expect(command).toContain('clientId: "mcode"'); - expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedIntegration")); + expect(command).toContain("refreshOwnedCatalogIntegrations"); + expect(command).toContain('["mcode", "pi"]'); + expect(command).toContain("refreshAsideProfilesThroughServer"); + expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); -test("refresh joins refresh but cannot swallow an explicit apply or disable", async () => { +test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); let refreshRuns = 0; diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts new file mode 100644 index 0000000000..e0fd0e1fb6 --- /dev/null +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -0,0 +1,446 @@ +import { routedSlug } from "../../src/providers/slug-codec"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; +import type { OcxConfig } from "../../src/types"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { convergeCodexCatalog } from "../../src/codex/convergence"; +import { loadBundledCodexCatalog, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../../src/codex/catalog"; +import type { RawCatalog, RawEntry } from "../../src/codex/catalog/parsing"; +import { clearModelCache, markModelsFetchFailure } from "../../src/codex/model-cache"; +import { loadPersistedCodexRuntime, resetCodexRuntimeResolveCacheForTests, resolveCodexRuntime } from "../../src/codex/runtime"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { buildCatalogEntries, effectiveSubagentRoster } from "../../src/codex/catalog/sync"; +import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; + +test("native-first picker order preserves Go subagent ranks and is repeatable", () => { + const rows: any[] = [ + { slug: "opencode-go/glm-5.3", priority: 0 }, + { slug: "gpt-5.6-sol", priority: 9 }, + { slug: "gpt-6-astra", priority: 9 }, + ]; + const order = ["gpt-6-astra", "gpt-5.6-sol", "opencode-go/glm-5.3"]; + applyFullModelPickerOrder(rows, order); + expect([...rows].sort((a,b) => a.priority-b.priority).map(r => r.slug)).toEqual(order); + expect(rows.map(r => r[SPAWN_PRIORITY_FIELD])).toEqual([0,9,9]); + const once = structuredClone(rows); + applyFullModelPickerOrder(rows, order); + expect(rows).toEqual(once); +}); + +test("existing routed-only ordering retains its behavior", () => { + const rows: any[] = [{ slug: "opencode-go/glm-5.3", priority: 1000 }]; + applyFullModelPickerOrder(rows, ["opencode-go/glm-5.3"]); + expect(rows).toEqual([{ slug: "opencode-go/glm-5.3", priority: 1000 }]); +}); + +test("full picker helper treats a null passthrough order as absent", () => { + const rows = [{ slug: "gpt-5.5", priority: 9 }, { slug: "opencode-go/model", priority: 0 }]; + const before = structuredClone(rows); + // Production callers coalesce null; the exported boundary must also tolerate it directly. + applyFullModelPickerOrder(rows, null as unknown as readonly string[]); + expect(rows).toEqual(before); +}); + + +test("sync refreshes native spawn rank when featured models change", () => { + const sol = deriveEntry(null, "gpt-5.6-sol", "Sol", 105); + const order = ["gpt-5.6-sol"]; + applyFullModelPickerOrder([sol], order); + expect(sol[SPAWN_PRIORITY_FIELD]).toBe(105); + + const baseline = new Map([["gpt-5.6-sol", 9]]); + const promoted = mergeCatalogEntriesForSync([sol], [], baseline, ["gpt-5.6-sol"], false); + applyFullModelPickerOrder(promoted, order); + expect(promoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(0); + + const demoted = mergeCatalogEntriesForSync(promoted, [], baseline, ["opencode-go/glm-5.3"], false); + applyFullModelPickerOrder(demoted, order); + expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(101); +}); + +test("fresh routed rows do not inherit a previously ordered native template's guidance rank", () => { + const ids = ["fresh-a", "fresh-b", "fresh-c", "fresh-d", "fresh-e", "fresh-f"]; + const slugs = ids.map(id => routedSlug("opencode-go", id)); + const featured = slugs.slice(0, 5); + const order = ["gpt-5.5", slugs[5]!, ...featured.toReversed()]; + const template = deriveEntry(null, "gpt-5.5", "Previously ordered native", 0); + template[SPAWN_PRIORITY_FIELD] = 9; + const previousTemplate = structuredClone(template); + const rows = buildCatalogEntriesFromObservedState({ + template, + gptSlugs: ["gpt-5.5"], + goModels: ids.map(id => ({ + provider: "opencode-go", id, + reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", + })), + featured, modelPickerOrder: order, + wsEnabled: false, multiAgentMode: "v2", multiAgentV2Enabled: true, + exactComboSlugs: new Set(), accountSelectors: [], + suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), + }); + + const featuredRows = featured.map(slug => rows.find(row => row.slug === slug)!); + expect(featuredRows.map(row => row[SPAWN_PRIORITY_FIELD] ?? row.priority)).toEqual([0, 1, 2, 3, 4]); + const expectedCandidates = featured.map(model => ({ model, efforts: ["high", "xhigh"] })); + const before = effectiveSubagentRoster(featured, "v2", rows); + expect(before.candidates).toEqual(expectedCandidates); + expect(before.advertised).toEqual(expectedCandidates); + + applyFullModelPickerOrder(rows, order); + expect(effectiveSubagentRoster(featured, "v2", rows)).toEqual(before); + expect(rows.toSorted((a, b) => Number(a.priority) - Number(b.priority)).map(row => row.slug)).toEqual(order); + expect(template).toEqual(previousTemplate); +}); + + +test("bare native ids and routed slugs match exactly, without suffix aliases", () => { + const rows: any[] = [ + { slug: "openai/gpt-5.6-sol", priority: 2 }, + { slug: "gpt-5.6-sol", priority: 9 }, + { slug: "other/gpt-5.6-sol", priority: 3 }, + ]; + applyFullModelPickerOrder(rows, ["gpt-5.6-sol", "openai/gpt-5.6-sol"]); + expect(rows.map(row => row.priority)).toEqual([1, 0, 5]); + expect(rows.map(row => row[SPAWN_PRIORITY_FIELD])).toEqual([2, 9, 3]); +}); + +test.each([ + { order: [] as string[] }, + { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], after: ["opencode-go/glm-5.3"] }, + { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], before: ["opencode-go/glm-5.3"], after: [] }, + { order: ["gpt-5.6-sol", "opencode-go/team/model"], modelId: "team/model", before: ["other/model", "opencode-go/team/model"], after: ["opencode-go/team/model", "other/model"] }, + + { order: ["", "opencode-go/glm-5.3"] }, + { order: [" ", "opencode-go/glm-5.3"] }, + { order: [""] }, + { order: ["opencode-go/team/model"], modelId: "team/model" }, + { order: ["opencode-go/glm-5.3"] }, + { order: ["other/model", "opencode-go/glm-5.3"] }, +])("degraded discovery refreshes ranks and remains stable for %j", ({ order, modelId = "glm-5.3", before = [], after = [] }) => { + for (const accountSelectors of [[], ["account-a", "account-b"]]) { + const slug = routedSlug("opencode-go", modelId); + const fresh = (modelPickerOrder: readonly string[], featured: readonly string[] = []) => buildCatalogEntriesFromObservedState({ + template: null, gptSlugs: [], + goModels: [{ id: modelId, provider: "opencode-go", displayName: "GLM 5.3", reasoningEfforts: ["high", "max"] }], + featured, modelPickerOrder, wsEnabled: false, multiAgentMode: "default", + exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), + disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, + }); + const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: readonly string[], degraded: boolean, featured: readonly string[] = []) => + mergeCatalogEntriesFromObservedState({ + catalogModels, routedEntries, modelPickerOrder, accountSelectors, + baselineCatalogModels: [], baseline: new Map(), featured, wsEnabled: false, + template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(["opencode-go"]), + degradedProviderNames: new Set(degraded ? ["opencode-go"] : []), + legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, + exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true, + accountBoundEntries: [], + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, + }); + const fullOrder = ["gpt-5.6-sol", slug]; + const previous = merge([], fresh(fullOrder, before), fullOrder, false, before); + const saved = structuredClone(previous); + const healthy = merge(previous, fresh(order, after), order, false, after); + const degraded = merge(previous, [], order, true, after); + const row = (entries: Record[]) => entries.find(entry => entry.slug === slug)!; + expect(row(degraded).priority).toBe(row(healthy).priority); + expect(row(degraded)[SPAWN_PRIORITY_FIELD]).toBe(row(healthy)[SPAWN_PRIORITY_FIELD]); + expect(merge(degraded, [], order, true, after)).toEqual(degraded); + expect(previous).toEqual(saved); + } +}); + + +test("full ordering ignores empty entries and accepts raw upstream ids with slashes", () => { + const slug = routedSlug("vendor", "team/model"); + const rows = [{ slug, priority: 1000 }, { slug: "gpt-5.6-sol", priority: 9 }]; + applyFullModelPickerOrder(rows, ["", "gpt-5.6-sol", "vendor/team/model"]); + expect(rows.map(row => row.priority)).toEqual([1, 0]); + const exact = [{ slug, priority: 5 }]; + applyFullModelPickerOrder(exact, ["gpt-5.6-sol", slug, "vendor/team/model"]); + expect(exact[0]!.priority).toBe(1); +}); + +describe("picker ordering through production catalog writers", () => { + const ids = ["ordering-a", "ordering-b", "ordering-c", "ordering-d", "ordering-e", "ordering-f"]; + const slugs = ids.map(id => routedSlug("opencode-go", id)); + const configuredEfforts = ["high", "xhigh"]; + const envKeys = ["CODEX_HOME", "OPENCODEX_HOME", "CODEX_CLI_PATH"] as const; + let previousEnv: Array; + let previousFetch: typeof fetch; + let root: string; + let codexHome: string; + let catalogPath: string; + let fetchCalls: number; + let runtimeCommand: string; + + // Same executable-fixture protocol as codex-convergence-account-selectors.test.ts: + // a forced resolver refresh must receive the same version and catalog as a warm read. + function createRuntimeFixture(catalog: RawCatalog): string { + const script = join(root, "fixture-codex.js"); + writeFileSync(script, [ + 'if (process.argv.includes("--version")) {', + ' console.log("codex-cli 0.145.0");', + '} else {', + ` process.stdout.write(${JSON.stringify(JSON.stringify(catalog))});`, + '}', + ].join("\n")); + if (process.platform === "win32") { + const command = join(root, "fixture-codex.cmd"); + writeFileSync(command, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + return command; + } + const command = join(root, "fixture-codex"); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(command, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`); + chmodSync(command, 0o755); + return command; + } + + function assertRuntimeIdentity(): void { + const resolved = resolveCodexRuntime({ discoverAlternatives: false }); + expect(resolved.runtime.command).toBe(runtimeCommand); + expect(resolved.runtime.version).toBe("0.145.0"); + const persisted = loadPersistedCodexRuntime(); + expect(persisted?.command).toBe(runtimeCommand); + expect(persisted?.selectedVersion).toBe("0.145.0"); + } + + beforeEach(() => { + previousEnv = envKeys.map(key => process.env[key]); + previousFetch = globalThis.fetch; + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-picker-writers-"))); + codexHome = join(root, "codex"); + const opencodexHome = join(root, "ocx"); + mkdirSync(codexHome); + mkdirSync(opencodexHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync(join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n[features]\nmulti_agent_v2 = true\n'); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + const native = deriveEntry(null, "gpt-5.5", "Native fixture", 9); + const catalog = { models: [native] }; + runtimeCommand = createRuntimeFixture(catalog); + process.env.CODEX_CLI_PATH = runtimeCommand; + // Resolve the real fixture executable before admission captures runtime provenance. + expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); + assertRuntimeIdentity(); + writeFileSync(catalogPath, JSON.stringify(catalog)); + fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("catalog writer fixture must not make a network request"); + }) as typeof fetch; + }); + + afterEach(() => { + try { + const database = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${database}${suffix}`, { force: true }); + } finally { + globalThis.fetch = previousFetch; + envKeys.forEach((key, index) => { + const value = previousEnv[index]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + removeTreeWithRetry(root); + } + }); + + function config(featured = slugs.slice(0, 5), order: string[] = []): OcxConfig { + return { + port: 10100, + defaultProvider: "opencode-go", + multiAgentMode: "v2", + subagentModels: featured, + subagentModelsVersion: SUBAGENT_MODELS_VERSION, + modelPickerOrder: order, + providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, + "opencode-go": { + adapter: "openai-chat", baseUrl: "https://catalog-fixture.invalid/v1", authMode: "key", + apiKey: "ordering-fixture-key", liveModels: false, models: [...ids], + modelReasoningEfforts: Object.fromEntries(ids.map(id => [id, [...configuredEfforts]])), + modelDefaultReasoningEfforts: Object.fromEntries(ids.map(id => [id, "xhigh"])), + }, + }, + }; + } + + async function writeCatalog(writer: "convergence" | "retained", next: OcxConfig, degraded = false): Promise { + assertRuntimeIdentity(); + const requestedRoster = [...next.subagentModels!]; + saveConfig(next); + const saved = loadConfig(); + expect(saved.subagentModelsVersion).toBe(SUBAGENT_MODELS_VERSION); + expect(saved.subagentModels).toEqual(requestedRoster); + expect(next.subagentModels).toEqual(requestedRoster); + if (degraded) { + // Preserve disk rows without a discovery cache; built-in metadata augmentation still runs. + clearModelCache("opencode-go"); + markModelsFetchFailure("opencode-go"); + } + if (writer === "convergence") { + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(next), { + action: "converge", scope: "catalog", reason: "management-mutation", mode: "explicit", deadlineMs: 5_000, + }); + expect(result.catalogRefresh).toMatchObject({ status: "committed", degraded }); + } else { + const result = await syncCatalogModels(next); + expect(result.path).toBe(catalogPath); + expect(result.skippedReason).toBeUndefined(); + } + assertRuntimeIdentity(); + expect(fetchCalls).toBe(0); + return (JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog).models ?? []; + } + + function roster(rows: RawEntry[], featured: string[]) { + const beforeAssertions = JSON.stringify(rows); + const result = effectiveSubagentRoster(featured, "v2", rows); + expect(result.candidates.map(candidate => candidate.model)).toEqual(featured); + expect(result.candidates).toHaveLength(5); + expect(result.advertised).toEqual(result.candidates); + for (const candidate of result.candidates) expect(candidate.efforts).toEqual(configuredEfforts); + for (const slug of slugs) { + const row = rows.find(row => row.slug === slug); + expect(row).toBeDefined(); + expect(row!.default_reasoning_level).toBe("xhigh"); + const levels = row!.supported_reasoning_levels; + expect(Array.isArray(levels)).toBe(true); + expect((levels as Array<{ effort: string }>).map(level => level.effort)).toEqual(configuredEfforts); + } + // Full catalog comparisons below must still compare untouched metadata, not matcher nodes. + expect(JSON.stringify(rows)).toBe(beforeAssertions); + return result; + } + + for (const writer of ["convergence", "retained"] as const) { + const filteredOrder = ["gpt-5.5", slugs[5]!, ...slugs.slice(0, 5).reverse(), " gpt-5.5 "]; + const malformedOrders: Array<{ label: string; input: unknown; filtered: string[] }> = [ + { label: "string scalar", input: "gpt-5.5", filtered: [] }, + { label: "number scalar", input: 7, filtered: [] }, + { label: "boolean scalar", input: true, filtered: [] }, + { label: "object", input: { 0: "gpt-5.5", length: 1 }, filtered: [] }, + { + label: "mixed array", + input: [null, 7, "", " \t", filteredOrder[0], false, filteredOrder[1], {}, ...filteredOrder.slice(2)], + // Significant surrounding whitespace remains part of the original spelling. + filtered: filteredOrder, + }, + ]; + + test.each(malformedOrders)(`${writer} tolerates $label passthrough order in healthy and retained discovery`, async ({ input, filtered }) => { + const control = config(slugs.slice(0, 5), filtered); + const expected = await writeCatalog(writer, control); + if (filtered.length > 0) { + // Trimming the final nonblank string would incorrectly override the native rank. + expect(expected.find(row => row.slug === "gpt-5.5")?.priority).toBe(0); + } + const expectedRoster = roster(expected, control.subagentModels!); + // Model configuration is passthrough at runtime; exercise the writers, not the normalizer. + const malformed = Object.assign(config(control.subagentModels), { modelPickerOrder: input }); + const actual = await writeCatalog(writer, malformed); + expect(actual).toEqual(expected); + expect(roster(actual, control.subagentModels!)).toEqual(expectedRoster); + + const priorCatalog = readFileSync(catalogPath); + const cachePath = join(codexHome, "models_cache.json"); + const priorCache = existsSync(cachePath) ? readFileSync(cachePath) : null; + const restoreSeed = () => { + writeFileSync(catalogPath, priorCatalog); + if (priorCache === null) rmSync(cachePath, { force: true }); + else writeFileSync(cachePath, priorCache); + }; + const retainedControl = config(control.subagentModels, filtered); + const retainedMalformed = Object.assign(config(control.subagentModels), { modelPickerOrder: input }); + for (const candidate of [retainedControl, retainedMalformed]) { + candidate.providers["opencode-go"]!.liveModels = true; + candidate.providers["opencode-go"]!.models = []; + } + // Both sides activate identical Go metadata augmentation and failure/cooldown state. + // Only the malformed order differs; a static healthy catalog is not this counterfactual. + restoreSeed(); + const expectedRetained = await writeCatalog(writer, retainedControl, true); + const expectedRetainedRoster = roster(expectedRetained, control.subagentModels!); + expect(expectedRetainedRoster).toEqual(expectedRoster); + restoreSeed(); + const retained = await writeCatalog(writer, retainedMalformed, true); + expect(retained).toEqual(expectedRetained); + expect(roster(retained, control.subagentModels!)).toEqual(expectedRetainedRoster); + }, 30_000); + + test(`${writer} applies full display order without changing five eligible Go candidates`, async () => { + const initial = config(); + const before = roster(await writeCatalog(writer, initial), initial.subagentModels!); + // Bring the sixth routed model above every featured model in the display. + const order = ["gpt-5.5", slugs[5]!, ...slugs.slice(0, 5).reverse()]; + const ordered = config(initial.subagentModels, order); + const rows = await writeCatalog(writer, ordered); + expect(rows.filter(row => order.includes(String(row.slug))) + .sort((a, b) => Number(a.priority) - Number(b.priority)).map(row => row.slug)).toEqual(order); + expect(roster(rows, ordered.subagentModels!)).toEqual(before); + expect(roster(await writeCatalog(writer, ordered), ordered.subagentModels!)).toEqual(before); + }, 30_000); + + test(`${writer} refreshes retained outage ranks after a full-picker and featured-roster change`, async () => { + const previous = await writeCatalog(writer, config(slugs.slice(0, 5), ["gpt-5.5", ...slugs])); + for (const row of previous) { + if (slugs.includes(String(row.slug))) row.ordering_retained_fixture = true; + } + // Promote the formerly excluded sixth model, demote the first, and clear full ordering. + const featured = slugs.slice(1).reverse(); + const next = config(featured, [slugs[0]!]); + const healthy = await writeCatalog(writer, next); + const expectedRoster = roster(healthy, featured); + writeFileSync(catalogPath, JSON.stringify({ models: previous })); + next.providers["opencode-go"]!.liveModels = true; + next.providers["opencode-go"]!.models = []; + const retained = await writeCatalog(writer, next, true); + expect(roster(retained, featured)).toEqual(expectedRoster); + for (const slug of slugs) { + const actual = retained.find(row => row.slug === slug)!; + const expected = healthy.find(row => row.slug === slug)!; + expect(actual.ordering_retained_fixture).toBe(true); + expect(actual.priority).toBe(expected.priority); + expect(actual[SPAWN_PRIORITY_FIELD]).toBe(expected[SPAWN_PRIORITY_FIELD]); + } + expect(await writeCatalog(writer, next, true)).toEqual(retained); + }, 30_000); + } +}); + + +test("public catalog wrapper applies saved full order while preserving guidance ranks", () => { + const routed = ["a", "b", "c", "d", "e", "f"].map(id => ({ provider: "p", id })); + const featured = ["p/a", "p/b", "p/c", "p/d", "p/e"]; + const build = (order: string[]) => buildCatalogEntries( + null, ["gpt-5.5"], routed, featured, false, "default", new Set(), [], + new Set(), new Set(), undefined, undefined, undefined, false, order, + ); + const natural = build([]); + const order = ["gpt-5.5", "p/f", "p/e", "p/d", "p/c", "p/b", "p/a"]; + const ordered = build(order); + expect(ordered.toSorted((a, b) => Number(a.priority) - Number(b.priority)).map(row => row.slug)).toEqual(order); + expect(effectiveSubagentRoster(featured, "v1", ordered)).toEqual(effectiveSubagentRoster(featured, "v1", natural)); + // Independently mirror the upstream description's visible-priority window, not the OCX helper. + const nativeDescription = ordered.toSorted((a, b) => Number(a.priority) - Number(b.priority)) + .filter(row => row.visibility === "list").slice(0, 5).map(row => row.slug); + expect(nativeDescription).toEqual(["gpt-5.5", "p/f", "p/e", "p/d", "p/c"]); +}); diff --git a/tests/codex-integration/catalog-go-exact-efforts.test.ts b/tests/codex-integration/catalog-go-exact-efforts.test.ts new file mode 100644 index 0000000000..5fa4da816b --- /dev/null +++ b/tests/codex-integration/catalog-go-exact-efforts.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { deriveEntry, mergeCatalogEntriesForSync } from "../../src/codex/catalog/sync"; + +for (const template of [null, { slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "ultra" }] }]) { + test(`Go preserves exact configured efforts (${template ? "template" : "fallback"})`, () => { + for (const [id, efforts] of [ + ["glm-5.3", ["high", "max"]], + ["glm-5.3-flash", ["high", "max"]], + ["omen-alpha", ["high", "max"]], + ["deepseek-v4-flash-vision-exp", ["high", "max"]], + ["muse-spark-1.3-contributor", ["high", "xhigh"]], + ] as const) { + const entry = deriveEntry(template, `opencode-go/${id}`, "Go", 1, { + provider: "opencode-go", id, reasoningEfforts: [...efforts], defaultReasoningEffort: efforts[1], + }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual([...efforts]); + expect(entry.default_reasoning_level).toBe(efforts[1]); + } + }); +} + +test("other providers retain their existing virtual tiers", () => { + const entry = deriveEntry(null, "other/model", "Other", 1, { + provider: "other", id: "model", reasoningEfforts: ["high"], + }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "max", "ultra"]); +}); + +test("sync does not reintroduce max for Muse", () => { + const muse = deriveEntry(null, "opencode-go/muse-spark-1.3-contributor", "Muse", 1, { + provider: "opencode-go", id: "muse-spark-1.3-contributor", + reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", + }); + for (const [disk, fresh] of [[[muse], []], [[], [muse]]]) { + const entries = mergeCatalogEntriesForSync(disk, fresh, new Map(), [], false); + const entry = entries.find(e => e.slug === muse.slug)!; + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "xhigh"]); + } +}); diff --git a/tests/codex-integration/client-injection-guard.test.ts b/tests/codex-integration/client-injection-guard.test.ts new file mode 100644 index 0000000000..829db8a5a0 --- /dev/null +++ b/tests/codex-integration/client-injection-guard.test.ts @@ -0,0 +1,226 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; + +const roots: string[] = []; +const coordinators: string[] = []; +const children: Array<{ kill(signal?: number | NodeJS.Signals): void; exited: Promise; exitCode: number | null }> = []; +const SCRIPT = String.raw` +const fs = require("node:fs"); +const path = require("node:path"); +const { createHash } = require("node:crypto"); +const { Database } = require("bun:sqlite"); +const configApi = require("./src/config"); +const { injectCodexConfig } = require("./src/codex/inject"); +const paths = require("./src/codex/paths"); +const { restoreJournalState } = require("./src/codex/journal"); +const { readCodexTransitionState, openCodexCoordinatorTransaction } = require("./src/codex/transition-state"); +const { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity } = require("./src/codex/user-identity"); +const { withClientLifecycleSync } = require("./src/client/lifecycle-lock"); +const { readDesktopDisconnectReceipt, writeDesktopDisconnectReceipt } = require("./src/claude/desktop-remote-store"); +const mode = process.env.OCX_GUARD_SCENARIO; +const root = process.env.OCX_GUARD_ROOT; +const tokenFingerprint = createHash("sha256").update("test-key").digest("hex"); +const owner = { serverUrl: "https://hub.example.test", apiKeyId: "client-fixture", connectedAt: "2026-01-01T00:00:00.000Z" }; +let guardCalls = 0; +const observations = []; +let nBlocker; +let cBlocker; +function snapshot() { + return Object.fromEntries([paths.CODEX_CONFIG_PATH, paths.CODEX_PROFILE_PATH, path.join(paths.getCodexHome(), "opencodex-journal.json")].map(file => { + if (!fs.existsSync(file)) return [path.basename(file), null]; + const stat = fs.lstatSync(file, { bigint: true }); + return [path.basename(file), { + hash: createHash("sha256").update(fs.readFileSync(file)).digest("hex"), + identity: String(stat.dev) + ":" + String(stat.ino) + ":" + String(stat.mtimeNs) + ":" + String(stat.size), + }]; + })); +} +function claimDisconnect() { + withClientLifecycleSync(held => { + writeDesktopDisconnectReceipt(held, null, { + version: 1, owner, tokenFingerprint, keepCatalog: false, phase: "prepared", + }); + }, { lockPath: path.join(root, "client-lock.sqlite") }); +} +function guard() { + guardCalls++; + if (mode === "async") return Promise.resolve(); + if (mode === "async-reject") return Promise.reject(new Error("guard async rejection")); + if (mode === "external" || mode === "malformed") throw new Error("client_guard_refused"); + const read = readDesktopDisconnectReceipt(); + observations.push(read.kind === "valid" ? read.value.phase : read.kind); + if (read.kind === "valid" && read.value.phase !== "complete") throw new Error("client_guard_refused"); +} +async function invoke() { + try { + const result = await injectCodexConfig(19999, configApi.loadConfig(), { + catalogPath: null, lockTimeoutMs: 5000, + journalOwner: { kind: "client", apiKeyId: owner.apiKeyId }, + routingTarget: { baseUrl: owner.serverUrl + "/v1", requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }, + beforeClientWrite: guard, + }); + return { success: result.success, message: result.message }; + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : String(error) }; + } +} +(async () => { + configApi.withConfigMutationLockSync(() => {}); + const coordinatorPath = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), fs.realpathSync.native(paths.getCodexHome())); + if (mode !== "legacy" && mode !== "external") { + const ready = readCodexTransitionState(); + if (ready.kind !== "ready") throw new Error("coordinator_setup_failed"); + } + if (mode === "queued") { + const seeded = await invoke(); + if (!seeded.success) throw new Error("injected_seed_failed: " + seeded.message); + guardCalls = 0; + observations.length = 0; + } + if (mode === "malformed") fs.writeFileSync(path.join(paths.getCodexHome(), "opencodex-journal.json"), "malformed journal sentinel\n"); + const before = snapshot(); + let contention; + if (mode === "queued" || mode === "queued-native") { + // A real N transaction alone leaves C available for the disconnect claim. + nBlocker = openCodexCoordinatorTransaction(coordinatorPath); + const pending = invoke(); + if (guardCalls !== 0) throw new Error("guard_ran_before_coordinated_commit"); + claimDisconnect(); + const restored = restoreJournalState(); + if (mode === "queued" && !restored.complete) throw new Error("injected_restore_failed"); + const restoredBeforeRelease = snapshot(); + nBlocker.rollback(); nBlocker.close(); nBlocker = undefined; + const result = await pending; + console.log(JSON.stringify({ result, before: restoredBeforeRelease, after: snapshot(), guardCalls, observations, restored })); + return; + } + if (mode === "legacy") { + const databasePath = configApi.prepareConfigMutationDatabasePathForWrite(); + cBlocker = new Database(databasePath, { readwrite: true, create: false }); + cBlocker.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE"); + contention = await invoke(); + const during = snapshot(); + cBlocker.exec("ROLLBACK"); cBlocker.close(); cBlocker = undefined; + if (JSON.stringify(during) !== JSON.stringify(before)) throw new Error("legacy_write_escaped_C"); + if (guardCalls !== 0) throw new Error("legacy_guard_ran_outside_C"); + } + if (mode === "deny" || mode === "legacy") claimDisconnect(); + const result = await invoke(); + let restored; + if (mode === "allow" && result.success) { + claimDisconnect(); + restored = restoreJournalState(); + } + console.log(JSON.stringify({ result, contention, before, after: snapshot(), guardCalls, observations, restored })); +})().catch(error => { + console.log(JSON.stringify({ fatal: error instanceof Error ? error.message : String(error) })); + process.exitCode = 1; +}).finally(() => { + try { nBlocker?.rollback(); nBlocker?.close(); } catch { process.exitCode = 1; } + try { cBlocker?.exec("ROLLBACK"); cBlocker?.close(); } catch { process.exitCode = 1; } +}); +`; + +async function within(promise: Promise, milliseconds: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("injection guard child deadline")), milliseconds); + })]); + } finally { if (timer !== undefined) clearTimeout(timer); } +} +async function runScenario(mode: string): Promise> { + const root = mkdtempSync(join(tmpdir(), "ocx-client-guard-")); roots.push(root); + const codex = join(root, "codex"); const ocx = join(root, "ocx"); const desktop = join(root, "desktop"); + for (const directory of [codex, ocx, desktop]) mkdirSync(directory, { recursive: true }); + const client = { + serverUrl: "https://hub.example.test", managementUrl: "https://hub.example.test", + managementTransport: "direct", selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "client-fixture", + tokenFingerprint: (await import("node:crypto")).createHash("sha256").update("test-key").digest("hex"), + protocolVersion: 1, connectedAt: "2026-01-01T00:00:00.000Z", + }; + writeFileSync(join(ocx, "config.json"), JSON.stringify({ + port: 19999, providers: {}, defaultProvider: "openai", runtimeRole: "client", + client, syncResumeHistory: false, clientIntegrations: { codex: true }, + })); + writeFileSync(join(ocx, "service-api-token"), "test-key\n", { mode: 0o600 }); + writeFileSync(join(codex, "config.toml"), mode === "external" + ? 'model_provider = "user-managed"\n[model_providers.user-managed]\nbase_url = "https://user.example.test/v1"\n' + : 'model = "gpt-5"\n'); + if (mode === "legacy") writeFileSync(join(codex, "opencodex.config.toml"), '# user reference\nmodel = "gpt-5"\n'); + if (mode === "external") writeFileSync(join(codex, "opencodex-journal.json"), "guarded journal sentinel\n"); + coordinators.push(resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(codex))); + const child = Bun.spawn({ + cmd: [process.execPath, "--eval", SCRIPT], cwd: repoRoot(), + env: { + ...process.env, CODEX_HOME: codex, OPENCODEX_HOME: ocx, + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: desktop, + OCX_GUARD_SCENARIO: mode, OCX_GUARD_ROOT: root, + PATH: [dirname(process.execPath), ...(process.platform === "win32" + ? [join(process.env.SystemRoot ?? "C:\\Windows", "System32")] + : ["/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(delimiter), + }, + stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + children.push(child); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).text(); + const code = await within(child.exited, 30_000); + const output = await within(Promise.all([stdout, stderr]), 5_000); + const result = JSON.parse(output[0].trim().split("\n").at(-1) ?? "{}"); + if (code !== 0) throw new Error("injection fixture failed: " + String(result.fatal ?? output[1])); + return result; +} + +afterEach(async () => { + const errors: unknown[] = []; + for (const child of children.splice(0)) { + try { + if (child.exitCode === null) child.kill("SIGKILL"); + await within(child.exited, 5_000); + } catch (error) { errors.push(error); } + } + for (const file of coordinators.splice(0)) for (const suffix of ["", "-journal", "-wal", "-shm"]) { + try { rmSync(file + suffix, { force: true }); } catch (error) { errors.push(error); } + } + for (const root of roots.splice(0)) { + try { removeTreeWithRetry(root); } catch (error) { errors.push(error); } + } + if (errors.length) throw new AggregateError(errors, "injection fixture cleanup failed"); +}, 30_000); + +for (const mode of ["deny", "queued-native", "legacy", "external", "malformed", "async", "async-reject"]) { + test("client commit guard preserves every routing artifact (" + mode + ")", async () => { + const result = await runScenario(mode); + expect(result.result.success).toBe(false); + expect(result.result.message).toContain(mode.startsWith("async") ? "must be synchronous" : "client_guard_refused"); + expect(result.guardCalls).toBe(1); + expect(result.after).toEqual(result.before); + if (mode === "queued-native") expect(result.observations).toContain("prepared"); + if (mode === "legacy") expect(result.contention.success).toBe(false); + }, { timeout: 45_000 }); +} +test("a queued reinjection cannot recreate artifacts after a genuine disconnect restore", async () => { + const result = await runScenario("queued"); + expect(result.result.success).toBe(false); + expect(result.restored.complete).toBe(true); + expect(result.after).toEqual(result.before); + // Restoring artifacts may invalidate N admission before the callback. The + // native queued case above separately proves the receipt guard is reached. + expect(result.guardCalls).toBeLessThanOrEqual(1); +}, { timeout: 45_000 }); +test("an injection committed before the disconnect claim is restored afterward", async () => { + const result = await runScenario("allow"); + expect(result.result.success).toBe(true); + expect(result.guardCalls).toBe(1); + expect(result.restored.complete).toBe(true); + for (const [name, before] of Object.entries(result.before) as Array<[string, any]>) { + expect(result.after[name]?.hash ?? null).toBe(before?.hash ?? null); + } +}, { timeout: 45_000 }); diff --git a/tests/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index c30919edc0..3e49033acc 100644 --- a/tests/codex-integration/codex-account-delete-atomicity.test.ts +++ b/tests/codex-integration/codex-account-delete-atomicity.test.ts @@ -1,8 +1,16 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; +import * as fsModule from "node:fs"; import * as accountStoreModule from "../../src/codex/account-store"; +import * as websocketRegistryModule from "../../src/codex/websocket-registry"; +import * as quotaAutoRefreshStateModule from "../../src/codex/quota-auto-refresh-state"; import { getCodexAccountCredential, saveCodexAccountCredential, @@ -22,15 +30,12 @@ import { } from "../../src/codex/quota"; import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; import * as configModule from "../../src/config"; -import { flushConfigDirHardeningForTests } from "../../src/config/paths"; -import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -let testDir = ""; +const TEST_DIR = join(import.meta.dir, ".tmp-codex-account-delete-atomicity"); const ACCOUNT_ID = "delete-atomicity"; let previousHome: string | undefined; -const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; function seededConfig(): OcxConfig { const config = loadConfig(); @@ -57,33 +62,18 @@ function seededConfig(): OcxConfig { return config; } -function installScratchHome(): void { - // These tests exercise account-delete ordering, not Windows ACL behavior. Stub BOTH runners - // so asynchronous hardening never spawns a real icacls.exe child that can hold the fixture open. - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - testDir = mkdtempSync(join(tmpdir(), "ocx-codex-account-delete-atomicity-")); - process.env.OPENCODEX_HOME = testDir; -} - -async function removeScratchHome(): Promise { - // Settle queued ACL work before restoring env/removing the directory. Windows can hold the - // fixture open until a child exits, and a failed teardown otherwise poisons later tests. - await flushConfigDirHardeningForTests(); - setIcaclsRunnerForTests(null); - setAsyncIcaclsRunnerForTests(null); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - if (testDir) removeTreeWithRetry(testDir); - testDir = ""; -} - beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; - installScratchHome(); + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; }); -afterEach(async () => { await removeScratchHome(); }); +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); describe("Codex account delete persistence ordering", () => { test("a config persistence failure leaves the account and destructive state intact", () => { @@ -152,18 +142,22 @@ describe("Codex account delete persistence ordering", () => { test("a concurrent external edit remains byte-identical after uncertain failure", () => { const config = seededConfig(); const before = structuredClone(config); + let replacementBytes: Buffer | undefined; const realSave = configModule.saveConfigPreservingClaudeCode; const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") .mockImplementation(candidate => { realSave(candidate); const external = loadConfig(); external.port = 12345; - writeFileSync(getConfigPath(), JSON.stringify(external, null, 2) + "\n"); + replacementBytes = Buffer.from(JSON.stringify(external, null, 2) + "\n", "utf8"); + writeFileSync(getConfigPath(), replacementBytes); throw new Error("forced concurrent failure"); }); try { expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow(CodexAccountDeleteRollbackError); + expect(replacementBytes).toBeDefined(); + expect(readFileSync(getConfigPath())).toEqual(replacementBytes); const persisted = loadConfig(); expect(persisted.port).toBe(12345); expect(persisted.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false); @@ -176,6 +170,36 @@ describe("Codex account delete persistence ordering", () => { } }); + test("distinct bytes with the same decoded text are treated as changed", () => { + const config = seededConfig(); + const before = structuredClone(config); + const validBytes = Buffer.from('{"value":"\uFFFD"}\n', "utf8"); + const malformedBytes = Buffer.concat([ + Buffer.from('{"value":"', "utf8"), + Buffer.from([0x80]), + Buffer.from('"}\n', "utf8"), + ]); + expect(validBytes.equals(malformedBytes)).toBe(false); + expect(validBytes.toString("utf8")).toBe(malformedBytes.toString("utf8")); + writeFileSync(getConfigPath(), validBytes); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(() => { + writeFileSync(getConfigPath(), malformedBytes); + throw new Error("forced byte-alias failure"); + }); + + try { + expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow(CodexAccountDeleteRollbackError); + expect(readFileSync(getConfigPath()).equals(malformedBytes)).toBe(true); + expect(config).toEqual(before); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); + expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); + } finally { + saveSpy.mockRestore(); + } + }); + test("a missing config after uncertain failure is not recreated", () => { const config = seededConfig(); const before = structuredClone(config); @@ -199,6 +223,88 @@ describe("Codex account delete persistence ordering", () => { } }); + test("an unreadable config after uncertain failure preserves state and sanitizes errors", () => { + const config = seededConfig(); + const before = structuredClone(config); + const configPath = getConfigPath(); + const beforeBytes = readFileSync(configPath); + const readSpy = spyOn(fsModule, "readFileSync"); + const removeSpy = spyOn(accountStoreModule, "removeCodexAccountCredential"); + const invalidateSpy = spyOn(websocketRegistryModule, "invalidateCodexWebSocketsForAccount"); + const forgetSpy = spyOn(quotaAutoRefreshStateModule, "forgetCodexQuotaAutoRefreshAccount"); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(() => { + readSpy.mockImplementationOnce(() => { + throw new Error("EACCES /private/config.json Bearer read-secret-token"); + }); + throw new Error("write failed /private/config.json Bearer write-secret-token"); + }); + + try { + let thrown: unknown; + try { + deleteCodexAccount(config, ACCOUNT_ID); + } catch (error) { + thrown = error; + } + expect(readSpy).toHaveBeenLastCalledWith(configPath); + expect(thrown).toBeInstanceOf(CodexAccountDeleteRollbackError); + expect((thrown as Error).message).toBe( + "Account deletion failed and the previous config could not be restored. Restart before retrying.", + ); + expect(String(thrown)).not.toContain("/private/config.json"); + expect(String(thrown)).not.toContain("secret-token"); + expect((thrown as Error).cause).toBeUndefined(); + expect(config).toEqual(before); + expect(readFileSync(configPath)).toEqual(beforeBytes); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); + expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(forgetSpy).not.toHaveBeenCalled(); + } finally { + readSpy.mockRestore(); + saveSpy.mockRestore(); + removeSpy.mockRestore(); + invalidateSpy.mockRestore(); + forgetSpy.mockRestore(); + } + }); + + test("a transient config skips persistence but still removes credentials and runtime state", () => { + const config = seededConfig(); + const configPath = getConfigPath(); + unlinkSync(configPath); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode"); + const removeSpy = spyOn(accountStoreModule, "removeCodexAccountCredential"); + const invalidateSpy = spyOn(websocketRegistryModule, "invalidateCodexWebSocketsForAccount"); + const forgetSpy = spyOn(quotaAutoRefreshStateModule, "forgetCodexQuotaAutoRefreshAccount"); + + try { + expect(deleteCodexAccount(config, ACCOUNT_ID)).toBe(true); + expect(saveSpy).not.toHaveBeenCalled(); + expect(existsSync(configPath)).toBe(false); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ stable: ACCOUNT_ID }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(config.codexAccountPriorities).toBeUndefined(); + expect(config.activeCodexAccountPinned).toBeUndefined(); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(ACCOUNT_ID)).toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(false); + expect(getAccountQuota(ACCOUNT_ID)).toBeNull(); + expect(removeSpy).toHaveBeenCalledWith(ACCOUNT_ID); + expect(invalidateSpy).toHaveBeenCalledWith(ACCOUNT_ID); + expect(forgetSpy).toHaveBeenCalledWith(ACCOUNT_ID); + } finally { + saveSpy.mockRestore(); + removeSpy.mockRestore(); + invalidateSpy.mockRestore(); + forgetSpy.mockRestore(); + } + }); + test("the durable config deletion happens before credential and runtime cleanup", () => { const config = seededConfig(); const realSave = configModule.saveConfigPreservingClaudeCode; diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index fc58ad2abc..ff47767f1f 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -14,7 +14,7 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, + clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, fetchMainAccountInfoSnapshot, clearCodexQuotaPrimeState, primeCodexPoolQuotas, seedCodexAuthAdmissionForTests, type CodexAuthAccountDto, listCodexAuthAccounts, @@ -27,6 +27,8 @@ import { saveCodexAccountCredential, } from "../../src/codex/account-store"; import * as accountStoreModule from "../../src/codex/account-store"; +import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; +import { getMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { clearCodexUpstreamHealth, clearThreadAccountMap, @@ -255,6 +257,266 @@ function seedPoolAccount( }); } +describe("main quota refresh diagnostics", () => { + function writeMain(accountId = "fixture-account"): string { + const accessToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: accessToken, account_id: accountId }, + })); + return accessToken; + } + + test.each([401, 403, 429, 503])("HTTP %s is diagnostic, not proof of sign-out", async status => { + writeMain(); + globalThis.fetch = (async () => new Response("private-upstream-canary", { status })) as typeof fetch; + const main = (await listCodexAuthAccounts(makeConfig(), true)).find(row => row.isMain); + expect(main).toMatchObject({ quotaRefresh: { status: "http_error", httpStatus: status }, + plan: null, quota: null, hasCredential: true, needsReauth: false }); + expect(JSON.stringify(main)).not.toContain("private-upstream-canary"); + }); + + test.each(["network_error", "invalid_response", "not_reported", "body_reset"] as const)("classifies %s without serializing errors", async kind => { + writeMain(); + globalThis.fetch = (async () => { + if (kind === "network_error") throw new TypeError("private-network-canary"); + if (kind === "body_reset") return new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-stream-canary")); }, + })); + return kind === "invalid_response" ? new Response("private-json-canary") : Response.json({}); + }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: kind === "body_reset" ? "network_error" : kind }); + expect(result.info.quota).toBeNull(); + expect(JSON.stringify(result)).not.toContain("canary"); + }); + + test("timeout reports only the fixed category", async () => { + writeMain(); + const signal = AbortSignal.abort(new DOMException("private-timeout-canary", "TimeoutError")); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(signal); + globalThis.fetch = (async () => { throw signal.reason; }) as typeof fetch; + try { + expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh).toEqual({ status: "timeout" }); + } finally { timeout.mockRestore(); } + }); + + test.each([401, 403])("HTTP %s takes precedence over an unreadable error body", async status => { + writeMain(); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-error-body-canary")); }, + }), { status })) as typeof fetch; + const main = (await listCodexAuthAccounts(makeConfig(), true)).find(row => row.isMain); + expect(main).toMatchObject({ quotaRefresh: { status: "http_error", httpStatus: status }, + quota: null, hasCredential: true, needsReauth: false }); + expect(JSON.stringify(main)).not.toContain("canary"); + }); + + test("HTTP status survives an aborted error body", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + globalThis.fetch = (async () => { + controller.abort(new DOMException("private-http-timeout-canary", "TimeoutError")); + return new Response(new ReadableStream({ + start(stream) { stream.error(controller.signal.reason); }, + }), { status: 403 }); + }) as typeof fetch; + try { + expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh) + .toEqual({ status: "http_error", httpStatus: 403 }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + } finally { timeout.mockRestore(); } + }); + + test("timeout while reading a successful body reports timeout", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(stream) { + controller.abort(new DOMException("private-body-timeout-canary", "TimeoutError")); + stream.error(controller.signal.reason); + }, + }))) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "timeout" }); + expect(JSON.stringify(result)).not.toContain("canary"); + } finally { timeout.mockRestore(); } + }); + + test("Reserve observer failure is internal even if the request timer has expired", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + const observer = spyOn(reserveAvailabilityModule, "observeMainReserveRevocation") + .mockImplementation(() => { + controller.abort(); + throw new Error("private-reserve-publication-canary"); + }); + globalThis.fetch = (async () => Response.json({ plan_type: "plus", + rate_limit: { primary_window: { used_percent: 37 } } })) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(observer).toHaveBeenCalledTimes(1); + expect(result.quotaRefresh).toEqual({ status: "internal_error" }); + expect(result.info.quota).toBeNull(); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(JSON.stringify(result)).not.toContain("canary"); + } finally { observer.mockRestore(); timeout.mockRestore(); } + }); + + test.each([false, true])("decoded null is invalid with a matching Reserve slot=%s", async matchingSlot => { + const accessToken = writeMain(); + reconcileMainCodexAccountRuntimeState(); + const token = { accessToken, chatgptAccountId: "fixture-account" }; + const writer = observeMainQuotaCredential(accessToken, token.chatgptAccountId); + let capabilityReads = 0; + let passiveReads = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + if (new Headers(init?.headers).get("x-openai-codex-luna-reserve") === "1") { + capabilityReads++; + return Response.json({ + rate_limit: { allowed: false }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }], + }); + } + passiveReads++; + return Response.json(null); + }) as typeof fetch; + const authorization = matchingSlot + ? await reserveAvailabilityModule.getMainReserveAuthorization({ token, writer, observeOrdinaryQuota: () => {} }) + : undefined; + if (matchingSlot) { + expect(authorization).toBeDefined(); + expect(reserveAvailabilityModule.isMainReserveAuthorizationLive(authorization, token)).toBe(true); + } + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); + expect(result.info.quota).toBeNull(); + expect(capabilityReads).toBe(matchingSlot ? 1 : 0); + expect(passiveReads).toBe(1); + if (matchingSlot) { + expect(reserveAvailabilityModule.isMainReserveAuthorizationLive(authorization, token)).toBe(true); + } + }); + + test.each([{ value: [] }, { value: "invalid-usage" }, { value: 7 }, { value: false }])( + "decoded non-object usage is invalid: %j", async ({ value }) => { + writeMain(); + globalThis.fetch = (async () => Response.json(value)) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); + expect(result.info.quota).toBeNull(); + }, + ); + + test.each( + (["snapshot", "accounts"] as const).flatMap(surface => + (["none", "same_id", "round_trip"] as const).flatMap(invalidation => + (["http", "terminal_http", "body", "ok"] as const).map(outcome => ({ surface, invalidation, outcome })), + ), + ), + )("diagnostic dispatch fence: %j", async ({ surface, invalidation, outcome }) => { + writeMain(); + let started!: () => void; + const dispatched = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let reads = 0; + globalThis.fetch = (async () => { + reads++; + started(); + await gate; + if (outcome === "http") return new Response("private-http-canary", { status: 503 }); + if (outcome === "terminal_http") { + return Response.json({ detail: { code: "invalid_workspace_selected" } }, { status: 403 }); + } + if (outcome === "body") return new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-body-canary")); }, + })); + return Response.json({ rate_limit: { primary_window: { used_percent: 37 } } }); + }) as typeof fetch; + const pending = surface === "snapshot" + ? fetchMainAccountInfoSnapshot(true) + : listCodexAuthAccounts(makeConfig(), true).then(rows => rows.find(row => row.isMain)!); + try { + await Promise.race([dispatched, pending.then(() => { throw new Error("Main WHAM never dispatched"); })]); + if (invalidation === "same_id") { + clearMainAccountInfoCache(); + } else if (invalidation === "round_trip") { + writeMain("other-account"); + reconcileMainCodexAccountRuntimeState(); + writeMain(); + reconcileMainCodexAccountRuntimeState(); + } + release(); + const result = await pending; + expect(reads).toBe(1); + if (invalidation === "none") { + const expected = outcome === "http" ? { status: "http_error", httpStatus: 503 } + : outcome === "terminal_http" ? { status: "http_error", httpStatus: 403 } + : { status: outcome === "body" ? "network_error" : "ok" }; + expect(result.quotaRefresh).toEqual(expected); + } else { + expect(result).not.toHaveProperty("quotaRefresh"); + } + // The existing terminal-auth decision still applies, independently of diagnostic freshness. + if (outcome === "terminal_http") expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(result).not.toHaveProperty("quotaRefreshGeneration"); + expect(JSON.stringify(result)).not.toContain("quotaRefreshGeneration"); + expect(JSON.stringify(result)).not.toContain("canary"); + const cached = getMainAccountInfoCache(); + if (cached) { + expect(cached).not.toHaveProperty("quotaRefreshGeneration"); + expect(cached).not.toHaveProperty("quotaRefresh"); + } + } finally { + release(); + await pending; + } + }); + + test("missing credentials omit diagnostics without issuing a request", async () => { + let reads = 0; + globalThis.fetch = (async () => { reads++; return Response.json({}); }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toBeUndefined(); + expect(reads).toBe(0); + }); + + test("exhausted identity retry does not publish either account's diagnostic", async () => { + writeMain(); + let reads = 0; + globalThis.fetch = (async () => { + reads++; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), + account_id: `replacement-${reads}` }, + })); + return Response.json({ rate_limit: { primary_window: { used_percent: 37 } } }); + }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(reads).toBe(2); + expect(result.quotaRefresh).toBeUndefined(); + expect(result.info.quota).toBeNull(); + }); + + test("fresh success reports ok while cache reuse never claims another probe", async () => { + writeMain(); + let reads = 0; + globalThis.fetch = (async () => { reads++; return Response.json({ plan_type: "plus", + rate_limit: { primary_window: { used_percent: 37 } } }); }) as typeof fetch; + const fresh = await fetchMainAccountInfoSnapshot(true); + expect(fresh.quotaRefresh).toEqual({ status: "ok" }); + const cached = await fetchMainAccountInfoSnapshot(false); + expect(cached.quotaRefresh).toBeUndefined(); + expect(cached.info.quota).toEqual(fresh.info.quota); + expect(reads).toBe(1); + }); +}); + beforeEach(() => { resetLifecycleDrainStateForTests(); previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -432,6 +694,7 @@ describe("codex-auth API", () => { releasePool(); const response = await pending; const body = await response?.json() as { accounts: CodexAuthAccountDto[] }; + expect(body.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).not.toHaveProperty("quotaRefresh"); expect(body.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ email: "Codex App login", plan: null, @@ -462,7 +725,9 @@ describe("codex-auth API", () => { clearMainAccountInfoCache(); let drain = acquireNativeMainProfileDrain("credential-snapshot-cold-cache"); try { - expect((await listMain()).hasCredential).toBe(true); + const main = await listMain(); + expect(main.hasCredential).toBe(true); + expect(main).not.toHaveProperty("quotaRefresh"); } finally { drain?.release(); } @@ -472,7 +737,9 @@ describe("codex-auth API", () => { expect((await listMain()).hasCredential).toBe(false); drain = acquireNativeMainProfileDrain("credential-snapshot-warm-cache"); try { - expect((await listMain()).hasCredential).toBe(false); + const main = await listMain(); + expect(main.hasCredential).toBe(false); + expect(main).not.toHaveProperty("quotaRefresh"); } finally { drain?.release(); } diff --git a/tests/codex-integration/codex-catalog-model-picker-order.test.ts b/tests/codex-integration/codex-catalog-model-picker-order.test.ts index 7ef43e0c3f..1bc57c76cc 100644 --- a/tests/codex-integration/codex-catalog-model-picker-order.test.ts +++ b/tests/codex-integration/codex-catalog-model-picker-order.test.ts @@ -3,6 +3,7 @@ import { buildCatalogEntriesFromObservedState, effectiveSubagentRoster, MAX_SPAWN_AGENT_MODEL_OVERRIDES, + orderForModelPicker, } from "../../src/codex/catalog/sync"; import type { CatalogModel } from "../../src/types"; @@ -144,10 +145,9 @@ describe("modelPickerOrder (#1649)", () => { expect(candidateSlugs).not.toContain("jd-chat/kimi-k3"); }); - // Documents the scope boundary raised in review: modelPickerOrder targets routed - // / rows only. A bare native slug listed here must NOT reorder its native - // passthrough row (native ordering goes through subagentModels). - test("a bare native slug in modelPickerOrder does not reorder its native row", () => { + // This is the pure builder, before the complete-order pass performed by the wrapper/merge. + // Its legacy routed pass leaves native ranks alone; full ordering is tested separately. + test("the builder leaves a bare native row unchanged before the complete-order pass", () => { const entries = buildCatalogEntriesFromObservedState({ template: template() as never, gptSlugs: ["gpt-5.5", "gpt-5.4"], @@ -204,3 +204,25 @@ describe("modelPickerOrder (#1649)", () => { expect(withOrder.length).toBe(MAX_SPAWN_AGENT_MODEL_OVERRIDES); }); }); + + +describe("routed picker projection preserves existing priority bands", () => { + const rows = ["a", "b", "c", "d"].map(id => ({ provider: "p", id })); + test("featured and unlisted rows precede the listed band without mutating input", () => { + const before = structuredClone(rows); + expect(orderForModelPicker(rows, ["p/d", "p/b", "p/a"], ["p/a"]).map(row => row.id)) + .toEqual(["a", "c", "d", "b"]); + expect(rows).toEqual(before); + expect(orderForModelPicker(rows, []).map(row => row.id)).toEqual(["a", "b", "c", "d"]); + }); + test("complete order may move featured display rows but uses exact before equivalent ids", () => { + const slashRows = [{ provider: "p", id: "team/model" }, { provider: "p", id: "other" }]; + expect(orderForModelPicker(slashRows, + ["gpt-5.5", "p/team-model", "p/other", "p/team/model"], ["p/other"]).map(row => row.id)) + .toEqual(["team/model", "other"]); + }); + test("native alias keeps its natural band for routed-only orders", () => { + const alias = { provider: "combo", id: "native", alias: "native/model", nativeAlias: true }; + expect(orderForModelPicker([...rows, alias], ["native/model", "p/d", "p/c", "p/b", "p/a"])[0]).toBe(alias); + }); +}); diff --git a/tests/codex-integration/codex-catalog-sync-hardening.test.ts b/tests/codex-integration/codex-catalog-sync-hardening.test.ts index 8e1adf2f63..8266015bba 100644 --- a/tests/codex-integration/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-integration/codex-catalog-sync-hardening.test.ts @@ -29,9 +29,9 @@ function runScript( return { stdout: result.stdout?.trim() ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 }; } -function createCodexCatalogFixture(dir: string): string { +function createCodexCatalogFixture(dir: string, models = [nativeEntry("gpt-5.5", 0)]): string { const scriptPath = join(dir, "codex-catalog-fixture.js"); - const bundled = JSON.stringify({ models: [nativeEntry("gpt-5.5", 0)] }); + const bundled = JSON.stringify({ models }); writeFileSync(scriptPath, [ 'if (process.argv.includes("--version")) {', ' console.log("codex-cli 0.999.0");', @@ -448,6 +448,58 @@ describe("Codex catalog sync hardening", () => { expect(rows.filter(row => row.slug === "gpt-daybreak-blue-latest")).toHaveLength(1); }); + test("canonical custom Astra repairs stale efforts and keeps a narrow ladder across syncs", () => { + const catalogPath = join(codexHome, "catalog.json"); + const runtime = createCodexCatalogFixture(codexHome, [{ + ...nativeEntry("gpt-5.5", 0), + // Another model permits sentinels, so the global union cannot perform this repair. + supported_reasoning_levels: ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].map(effort => ({ effort, description: effort })), + }]); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n'); + writeFileSync(catalogPath, JSON.stringify({ models: [{ + ...ocxAuthoredEntry("openai/gpt-6-astra", 5), + opencodex_catalog_kind: "custom-model-v1", + supported_reasoning_levels: [{ effort: "minimal", description: "stale" }], + default_reasoning_level: "minimal", + }] })); + const result = runScript(codexHome, opencodexHome, ` + const { readFileSync } = require("node:fs"); + const { saveConfig } = require("./src/config"); + const { syncCatalogModels } = require("./src/codex/catalog"); + const config = { + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool" } }, + codexAccountPickerEnabled: false, + customModels: [{ id: "astra", provider: "openai", modelId: "gpt-6-astra", reasoningEfforts: ["none", "minimal", "low"], defaultReasoningEffort: "minimal" }] + }; + saveConfig(config); + (async () => { + const first = await syncCatalogModels(config, { allowWhenDesiredDisabled: true }); + const firstBytes = readFileSync(first.path, "utf8"); + const second = await syncCatalogModels(config, { allowWhenDesiredDisabled: true }); + const secondBytes = readFileSync(second.path, "utf8"); + config.customModels = []; + saveConfig(config); + await syncCatalogModels(config, { allowWhenDesiredDisabled: true }); + console.log(JSON.stringify({ + first: JSON.parse(firstBytes), second: JSON.parse(secondBytes), + unchanged: firstBytes === secondBytes, + deleted: JSON.parse(readFileSync(first.path, "utf8")) + })); + })(); + `, { CODEX_CLI_PATH: runtime }); + expect(result.status).toBe(0); + const output = JSON.parse(result.stdout); + for (const catalog of [output.first, output.second]) { + const astra = catalog.models.find((row: { slug: string }) => row.slug === "openai/gpt-6-astra"); + expect(astra.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["low"]); + expect(astra.default_reasoning_level).toBe("low"); + } + expect(output.unchanged).toBe(true); + expect(output.deleted.models.some((row: { slug: string }) => row.slug === "openai/gpt-6-astra")).toBe(false); + }); + test("explicit Codex-forward Daybreak survives sync with Sol metadata while account picker is off", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 8c9deee4d3..9b3ce52cb3 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -8,7 +8,7 @@ import { isGpt56NativeSlug } from "../../src/codex/catalog/effort"; import { nativeOpenAiContextTier, nativeOpenAiMaxInputTokens } from "../../src/codex/catalog"; import { shouldUpgradeToUpstreamEntry } from "../../src/codex/catalog/metadata"; import { applyNativeVisibility, augmentRoutedModelsWithMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, CODEX_ACCOUNT_BOUND_CATALOG_KIND, CODEX_NATIVE_ALIAS_CATALOG_KIND, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT6_ASTRA_MODEL, NATIVE_OPENAI_MODELS, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeReasoningEfforts, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, resolveComboCatalogMember, shouldExposeRoutedModel, upstreamNativeEntry } from "../../src/codex/catalog"; -import { applyProviderConfigHints, mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/provider-fetch"; +import { applyProviderConfigHints, fetchProviderModels, mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/provider-fetch"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, @@ -36,15 +36,17 @@ import { setCached, type ProviderModelDiscoveryStatus, } from "../../src/codex/model-cache"; -import type { OcxConfig } from "../../src/types"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { COMBO_NAMESPACE } from "../../src/combos"; import type { NormalizedComboConfig } from "../../src/combos/types"; import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { providerMatchesRegistryTransportWithStaticGuards } from "../../src/providers/static-model-discovery"; import { enrichProviderFromCatalog } from "../../src/oauth/key-providers"; import { handleManagementAPI } from "../../src/server/management-api"; import { OAUTH_PROVIDERS } from "../../src/oauth"; import { + catalogEntryEfforts, clampCatalogModelsToObservedCodexSupport, supportedCodexReasoningEffortsFromObservedCatalog, } from "../../src/codex/catalog/effort"; @@ -666,6 +668,18 @@ describe("combo catalog capability intersection", () => { .toEqual([]); }); + test("filters dashboard-hidden provider models before catalog sync", () => { + const models = [ + { provider: "vendor", id: "visible-model" }, + { provider: "vendor", id: "hidden-model" }, + ]; + + expect(filterCatalogVisibleModels(models, { + disabledModels: ["vendor/hidden-model"], + providers: { vendor: {} }, + })).toEqual([{ provider: "vendor", id: "visible-model" }]); + }); + test("repairs a provider row after its shadowing combo alias is disabled", () => { const alias = "vendor/deepseek-v4-flash"; const combo = deriveComboCatalogModel( @@ -3758,6 +3772,114 @@ describe("Codex catalog routed normalization", () => { expect(astra?.base_instructions).not.toContain("daybreak"); }); + const nativeCustomEffortCases: Array<{ + name: string; + efforts?: string[]; + defaultEffort?: string; + expected: string[]; + expectedDefault?: string; + }> = [ + { name: "legacy sentinels", efforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], defaultEffort: "minimal", expected: ["low", "medium", "high", "xhigh", "max"], expectedDefault: "low" }, + { name: "native default", expected: ["low", "medium", "high", "xhigh", "max", "ultra"], expectedDefault: "low" }, + { name: "empty declaration", efforts: [], defaultEffort: "minimal", expected: [] }, + { name: "no compatible rung", efforts: ["none", "minimal"], defaultEffort: "minimal", expected: ["low"], expectedDefault: "low" }, + { name: "narrow subset", efforts: ["low"], defaultEffort: "high", expected: ["low"], expectedDefault: "low" }, + { name: "valid explicit default", efforts: ["high", "medium", "high"], defaultEffort: "high", expected: ["medium", "high"], expectedDefault: "high" }, + { name: "first survivor default", efforts: ["high", "medium"], defaultEffort: "minimal", expected: ["medium", "high"], expectedDefault: "medium" }, + { name: "Ultra mode", efforts: ["minimal", "ultra"], defaultEffort: "minimal", expected: ["ultra"], expectedDefault: "ultra" }, + ]; + + test.each(nativeCustomEffortCases)("canonical custom Astra bounds $name through gather/build/merge", async fixture => { + globalThis.fetch = (() => { throw new Error("canonical forward discovery must not fetch"); }) as typeof fetch; + const config = withStubbedProviderFetch({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", codexAccountMode: "pool" } }, + customModels: [{ + id: "astra-effort", + provider: "openai", + modelId: "gpt-6-astra", + ...(fixture.efforts !== undefined ? { reasoningEfforts: fixture.efforts } : {}), + ...(fixture.defaultEffort !== undefined ? { defaultReasoningEffort: fixture.defaultEffort } : {}), + }], + }); + const beforeConfig = JSON.stringify(config); + const beforeNative = JSON.stringify(upstreamNativeEntry("gpt-6-astra")); + const models = await gatherRoutedModelsDirect(config); + const custom = models.find(row => row.provider === "openai" && row.id === "gpt-6-astra"); + expect(custom?.codexForwardNativeCapabilityAlias).toBe(true); + expect(custom?.reasoningEfforts).toEqual(fixture.expected); + expect(custom?.defaultReasoningEffort).toBe(fixture.expectedDefault); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const first = mergeCatalogEntriesForSync([], entries, new Map(), [], false); + const second = mergeCatalogEntriesForSync(first, buildCatalogEntries(nativeTemplate(), [], models), new Map(), [], false); + // Another model's sentinels make the legacy union permissive: it cannot mask this bug. + const observed = { models: [{ + slug: "other-model", + supported_reasoning_levels: ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].map(effort => ({ effort })), + }] }; + const beforeObserved = JSON.stringify(observed); + for (const projection of [entries, first, second]) { + clampCatalogModelsToObservedCodexSupport(projection, supportedCodexReasoningEffortsFromObservedCatalog(observed)); + const row = projection.find(entry => entry.slug === "openai/gpt-6-astra"); + expect(row ? catalogEntryEfforts(row) : undefined).toEqual(fixture.expected); + expect(row?.default_reasoning_level).toBe(fixture.expectedDefault); + expect(row?.use_responses_lite).toBe(true); + expect(row?.multi_agent_reasoning_effort).toBe("xhigh"); + if (fixture.expected.length === 0) expect(row).not.toHaveProperty("default_reasoning_level"); + } + expect(JSON.stringify(config)).toBe(beforeConfig); + expect(JSON.stringify(upstreamNativeEntry("gpt-6-astra"))).toBe(beforeNative); + expect(JSON.stringify(observed)).toBe(beforeObserved); + }); + + test.each([ + { name: "YYLJ", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", modelId: "gpt-6-astra" }, + { name: "openai", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "forward", modelId: "gpt-6-astra" }, + { name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra" }, + { name: "openai", adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra" }, + { name: "openai-apikey", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", modelId: "gpt-6-astra" }, + { name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", modelId: "gpt-unproven" }, + ] satisfies Array<{ name: string; adapter: OcxProviderConfig["adapter"]; baseUrl: string; authMode: OcxProviderConfig["authMode"]; modelId: string }>)( + "custom $name/$modelId does not infer native effort capability from $baseUrl / $authMode / $adapter", + async fixture => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: fixture.name, + providers: { [fixture.name]: { adapter: fixture.adapter, baseUrl: fixture.baseUrl, authMode: fixture.authMode, liveModels: false, models: [fixture.modelId] } }, + customModels: [{ id: "unproven", provider: fixture.name, modelId: fixture.modelId, displayName: "Astra", reasoningEfforts: ["none", "minimal", "low"], defaultReasoningEffort: "minimal" }], + }); + const custom = models.find(row => row.provider === fixture.name && row.id === fixture.modelId); + expect(custom?.codexForwardNativeCapabilityAlias).toBeUndefined(); + expect(custom?.reasoningEfforts).toEqual(["none", "minimal", "low"]); + expect(custom?.defaultReasoningEffort).toBe("minimal"); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(entry => entry.slug === `${fixture.name}/${fixture.modelId}`); + expect(row ? catalogEntryEfforts(row) : undefined) + .toEqual(["none", "minimal", "low", "max", "ultra"]); + }, + ); + + test("fresh none-only custom rows keep their ladder while retained provider rows still gain max", async () => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { "custom-provider": { adapter: "openai-chat", baseUrl: "https://example.invalid/v1", liveModels: false } }, + customModels: [{ id: "none-only", provider: "custom-provider", modelId: "none-only", reasoningEfforts: ["none"] }], + }); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const retained = { ...nativeTemplate(), slug: "foreign/model", supported_reasoning_levels: [{ effort: "low", description: "Low" }] }; + const stale = { ...entries[0]!, slug: "custom-provider/deleted" }; + const merged = mergeCatalogEntriesForSync([retained, stale], entries, new Map(), [], false); + expect(merged.find(row => row.slug === "custom-provider/none-only")?.supported_reasoning_levels) + .toEqual(entries[0]!.supported_reasoning_levels); + const foreign = merged.find(row => row.slug === "foreign/model"); + expect(foreign ? catalogEntryEfforts(foreign) : undefined) + .toEqual(["low", "max"]); + expect(merged.some(row => row.slug === "custom-provider/deleted")).toBe(false); + }); + test("Astra refresh repairs only built-in speed text and does not leak native effort", () => { const pinned = upstreamNativeEntry(NATIVE_GPT6_ASTRA_MODEL)!; expect(pinned.service_tiers).toEqual([{ id: "priority", name: "Fast", description: "2x speed, increased usage" }]); @@ -3825,6 +3947,7 @@ describe("Codex catalog routed normalization", () => { // not overwrite it — otherwise the catalog would advertise reasoning the user // explicitly disabled for this row. reasoningEfforts: [], + defaultReasoningEffort: "minimal", }], }); const model = models.find(row => row.provider === "openai" && row.id === NATIVE_DAYBREAK_BLUE_MODEL); @@ -4096,7 +4219,7 @@ describe("Codex catalog routed normalization", () => { expect(routed?.supports_search_tool).toBe(true); }); - test("liveModels false uses configured provider models without fetching", async () => { + test("liveModels false uses the explicit and retained union instead of another default without fetching", async () => { clearModelCache("static-provider"); const originalFetch = globalThis.fetch; let fetchCalls = 0; @@ -4113,6 +4236,8 @@ describe("Codex catalog routed normalization", () => { authMode: "key", liveModels: false, models: ["alpha", "beta"], + defaultModel: "unlisted-default", + retainModels: ["beta", "retained", "retained"], }, }, }); @@ -4121,6 +4246,7 @@ describe("Codex catalog routed normalization", () => { expect(models.map(m => `${m.provider}/${m.id}`)).toEqual([ "static-provider/alpha", "static-provider/beta", + "static-provider/retained", ]); } finally { globalThis.fetch = originalFetch; @@ -4128,6 +4254,142 @@ describe("Codex catalog routed normalization", () => { } }); + test("liveModels false uses the default model when no static list is configured", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + providers: { + "static-default": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + defaultModel: "only-model", + }, + }, + }); + + expect(fetchCalls).toBe(0); + expect(models.map(m => `${m.provider}/${m.id}`)).toEqual([ + "static-default/only-model", + ]); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("static-default"); + } + }); + + test.each([ + { label: "omitted", models: undefined }, + { label: "empty", models: [] as string[] }, + ])("static $label models deduplicate default and retain ids before either OAuth resolver", async ({ models: configuredModels }) => { + const oauth = await import("../../src/oauth"); + const resolveToken = spyOn(oauth, "resolveModelsAuthToken") + .mockRejectedValue(new Error("static catalogs must not resolve or refresh OAuth")); + const resolveSnapshot = spyOn(oauth, "getValidAccessTokenSnapshot") + .mockRejectedValue(new Error("static catalogs must not refresh Cloud Code Assist OAuth")); + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("static catalogs must not make a network request"); + }) as typeof fetch; + try { + for (const adapter of ["openai-chat", "google"] as const) { + const provider = `static-oauth-${adapter}`; + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const models = await gatherRoutedModels({ + providers: { + [provider]: { + adapter, baseUrl: "https://static-oauth.example.test/v1", authMode: "oauth", + ...(adapter === "google" ? { googleMode: "cloud-code-assist" as const } : {}), + liveModels: false, + ...(configuredModels === undefined ? {} : { models: configuredModels }), + defaultModel: "default-only", + retainModels: ["retained", "default-only", "retained"], + }, + }, + }, { providerModelOutcomes: outcomes }); + expect(models.map(model => `${model.provider}/${model.id}`)) + .toEqual([`${provider}/default-only`, `${provider}/retained`]); + expect(outcomes).toEqual([{ provider, state: "authoritative" }]); + } + expect(resolveToken).not.toHaveBeenCalled(); + expect(resolveSnapshot).not.toHaveBeenCalled(); + expect(fetchCalls).toBe(0); + } finally { + resolveToken.mockRestore(); + resolveSnapshot.mockRestore(); + } + }); + + test("static empty models without a default or retained ids remain authoritatively empty", async () => { + const provider = "static-empty-unregistered"; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("empty static catalogs must not make a network request"); + }) as typeof fetch; + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const models = await gatherRoutedModels({ + providers: { + [provider]: { + adapter: "openai-chat", baseUrl: "https://static-empty.example.test/v1", + authMode: "key", liveModels: false, + models: [], + }, + }, + }, { providerModelOutcomes: outcomes }); + expect(models).toEqual([]); + expect(outcomes).toEqual([{ provider, state: "authoritative" }]); + expect(fetchCalls).toBe(0); + }); + + test.each([false, true])("forward auth bypasses default/static seeds with liveModels=%s", async liveModels => { + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("forward model discovery must not request an upstream catalog"); + }) as typeof fetch; + const config = withStubbedProviderFetch({ providers: { + "forward-static-fixture": { + adapter: "openai-responses", authMode: "forward" as const, + baseUrl: "https://forward-static.example.test/v1", liveModels, + models: [], defaultModel: "forward-default", retainModels: ["forward-retained"], + }, + } }); + expect(await fetchProviderModels("forward-static-fixture", config.providers["forward-static-fixture"], 0)).toEqual([]); + expect(fetchCalls).toBe(0); + }); + + test("successful empty live discovery never activates the default-only failure fallback", async () => { + const provider = "live-empty-default"; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return Response.json({ data: [] }); + }) as typeof fetch; + const config = { providers: { + [provider]: { + adapter: "anthropic", baseUrl: "https://live-empty.example.test/v1", + authMode: "key" as const, apiKey: "fixture-key", liveModels: true, + defaultModel: "failure-fallback-only", + }, + } }; + for (let read = 0; read < 2; read += 1) { + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + expect(await gatherRoutedModels(config, { providerModelOutcomes: outcomes })).toEqual([]); + expect(outcomes).toEqual([{ provider, state: "authoritative" }]); + } + expect(fetchCalls).toBe(1); + expect(getStaleCached(provider)).toEqual([]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "ok" }); + }); + test("Google Antigravity honors an explicit static catalog and suppresses stale discovery", async () => { const providerName = "google-antigravity"; const provider = structuredClone(OAUTH_PROVIDERS[providerName].providerConfig); @@ -5241,21 +5503,56 @@ describe("Codex catalog routed normalization", () => { expect(slugs).toEqual(["opencode-go/glm-5.2"]); }); - test("liveModels false with no models exposes no augmented provider rows", async () => { + test.each([ + { label: "omitted", models: undefined }, + { label: "empty", models: [] as string[] }, + ])("static Go $label models expose only the inherited default, not the metadata roster", async ({ models: configuredModels }) => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://opencode-go.test/v1", apiKey: "sk-test", + liveModels: false, + ...(configuredModels === undefined ? {} : { models: configuredModels }), + }; + // Go keeps the registry's historical name-based ownership even with this fixture URL. + // This was never a genuinely default-less provider after capture-time enrichment. + expect(providerMatchesRegistryTransportWithStaticGuards("opencode-go", provider)).toBe(true); + const enriched = structuredClone(provider); + enrichProviderFromRegistry("opencode-go", enriched); + expect(enriched.defaultModel).toBe("kimi-k2.7-code"); + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("static inherited defaults must not request upstream models"); + }) as typeof fetch; const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; - const models = await gatherRoutedModels({ - providers: { - "opencode-go": { - adapter: "openai-chat", - baseUrl: "https://opencode-go.test/v1", - apiKey: "sk-test", - liveModels: false, - }, - }, - }, { providerModelOutcomes: outcomes }); - - expect(models).toEqual([]); + const models = await gatherRoutedModels({ providers: { "opencode-go": provider } }, { providerModelOutcomes: outcomes }); + expect(models.map(model => `${model.provider}/${model.id}`)).toEqual(["opencode-go/kimi-k2.7-code"]); expect(outcomes).toEqual([{ provider: "opencode-go", state: "authoritative" }]); + expect(fetchCalls).toBe(0); + }); + + test.each([ + { label: "omitted", models: undefined }, + { label: "empty", models: [] as string[] }, + ])("custom MiMo $label models remain empty without an effective default", async ({ models: configuredModels }) => { + const provider: OcxProviderConfig = { + adapter: "mimo-free", baseUrl: "https://mimo-custom.example.test/v1", authMode: "key", + liveModels: false, + ...(configuredModels === undefined ? {} : { models: configuredModels }), + }; + expect(providerMatchesRegistryTransportWithStaticGuards("mimo-free", provider)).toBe(false); + const enriched = structuredClone(provider); + enrichProviderFromRegistry("mimo-free", enriched); + expect(enriched.defaultModel).toBeUndefined(); + expect(enriched.models).toEqual(configuredModels); + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("empty custom static providers must not request upstream models"); + }) as typeof fetch; + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + expect(await gatherRoutedModels({ providers: { "mimo-free": provider } }, { providerModelOutcomes: outcomes })).toEqual([]); + expect(outcomes).toEqual([{ provider: "mimo-free", state: "authoritative" }]); + expect(fetchCalls).toBe(0); }); test("anthropic sonnet 4.6 keeps the upstream 1M context window", () => { @@ -5511,11 +5808,11 @@ describe("Codex catalog routed normalization", () => { const expected = [ { slug: "deepseek/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, { slug: "deepseek/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, - { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, - { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max"] }, + { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max"] }, + { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max"] }, { slug: "zai/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, { slug: "zai/glm-5.2[1m]", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, { slug: "zhipu-bigmodel/glm-4.6", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index c893af1d65..419ed0da6c 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -8,6 +8,8 @@ */ import { afterEach, describe, expect, test } from "bun:test"; import { + copyFileSync, + rmSync, existsSync, lstatSync, mkdirSync, @@ -19,7 +21,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { createHash } from "node:crypto"; import { Database } from "bun:sqlite"; @@ -138,12 +140,42 @@ class Fixture { readonly lockAllowlist: string[]; readonly serviceManagerEnv: Record; readonly serviceManagerPreloadPath: string | undefined; + readonly powerShellCacheEnv: Record = {}; readonly children: Array> = []; constructor() { for (const path of [this.codex, this.ocx, this.homeA, this.homeB, this.userprofileA, this.userprofileB, this.runtime, this.provider]) { mkdirSync(path, { recursive: true, mode: 0o700 }); } + try { + if (process.platform === "win32") { + // Fresh child profiles otherwise repeatedly rebuild PowerShell's command cache. + // Seed one owned copy per fixture; children must never update the parent cache. + const cache = join(this.root, "module-analysis-cache"); + this.powerShellCacheEnv.PSModuleAnalysisCachePath = cache; + const source = Object.entries(process.env).find(([key]) => + key.toLowerCase() === "psmoduleanalysiscachepath")?.[1]; + if (source && isAbsolute(source)) { + try { + const before = lstatSync(source); + if (before.isFile() && !before.isSymbolicLink()) { + copyFileSync(source, cache); + if (lstatSync(cache).size !== before.size) rmSync(cache, { force: true }); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ESTALE") { + throw new Error("Composed fixture could not read or copy the PowerShell module cache"); + } + rmSync(cache, { force: true }); + } + } + } + } catch (error) { + // Construction precedes registration in roots, so afterEach cannot own this cleanup. + rmSync(this.root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + throw error; + } this.lockPath = resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(this.codex)); this.lockAllowlist = [this.lockPath, `${this.lockPath}-journal`, `${this.lockPath}-wal`, `${this.lockPath}-shm`]; for (const path of this.lockAllowlist) { @@ -163,6 +195,7 @@ class Fixture { // Do not inherit ambient homes or proxy configuration. `process.execPath` // is absolute, so a PATH is intentionally unnecessary for CLI children. return { + ...this.powerShellCacheEnv, HOME: home, USERPROFILE: userprofile, // Windows os.homedir() follows USERPROFILE, while POSIX follows HOME. diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index c019abd9dd..542d85357a 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -42,6 +42,7 @@ import { import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; import type { OcxConfig } from "../../src/types"; import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; +import { catalogEntryEfforts } from "../../src/codex/catalog/effort"; import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests, @@ -766,6 +767,35 @@ test("convergence preserves only provider-local degraded rows", async () => { expect(models.some(entry => entry.slug === "external/vendor-model")).toBe(true); }); +test.each([ + { efforts: ["none", "minimal", "low"], expected: ["low"], defaultEffort: "low" }, + { efforts: ["none", "minimal"], expected: ["low"], defaultEffort: "low" }, + { efforts: [], expected: [], defaultEffort: undefined }, +])("observed convergence bounds canonical custom efforts $efforts without reviving stale max", async fixture => { + seedObservedRuntimeSupport(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); + const nextConfig = config(false); + nextConfig.customModels = [{ + id: "astra-custom", + provider: "openai", + modelId: "gpt-6-astra", + reasoningEfforts: fixture.efforts, + defaultReasoningEffort: "minimal", + }]; + writeCatalog([nativeEntry(), { + ...generatedRoutedEntry("openai/gpt-6-astra"), + opencodex_catalog_kind: "custom-model-v1", + supported_reasoning_levels: [{ effort: "minimal", description: "Stale" }, { effort: "max", description: "Stale max" }], + default_reasoning_level: "minimal", + }]); + for (let pass = 0; pass < 2; pass++) { + const catalog = await convergeCatalog(nextConfig); + const row = catalog.models?.find(entry => entry.slug === "openai/gpt-6-astra"); + expect(row ? catalogEntryEfforts(row) : undefined).toEqual(fixture.expected); + expect(row?.default_reasoning_level).toBe(fixture.defaultEffort); + if (fixture.expected.length === 0) expect(row).not.toHaveProperty("default_reasoning_level"); + } +}); + function legacyCustomDeletionConfig(): OcxConfig { const nextConfig = config(false); nextConfig.providers.offline = { diff --git a/tests/codex-integration/codex-convergence-contract.test.ts b/tests/codex-integration/codex-convergence-contract.test.ts index fec03ff13c..c7b19064d4 100644 --- a/tests/codex-integration/codex-convergence-contract.test.ts +++ b/tests/codex-integration/codex-convergence-contract.test.ts @@ -374,7 +374,7 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence paths", () => { const counts = Object.fromEntries([ ["provider-routes.ts", 8], ["model-routes.ts", 14], @@ -382,7 +382,14 @@ test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 converge ["agent-settings-routes.ts", 4], ].map(([file, expected]) => { const source = readFileSync(repoPath("src", "server", "management", file as string), "utf8"); - const count = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + const direct = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + const shared = source.match(/await convergeVisibleCatalogs\(\)/g)?.length ?? 0; + if (file === "model-routes.ts") { + const helper = source.slice(source.indexOf("const convergeVisibleCatalogs ="), source.indexOf('if (url.pathname ===')); + expect(helper.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); + expect(shared).toBe(5); + } + const count = file === "model-routes.ts" ? direct - 1 + shared : direct; expect(count).toBe(expected); expect(source).not.toContain("refreshCodexCatalogBestEffort"); return [file, count]; @@ -435,7 +442,9 @@ test("both model-preset write paths converge the Codex catalog", () => { const handlerBody = source.slice(handlerStart, source.indexOf("url.pathname ===", handlerStart + 1)); // The "all" branch and the materialize branch each converge; "custom" only moves the marker, // so it deliberately does not. - expect(handlerBody.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(2); + expect(handlerBody.match(/await convergeVisibleCatalogs\(\)/g)?.length).toBe(2); + const customBranch = handlerBody.slice(handlerBody.indexOf('if (mode === "custom")'), handlerBody.indexOf("const preset =")); + expect(customBranch).not.toMatch(/await converge(?:CodexCatalog|VisibleCatalogs)\(\)/); }); /** diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 17af29a362..f0f21652c0 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -1377,6 +1377,90 @@ describe("codex routing", () => { expect(getCodexUpstreamHealth("a")).toBeNull(); }); + test("flat bare error at inspection EOF records failed 502 without clearing avoidance", async () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + const now = 1_800_000_000_000; + recordCodexUpstreamOutcome(config, "a", 503, { now }); + recordCodexUpstreamOutcome(config, "a", 503, { now: now + 1 }); + recordCodexUpstreamOutcome(config, "a", 503, { now: now + 2 }); + expect(isCodexAccountSoftAvoided("a", now + 2)).toBe(true); + expect(getCodexUpstreamHealth("a")?.consecutiveFailures).toBe(3); + const terminals: Array<[string, number | undefined]> = []; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: " + JSON.stringify({ + type: "error", + message: "provider reset", + }) + "\n\n")); + controller.close(); + }, + }); + + await new Promise(resolve => { + consumeForInspection(stream, (status, override) => { + terminals.push([status, override]); + recordCodexUpstreamOutcome( + config, + "a", + status === "failed" ? (override ?? 502) : 200, + { now: now + 3, threadId: "bare-error-flat" }, + ); + }, undefined, resolve); + }); + + expect(terminals).toEqual([["failed", 502]]); + expect(isCodexAccountSoftAvoided("a", now + 3)).toBe(true); + expect(getCodexUpstreamHealth("a")).toMatchObject({ + consecutiveFailures: 4, + lastFailureStatus: 502, + }); + }); + + test("nested bare error at inspection EOF records failed 502 without clearing avoidance", async () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + const now = 1_800_000_000_000; + recordCodexUpstreamOutcome(config, "a", 503, { now }); + recordCodexUpstreamOutcome(config, "a", 503, { now: now + 1 }); + recordCodexUpstreamOutcome(config, "a", 503, { now: now + 2 }); + expect(isCodexAccountSoftAvoided("a", now + 2)).toBe(true); + expect(getCodexUpstreamHealth("a")?.consecutiveFailures).toBe(3); + const terminals: Array<[string, number | undefined]> = []; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: " + JSON.stringify({ + type: "error", + error: { message: "nested provider reset" }, + }) + "\n\n")); + controller.close(); + }, + }); + + await new Promise(resolve => { + consumeForInspection(stream, (status, override) => { + terminals.push([status, override]); + recordCodexUpstreamOutcome( + config, + "a", + status === "failed" ? (override ?? 502) : 200, + { now: now + 3, threadId: "bare-error-nested" }, + ); + }, undefined, resolve); + }); + + expect(terminals).toEqual([["failed", 502]]); + expect(isCodexAccountSoftAvoided("a", now + 3)).toBe(true); + expect(getCodexUpstreamHealth("a")).toMatchObject({ + consecutiveFailures: 4, + lastFailureStatus: 502, + }); + }); + test("transient cooldown escalates to 2m, 10m, then the 30m cap", () => { const config = makeConfig(); const now = 1_800_000_000_000; diff --git a/tests/codex-integration/codex-shim-readiness.test.ts b/tests/codex-integration/codex-shim-readiness.test.ts index 9780321d48..b6b4e04e9b 100644 --- a/tests/codex-integration/codex-shim-readiness.test.ts +++ b/tests/codex-integration/codex-shim-readiness.test.ts @@ -11,9 +11,16 @@ import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { codexShimReadinessWarnings } from "../../src/cli/codex-shim-readiness"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); +// This case proves a real install followed by advisory collection, not startup latency. +const SHIM_INSTALL_CHILD_MS = process.platform === "win32" ? SPAWN_BUDGET_MS : undefined; +const SHIM_INSTALL_CLEANUP_MS = 5_000; +const SHIM_INSTALL_CASE_MS = SHIM_INSTALL_CHILD_MS === undefined + ? 10_000 + : SHIM_INSTALL_CHILD_MS + SHIM_INSTALL_CLEANUP_MS; const ready = { routingKind: "native" as const, @@ -169,13 +176,18 @@ describe("Codex shim install readiness", () => { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`, }, encoding: "utf8", + timeout: SHIM_INSTALL_CHILD_MS, + killSignal: "SIGKILL", }); + if (result.error || result.signal !== null) { + throw new Error(`Shim install fixture did not complete: error=${result.error?.name ?? "none"} signal=${result.signal ?? "none"}`); + } expect(result.status).toBe(0); expect(result.stdout).toStartWith("⚠️ Codex autostart shim installed"); expect(result.stderr).toContain("Codex routing could not be verified"); } finally { removeTreeWithRetry(root); } - }, 10_000); + }, SHIM_INSTALL_CASE_MS); }); diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index 3eb44394a9..12f4751a04 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; @@ -106,7 +106,8 @@ function withInstalledShim(run: (paths: { writeFileSync(wrapper, process.platform === "win32" ? `real ${wrapper}\n` : "#!/bin/sh\necho real\n", "utf8"); if (process.platform !== "win32") chmodSync(wrapper, 0o755); } - expect(installCodexShim().installed).toBe(true); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); const statePath = join(home, "codex-shim.json"); const state = JSON.parse(readFileSync(statePath, "utf8")) as { wrappers: Array<{ wrapperPath: string; backupPath: string }> }; run({ @@ -713,53 +714,149 @@ os._exit(0) }, ); - test("Unix install rolls back when launcher validation times out", () => { - if (process.platform === "win32") return; - - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-home-")); - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - const codexPath = join(binDir, "codex"); - const childPidPath = join(home, "probe-child.pid"); - const groupIdPath = join(home, "probe-group.pid"); - const original = `#!/bin/sh + for (const [name, mode] of [ + ["Unix install rolls back when launcher validation times out", "native"], + ["Unix timeout cleanup observes disappearance after EPERM without another signal", "disappears"], + ["Unix timeout cleanup preserves EPERM when passive probes keep failing", "permission"], + ["Unix timeout cleanup preserves EPERM when passive probes report a live group", "live"], + ] as const) { + test(name, () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const childPidPath = join(home, "probe-child.pid"); + const groupIdPath = join(home, "probe-group.pid"); + const original = `#!/bin/sh /bin/sleep 30 & child=$! printf '%s\\n' "$child" > "${childPidPath}" printf '%s\\n' "$$" > "${groupIdPath}" wait "$child" `; - try { - process.env.PATH = prependPath(binDir, oldPath); - process.env.OPENCODEX_HOME = home; - writeFileSync(codexPath, original, "utf8"); - chmodSync(codexPath, 0o755); + const nativeKill = process.kill.bind(process); + const permissionError = Object.assign(new Error("fixture termination denied"), { code: "EPERM" }); + let restoreKill: (() => void) | undefined; + let killCalls = 0; + let passiveProbes = 0; + let terminationStartedAt = 0; + let terminationElapsedMs = 0; + let childPid = 0; + let groupId = 0; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); - const installed = installCodexShim(); + if (mode !== "native") { + const killSpy = spyOn(process, "kill").mockImplementation((pid, signal) => { + // The child writes its own group identity before the parent resumes from spawnSync. + if (groupId === 0 && existsSync(groupIdPath)) { + const recorded = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + if (Number.isInteger(recorded) && recorded > 1) groupId = recorded; + } + if (groupId <= 1 || pid !== -groupId) return nativeKill(pid, signal); + if (signal === "SIGKILL") { + killCalls += 1; + if (killCalls === 1) terminationStartedAt = Date.now(); + throw permissionError; + } + if (signal === 0 && killCalls > 0) { + passiveProbes += 1; + if (mode === "permission" || (mode === "disappears" && passiveProbes === 1)) { + throw permissionError; + } + if (mode === "disappears") { + throw Object.assign(new Error("fixture group disappeared"), { code: "ESRCH" }); + } + return true; + } + return nativeKill(pid, signal); + }); + restoreKill = () => { killSpy.mockRestore(); }; + } + let installed: ReturnType; + try { + installed = installCodexShim(); + terminationElapsedMs = Date.now() - terminationStartedAt; + } finally { + restoreKill?.(); + restoreKill = undefined; + if (mode !== "native" && existsSync(groupIdPath)) { + groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + if (Number.isInteger(groupId) && groupId > 1) { + // Join only this fixture's real group, even when a later assertion fails. + // Synthetic ESRCH never proves cleanup; these observations use the native binding. + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { nativeKill(-groupId, 0); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") break; + } + Bun.sleepSync(10); + } + } + } + } + childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); + groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); - expect(installed.installed).toBe(false); - expect(installed.message).toContain("did not finish --version within 5000ms"); - expect(installed.message).toContain("original launcher was restored"); - expect(readFileSync(codexPath, "utf8")).toBe(original); - expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); - const childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); - const groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); - expect(Number.isInteger(childPid)).toBe(true); - expect(Number.isInteger(groupId)).toBe(true); - expectProcessGroupMissing(groupId); - const childState = processState(childPid); - expect(childState === "" || childState.startsWith("Z")).toBe(true); - } finally { - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } - }, 10_000); + expect(installed.installed).toBe(false); + if (mode === "native" || mode === "disappears") { + expect(installed.message).toContain("did not finish --version within 5000ms"); + } else { + expect(installed.message).toContain("[phase=termination; code=EPERM; status=124; signal=none]"); + expect(installed.message).not.toContain("did not finish --version within 5000ms"); + expect(terminationElapsedMs).toBeGreaterThanOrEqual(1_000); + } + if (mode !== "native") { + expect(killCalls).toBe(1); + expect(passiveProbes).toBeGreaterThanOrEqual(2); + } + expect(installed.message).toContain("original launcher was restored"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + expect(Number.isInteger(childPid)).toBe(true); + expect(Number.isInteger(groupId)).toBe(true); + expect(childPid).toBeGreaterThan(1); + expect(groupId).toBeGreaterThan(1); + expectProcessGroupMissing(groupId); + const childState = processState(childPid); + expect(childState === "" || childState.startsWith("Z")).toBe(true); + } catch (error) { + restoreKill?.(); + restoreKill = undefined; + let groupState = "unrecorded"; + if (groupId > 1) { + try { nativeKill(-groupId, 0); groupState = "present"; } + catch (probeError) { + const code = (probeError as NodeJS.ErrnoException).code; + groupState = code === "ESRCH" || code === "EPERM" ? code : "other-error"; + } + } + let childState = "unrecorded"; + if (childPid > 1) { + try { childState = processState(childPid).replace(/[^A-Za-z+<>N]/g, "").slice(0, 16) || "absent"; } + catch { childState = "unavailable"; } + } + console.error("[shim-timeout-fixture]", { mode, groupId, childPid, groupState, childState, killCalls, passiveProbes }); + throw error; + } finally { + restoreKill?.(); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(binDir); + removeTreeWithRetry(home); + } + }, 10_000); + } test("Unix install preserves an existing backup without probing or mutation", () => { if (process.platform === "win32") return; diff --git a/tests/codex-integration/codex-sync-api.test.ts b/tests/codex-integration/codex-sync-api.test.ts index 7c24e51073..1a2170cc95 100644 --- a/tests/codex-integration/codex-sync-api.test.ts +++ b/tests/codex-integration/codex-sync-api.test.ts @@ -20,7 +20,13 @@ const repoRoot = resolveRepoRoot(); const CHILD_TIMEOUT_MS = SPAWN_BUDGET_MS - 5_000; const COMPETING_OFF_REAP_MS = 5_000; const COMPETING_OFF_BOOT_MS = SPAWN_BUDGET_MS - COMPETING_OFF_REAP_MS; -const COMPETING_OFF_CHILD_MS = 2 * COMPETING_OFF_BOOT_MS + COMPETING_OFF_REAP_MS; +// Windows preparation performs real identity/admission preflight before discovery. +// Reserve that work separately: CI observed 52.7s before the flip could even start. +// The second process still keeps its original boot and reap limits. +const COMPETING_OFF_PREPARATION_MS = process.platform === "win32" + ? 2 * COMPETING_OFF_BOOT_MS + : COMPETING_OFF_BOOT_MS; +const COMPETING_OFF_CHILD_MS = COMPETING_OFF_PREPARATION_MS + COMPETING_OFF_BOOT_MS + COMPETING_OFF_REAP_MS; const COMPETING_OFF_TEST_MS = COMPETING_OFF_CHILD_MS + COMPETING_OFF_REAP_MS; let prevCodexHome: string | undefined; let prevOpenCodexHome: string | undefined; diff --git a/tests/codex-integration/codex-transition-state-race.test.ts b/tests/codex-integration/codex-transition-state-race.test.ts index 31c87a194a..9d5aef73b8 100644 --- a/tests/codex-integration/codex-transition-state-race.test.ts +++ b/tests/codex-integration/codex-transition-state-race.test.ts @@ -19,9 +19,16 @@ import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, } from "../../src/codex/user-identity"; +import { WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../../src/lib/windows-user-principal"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { repoPath } from "../helpers/repo-root"; -const CHILD_TIMEOUT_MS = 10_000; +// Before publishing ready, a Windows probe resolves its SID and known folder +// with two separately bounded PowerShell calls. The harness must cover both; +// this watchdog bounds the fixture, not coordinator lock/transition latency. +const CHILD_TIMEOUT_MS = watchdogMs(process.platform === "win32" + ? 2 * WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS + 5_000 + : 10_000); const transitionStateModuleUrl = pathToFileURL( repoPath("src", "codex", "transition-state.ts"), ).href; @@ -169,9 +176,18 @@ async function collectProbe(child: ReturnType): Promise { +async function waitForFiles( + paths: readonly string[], + children: readonly ReturnType[], +): Promise { const deadline = Date.now() + CHILD_TIMEOUT_MS; while (!paths.every(existsSync)) { + for (const child of children) { + if (child.exitCode !== null) { + const stderr = await new Response(child.stderr).text(); + throw new Error(`probe exited before barrier (code=${child.exitCode}): ${stderr}`); + } + } if (Date.now() >= deadline) throw new Error(`timed out waiting for ${paths.join(", ")}`); await Bun.sleep(5); } @@ -198,9 +214,9 @@ test("two real processes racing first use publish exactly one initial transition )); try { - await waitForFiles([join(barrier, "a.ready"), join(barrier, "b.ready")]); + await waitForFiles([join(barrier, "a.ready"), join(barrier, "b.ready")], children); writeFileSync(releasePath, "go"); - await waitForFiles([join(barrier, "a.outcome"), join(barrier, "b.outcome")]); + await waitForFiles([join(barrier, "a.outcome"), join(barrier, "b.outcome")], children); writeFileSync(retryPath, "retry-busy-loser"); const results = await Promise.all(children.map(collectProbe)); @@ -248,9 +264,10 @@ test("two real processes racing first use publish exactly one initial transition } } finally { for (const child of children) child.kill(); + await Promise.all(children.map(child => child.exited)); cleanupSandbox(sandbox); } -}, { timeout: 30_000 }); +}, { timeout: 4 * CHILD_TIMEOUT_MS }); test("different OPENCODEX_HOME claimants advance the row under one CODEX_HOME", async () => { const sandbox = createSandbox("shared-codex-home"); @@ -293,7 +310,7 @@ test("different OPENCODEX_HOME claimants advance the row under one CODEX_HOME", } finally { cleanupSandbox(sandbox); } -}, { timeout: 30_000 }); +}, { timeout: 4 * CHILD_TIMEOUT_MS }); test("a locked coordinator returns the exact typed busy outcome", async () => { const sandbox = createSandbox("busy"); @@ -313,7 +330,7 @@ test("a locked coordinator returns the exact typed busy outcome", async () => { controller?.close(); cleanupSandbox(sandbox); } -}, { timeout: 20_000 }); +}, { timeout: 3 * CHILD_TIMEOUT_MS }); test("an unsafe coordinator path returns the exact typed unsafe-path outcome", async () => { const sandbox = createSandbox("unsafe-path"); @@ -329,4 +346,4 @@ test("an unsafe coordinator path returns the exact typed unsafe-path outcome", a } cleanupSandbox(sandbox); } -}, { timeout: 20_000 }); +}, { timeout: 2 * CHILD_TIMEOUT_MS }); diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 19eb2548e5..4ec67d2196 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -101,14 +101,13 @@ function installModeHintRuntime(supported = true): string { describe("catalog ultra (always-on)", () => { const routed = [{ id: "glm-5.2", provider: "opencode-go", reasoningEfforts: ["low", "medium", "high", "xhigh"] }]; - test("routed + old natives always advertise mock max AND ultra", () => { + test("Go keeps declared efforts while old natives retain mock tiers", () => { const entries = buildCatalogEntries(template(), ["gpt-5.5"], routed as never, [], false); const native = entries.find(e => e.slug === "gpt-5.5")!; const glm = entries.find(e => e.slug === "opencode-go/glm-5.2")!; expect(efforts(native)).toContain("ultra"); expect(efforts(native)).toContain("max"); - expect(efforts(glm)).toContain("ultra"); - expect(efforts(glm)).toContain("max"); // mock max: adapters/wire clamp keep it honest + expect(efforts(glm)).toEqual(["low", "medium", "high", "xhigh"]); }); test("gpt-5.6-sol keeps native ultra + max; luna has max but no native ultra (upstream ladder)", () => { diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 1c4924d3de..98174c3848 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -858,6 +858,101 @@ describe("combo failure policy and advancement", () => { expect(pick?.target.provider).toBe("b"); }); + test.each(["pool", "direct"] as const)("defers native %s quota decisions to account and model scoped authentication", mode => { + const now = 50_000; + const config = baseConfig({ + providers: { + a: { + adapter: "openai-responses", + authMode: "forward", + codexAccountMode: mode, + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb" }, + }, + }); + setCachedProviderQuotaForTests("a", { weeklyPercent: 100, updatedAt: now }); + + const pick = pickComboTarget(config, "free", { now }); + + expect(pick?.target.provider).toBe("a"); + }); + + test("native provider summary quota does not suppress a bounded cooldown wait", async () => { + const now = 50_000; + const config = baseConfig({ + providers: { + a: { + adapter: "openai-responses", + authMode: "forward", + codexAccountMode: "pool", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + }, + combos: { + free: { + targets: [{ provider: "a", model: "m1" }], + waitForCooldownMs: 2_000, + }, + }, + }); + setCachedProviderQuotaForTests("a", { weeklyPercent: 100, updatedAt: now }); + coolComboTarget("free", { provider: "a", model: "m1" }, { now, cooldownMs: 1_000 }); + const sleeps: number[] = []; + + const pick = await pickComboTargetWithWait(config, "free", { + now, + waitForCooldownMs: 2_000, + sleep: async ms => { sleeps.push(ms); }, + }); + + expect(pick?.target.provider).toBe("a"); + expect(sleeps).toEqual([1_000]); + }); + + test("still filters exhausted quota on a noncanonical forward destination", () => { + const now = 50_000; + const config = baseConfig({ + providers: { + a: { + adapter: "openai-responses", + authMode: "forward", + codexAccountMode: "pool", + baseUrl: "https://chatgpt.com.example/backend-api/codex", + }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb" }, + }, + }); + setCachedProviderQuotaForTests("a", { weeklyPercent: 100, updatedAt: now }); + + const pick = pickComboTarget(config, "free", { now }); + + expect(pick?.target.provider).toBe("b"); + }); + + test("retains caller eligibility restrictions for native targets", () => { + const now = 50_000; + const config = baseConfig({ + providers: { + a: { + adapter: "openai-responses", + authMode: "forward", + codexAccountMode: "pool", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb" }, + }, + }); + setCachedProviderQuotaForTests("a", { weeklyPercent: 100, updatedAt: now }); + + const pick = pickComboTarget(config, "free", { + now, + eligible: target => target.provider !== "a", + }); + + expect(pick?.target.provider).toBe("b"); + }); + test("elapsed quota reset does not permanently blacklist a provider", () => { const now = 50_000; const config = baseConfig(); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 8262b242d7..72d596e75f 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -33,6 +33,7 @@ import { } from "../../src/codex/quota"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; let testDir: string; let previousHome: string | undefined; @@ -307,8 +308,12 @@ describe("main policy quota durability and lifecycle", () => { console.log(JSON.stringify({ before, other, legacy: getAccountQuota("__main__"), policy: getMainPolicyQuota(), credentialMatches: matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a") })); `; + // The fresh process is the restart oracle, including its module startup. + // Probe 34053484372 retained all assertions and caught an identity-guard + // mutation with this Windows budget; the previous 10s killed a healthy 12s delay. const child = Bun.spawnSync({ - cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, timeout: 10_000, + cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, + timeout: process.platform === "win32" ? SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS : 10_000, }); expect(child.exitCode).toBe(0); const result = JSON.parse(child.stdout.toString()); @@ -317,7 +322,7 @@ describe("main policy quota durability and lifecycle", () => { expect(result.legacy).toBeNull(); expect(result.policy).toEqual(quota); expect(result.credentialMatches).toBe(false); - }); + }, SPAWN_BUDGET_MS); } test("unrelated persistence hydrates and retains policy after legacy TTL expiry", () => { diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts index cf516f10e3..2fb3db08f8 100644 --- a/tests/codex-integration/model-visibility-management-api.test.ts +++ b/tests/codex-integration/model-visibility-management-api.test.ts @@ -1,6 +1,6 @@ import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync} from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { nativeModelRows } from "../../src/codex/catalog"; import { loadConfig, replacePersistedConfig, saveConfig } from "../../src/config"; @@ -9,6 +9,9 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { inMemoryManagementPersistence, isolatedDiskManagementPersistence } from "../helpers/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { listManagementModelRows, type ManagementModelRow } from "../../src/server/management/model-rows"; +import { routedSlug } from "../../src/providers/slug-codec"; const TEST_DIR = join(tmpdir(), `.tmp-model-visibility-management-${process.pid}`); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -392,4 +395,322 @@ describe("atomic model visibility management", () => { expect(loadConfig()).toEqual(before); }); }); -import { ManagementRequest as Request } from "../helpers/management-auth"; + +test("configured manual OpenAI rows can be toggled alongside native rows", async () => { + const config = loadConfig(); + config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; + config.customModels = [{id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}]; + config.disabledModels = ["openai/gpt-5.5", "gpt-5.4"]; + expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false}],enabled:true},config)).status).toBe(200); + expect(config.disabledModels).toEqual(["gpt-5.4"]); + expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false},{id:"gpt-5.4",native:true}],enabled:false},config)).status).toBe(200); + expect(config.disabledModels).toContain("openai/gpt-5.5"); + expect(config.disabledModels).toContain("gpt-5.4"); + expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"not-configured",native:false}],enabled:true},config)).status).toBe(400); +}); + +test("provider-group toggles persist mixed native and manual OpenAI targets together", async () => { + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", selectedModels: ["gpt-5.5"], + }; + config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; + const unrelatedDisabled = [...config.disabledModels!]; + const unrelatedProvider = structuredClone(config.providers["google-antigravity"]); + const targets = [{ id: "gpt-5.5", native: false }, { id: "gpt-5.4", native: true }]; + saveConfig(config); + + const disabled = await putWithConfig({ scope: "provider", provider: "openai", targets, enabled: false }, config); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toMatchObject({ ok: true, scope: "provider", provider: "openai", enabled: false }); + expect(config.disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.4"]); + expect(config.providers.openai.selectedModels).toEqual(["gpt-5.5"]); + expect(loadConfig().disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.4"]); + expect(loadConfig().providers.openai.selectedModels).toEqual(["gpt-5.5"]); + expect(loadConfig().providers["google-antigravity"]).toEqual(unrelatedProvider); + expect(refreshes).toBe(1); + + const enabled = await putWithConfig({ scope: "provider", provider: "openai", targets, enabled: true }, config); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ ok: true, scope: "provider", provider: "openai", enabled: true }); + expect(config.disabledModels).toEqual(unrelatedDisabled); + expect(config.providers.openai.selectedModels).toBeUndefined(); + expect(loadConfig().disabledModels).toEqual(unrelatedDisabled); + expect(loadConfig().providers.openai.selectedModels).toBeUndefined(); + expect(loadConfig().providers["google-antigravity"]).toEqual(unrelatedProvider); + expect(refreshes).toBe(2); +}); + +test("an invalid trailing target leaves a mixed OpenAI provider-group update atomic", async () => { + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", selectedModels: ["gpt-5.5"], + }; + config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; + saveConfig(config); + + for (const enabled of [false, true]) { + // Both valid targets would change state before the final invalid target is reached. + config.disabledModels = enabled ? ["other/keep", "openai/gpt-5.5", "gpt-5.4"] : ["other/keep"]; + saveConfig(config); + const before = structuredClone(config); + const persistedBefore = loadConfig(); + for (const invalid of [{ id: "not-configured", native: false }, { id: "gpt-9.9-imaginary", native: true }]) { + const response = await putWithConfig({ + scope: "provider", provider: "openai", enabled, + targets: [{ id: "gpt-5.5", native: false }, { id: "gpt-5.4", native: true }, invalid], + }, config); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "invalid model visibility target" }); + expect(config).toEqual(before); + expect(loadConfig()).toEqual(persistedBefore); + expect(refreshes).toBe(0); + } + } +}); + +test("manual models replace management rows with the same provider/id and deletion restores natives", async () => { + const config = loadConfig(); + config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; + config.customModels = [ + {id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}, + {id:"manual-google",provider:"google-antigravity",modelId:"gemini-3.1-pro",contextWindow:128_000}, + ]; + config.codexAccountNamespaces = { desktop: "@main" }; + config.codexAccountPickerEnabled = true; + const accountModel = "gpt-5.5-account-fixture"; + const qualifiedId = `desktop/${accountModel}`; + writeFileSync(join(isolatedCodexHome!.path, "models_cache.json"), JSON.stringify({ + models: [{ + slug: accountModel, supported_in_api: true, visibility: "list", + base_instructions: "You are Codex.", comp_hash: null, shell_type: "unified_exec", + supported_reasoning_levels: [{ effort: "medium" }], model_messages: {}, + }], + })); + // Even an exact qualified-ID collision must preserve the account-bound native route. + config.customModels.push({ id: "manual-qualified", provider: "openai", modelId: qualifiedId }); + const rows = await listManagementModelRows(config,{entitlementWaitMs:0}); + expect(rows.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ + expect.objectContaining({namespaced:"openai/gpt-5.5",custom:true,customId:"manual-gpt",contextWindow:128_000,fastRowAvailable:true}), + ]); + expect(rows.filter(row=>row.provider==="google-antigravity" && row.id==="gemini-3.1-pro")).toHaveLength(1); + expect(rows.filter(row => row.id === qualifiedId && row.native)).toEqual([ + expect.objectContaining({ namespaced: qualifiedId, provider: "openai", native: true }), + ]); + config.disabledModels = ["openai/gpt-5.5"]; + const disabledRows = await listManagementModelRows(config, { entitlementWaitMs: 0 }); + expect(disabledRows.find(row => row.namespaced === "openai/gpt-5.5")).toMatchObject({ + custom: true, disabled: true, fastRowAvailable: false, + }); + config.disabledModels = []; + config.customModels = []; + const restored = await listManagementModelRows(config,{entitlementWaitMs:0}); + expect(restored.some(row => row.id === qualifiedId && row.native)).toBe(true); + expect(restored.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ + expect.objectContaining({namespaced:"gpt-5.5",native:true}), + ]); +}); + +test("manual OpenAI visibility preserves the pending-selection error contract", async () => { + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", + initialModelSelection: { version: 1, registrationId: "11111111-1111-4111-8111-111111111111", status: "pending" }, + }; + config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; + const before = structuredClone(config); + for (const target of [{ id: "gpt-5.5", native: false }, { id: "not-configured", native: false }]) { + const response = await putWithConfig({ scope: "models", provider: "openai", targets: [target], enabled: true }, config); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "initial_model_selection_pending" }); + expect(config).toEqual(before); + } + expect((await putWithConfig({ scope: "invalid", provider: "openai", targets: [], enabled: true }, config)).status).toBe(400); + expect(config).toEqual(before); +}); + +describe("provider workspace custom-model API round trips", () => { + async function request(method: "GET" | "POST" | "DELETE", path: string, body?: unknown): Promise { + const url = new URL(path, "http://localhost"); + const response = await handleManagementAPI(new Request(url, { + method, + ...(body === undefined ? {} : { + headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), + }), url, loadConfig(), { + createManagementConvergeCodex: catalogConvergenceFactory(() => { refreshes += 1; }), + }); + if (!response) throw new Error(`management route was not handled: ${path}`); + return response; + } + + async function createCustom(provider: string, modelId: string): Promise { + const response = await request("POST", "/api/custom-models", { provider, modelId }); + expect(response.status).toBe(201); + const created = await response.json() as { id: string; provider: string; modelId: string }; + expect(created.provider).toBe(provider); + expect(created.modelId).toBe(modelId); + expect(typeof created.id).toBe("string"); + expect(created.id.length).toBeGreaterThan(0); + return created.id; + } + + async function readRows(): Promise { + const response = await request("GET", "/api/models"); + expect(response.status).toBe(200); + return await response.json() as ManagementModelRow[]; + } + + async function readCustoms(): Promise> { + const response = await request("GET", "/api/custom-models"); + expect(response.status).toBe(200); + return await response.json() as Array<{ id: string; provider: string; modelId: string }>; + } + + test("custom-only DELETE then POST creates a new stable id without clearing hides or allowlists", async () => { + const provider = "google-antigravity"; + const modelId = "workspace-custom-only"; + const config = loadConfig(); + config.providers["other-static"] = { + adapter: "openai-chat", baseUrl: "https://other.example.test/v1", liveModels: false, + models: [], selectedModels: ["other-selected"], + }; + config.disabledModels!.push(routedSlug(provider, modelId), routedSlug("other-static", modelId)); + saveConfig(config); + const hidden = [...loadConfig().disabledModels!]; + const selected = [...loadConfig().providers[provider].selectedModels!]; + const otherProvider = structuredClone(loadConfig().providers["other-static"]); + const firstId = await createCustom(provider, modelId); + const otherId = await createCustom("other-static", modelId); + const otherBefore = (await readRows()).find(row => row.customId === otherId); + expect(otherBefore).toBeDefined(); + + expect((await request("DELETE", `/api/custom-models/${encodeURIComponent(firstId)}`)).status).toBe(200); + expect((await readCustoms()).map(row => row.id)).toEqual([otherId]); + const afterDelete = await readRows(); + expect(afterDelete.some(row => row.provider === provider && row.id === modelId)).toBe(false); + expect(afterDelete.find(row => row.customId === otherId)).toEqual(otherBefore); + + const secondId = await createCustom(provider, modelId); + expect(secondId).not.toBe(firstId); + const reopened = (await readRows()).find(row => row.customId === secondId); + expect(reopened?.namespaced).toBe(routedSlug(provider, modelId)); + expect(reopened?.disabled).toBe(true); + expect((await readCustoms()).filter(row => row.provider === provider).map(row => row.id)).toEqual([secondId]); + expect(loadConfig().disabledModels).toEqual(hidden); + expect(loadConfig().providers[provider].selectedModels).toEqual(selected); + expect(loadConfig().providers["other-static"]).toEqual(otherProvider); + expect((await readRows()).find(row => row.customId === otherId)).toEqual(otherBefore); + expect(refreshes).toBe(4); // Three explicit POSTs and one DELETE; GETs never converge. + }); + + test.each([ + { bareHidden: false, routedHidden: true }, + { bareHidden: true, routedHidden: false }, + { bareHidden: true, routedHidden: true }, + ])("DELETE restores native identity with independent hides %j", async ({ bareHidden, routedHidden }) => { + const modelId = "gpt-5.5"; + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", selectedModels: ["manual-selection"], + }; + config.disabledModels!.push( + ...(bareHidden ? [modelId] : []), + ...(routedHidden ? [routedSlug("openai", modelId)] : []), + ); + saveConfig(config); + const hidden = [...loadConfig().disabledModels!]; + const selected = [...loadConfig().providers.openai.selectedModels!]; + const manualId = await createCustom("openai", modelId); + const otherId = await createCustom("google-antigravity", modelId); + const before = await readRows(); + const manual = before.filter(row => row.provider === "openai" && row.id === modelId); + expect(manual).toHaveLength(1); + expect(manual[0]!.customId).toBe(manualId); + expect(manual[0]!.namespaced).toBe(routedSlug("openai", modelId)); + expect(manual[0]!.disabled).toBe(routedHidden); + const otherBefore = before.find(row => row.customId === otherId); + expect(otherBefore).toBeDefined(); + + expect((await request("DELETE", `/api/custom-models/${encodeURIComponent(manualId)}`)).status).toBe(200); + expect((await readCustoms()).map(row => row.id)).toEqual([otherId]); + const after = await readRows(); + const native = after.filter(row => row.provider === "openai" && row.id === modelId); + expect(native).toHaveLength(1); + expect(native[0]!.native).toBe(true); + expect(native[0]!.customId).toBeUndefined(); + expect(native[0]!.namespaced).toBe(modelId); + expect(native[0]!.disabled).toBe(bareHidden); + expect(after.find(row => row.customId === otherId)).toEqual(otherBefore); + expect(loadConfig().disabledModels).toEqual(hidden); + expect(loadConfig().providers.openai.selectedModels).toEqual(selected); + expect(refreshes).toBe(3); + + // A deleted manual identity must not remain a valid non-native OpenAI visibility target. + const persistedBeforeInvalid = loadConfig(); + expect((await put({ scope: "models", provider: "openai", targets: [{ id: modelId }], enabled: false })).status).toBe(400); + expect((await request("DELETE", `/api/custom-models/${encodeURIComponent(manualId)}`)).status).toBe(404); + expect(loadConfig()).toEqual(persistedBeforeInvalid); + expect(refreshes).toBe(3); + }); + + test("DELETE of a custom override reveals its static provider row without hiding it", async () => { + const provider = "google-antigravity"; + const modelId = "claude-sonnet-4-6"; + const hidden = [...loadConfig().disabledModels!]; + const selected = [...loadConfig().providers[provider].selectedModels!]; + const id = await createCustom(provider, modelId); + const before = (await readRows()).filter(row => row.provider === provider); + expect(before.filter(row => row.id === modelId).map(row => row.customId)).toEqual([id]); + expect((await request("DELETE", `/api/custom-models/${encodeURIComponent(id)}`)).status).toBe(200); + const after = (await readRows()).filter(row => row.provider === provider); + const restored = after.filter(row => row.id === modelId); + expect(restored).toHaveLength(1); + expect(restored[0]!.customId).toBeUndefined(); + expect(restored[0]!.namespaced).toBe(routedSlug(provider, modelId)); + expect(restored[0]!.disabled).toBe(false); + expect(after).toHaveLength(before.length); + expect(await readCustoms()).toEqual([]); + expect(loadConfig().disabledModels).toEqual(hidden); + expect(loadConfig().providers[provider].selectedModels).toEqual(selected); + expect(refreshes).toBe(2); + }); + + test("DELETE of a qualified custom override preserves the independent account-native identity", async () => { + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + config.codexAccountNamespaces = { desktop: "@main" }; + config.codexAccountPickerEnabled = true; + const accountModel = "gpt-5.5-account-fixture"; + const qualifiedId = `desktop/${accountModel}`; + config.customModels = [{ id: "qualified-custom", provider: "openai", modelId: qualifiedId }]; + config.disabledModels!.push(qualifiedId, routedSlug("openai", qualifiedId)); + saveConfig(config); + writeFileSync(join(isolatedCodexHome!.path, "models_cache.json"), JSON.stringify({ models: [{ + slug: accountModel, supported_in_api: true, visibility: "list", + base_instructions: "You are Codex.", comp_hash: null, shell_type: "unified_exec", + supported_reasoning_levels: [{ effort: "medium" }], model_messages: {}, + }] })); + const hidden = [...loadConfig().disabledModels!]; + const before = (await readRows()).filter(row => row.id === qualifiedId); + expect(before).toHaveLength(2); + const nativeBefore = before.find(row => row.native === true); + expect(nativeBefore?.namespaced).toBe(qualifiedId); + expect(nativeBefore?.disabled).toBe(true); + expect(before.find(row => row.customId === "qualified-custom")?.namespaced).toBe(routedSlug("openai", qualifiedId)); + + expect((await request("DELETE", "/api/custom-models/qualified-custom")).status).toBe(200); + expect(await readCustoms()).toEqual([]); + expect((await readRows()).filter(row => row.id === qualifiedId)).toEqual([nativeBefore!]); + expect(loadConfig().disabledModels).toEqual(hidden); + expect(loadConfig().codexAccountNamespaces).toEqual({ desktop: "@main" }); + expect(refreshes).toBe(1); + }); +}); diff --git a/tests/codex-integration/multi-agent-compat.test.ts b/tests/codex-integration/multi-agent-compat.test.ts index 4949090e49..0d4e2c7abc 100644 --- a/tests/codex-integration/multi-agent-compat.test.ts +++ b/tests/codex-integration/multi-agent-compat.test.ts @@ -1308,6 +1308,66 @@ describe("injectDeveloperMessage", () => { expect(parsed.context.messages.map(message => message.role)).toEqual(["toolResult", "developer", "user"]); }); + for (const withLeadingResult of [false, true]) { + test(`aligns raw and parsed external-task guidance with leading result=${withLeadingResult}`, () => { + const leading: Record[] = withLeadingResult ? [{ + type: "function_call_output", call_id: "call_1", id: "result_fixture", + name: "exec", namespace: "functions", output: "previous tool output", + }] : []; + const external = { + type: "function_call_output", id: "external_fixture", name: "handoff_input", + namespace: "task_inbox", output: "current task", + }; + const rawInput: Record[] = [...leading, external]; + const raw = { model: "gpt-5.5", previous_response_id: "resp_remote", input: rawInput }; + const parsed = parseRequest(raw); + expect(parsed._continuationConversationMessageIndex).toBe(leading.length); + + injectDeveloperMessage(parsed, guidance); + + expect(raw.previous_response_id).toBe("resp_remote"); + expect(rawInput).toEqual([...leading, generatedItem(), external]); + expect(parsed.context.messages.map(message => message.role)).toEqual([ + ...(withLeadingResult ? ["toolResult"] : []), "developer", "user", + ]); + const reparsed = parseRequest(raw); + expect(reparsed.context.messages.map(({ role, content }) => ({ role, content }))).toEqual( + parsed.context.messages.map(({ role, content }) => ({ role, content })), + ); + }); + } + + test("keeps historical external tasks inside the replay prefix before changed guidance", () => { + const guidanceA = "A"; + const guidanceB = "B"; + const task = (id: string, output: string) => ({ + type: "function_call_output", id, name: "handoff_input", namespace: "task_inbox", output, + }); + const current = task("current_external", "current task"); + const rawInput = [ + generatedItem(guidanceA), task("previous_external", "previous task"), + { type: "message", role: "assistant", content: "done" }, current, + ]; + const history = structuredClone(rawInput.slice(0, 3)); + const raw = { model: "gpt-5.5", previous_response_id: "resp_remote", input: rawInput }; + const parsed = parseRequest(raw); + parsed._replayPrefixLen = 3; + parsed._continuationConversationMessageIndex = 3; + + injectDeveloperMessage(parsed, guidanceB); + + expect(raw.previous_response_id).toBe("resp_remote"); + expect(rawInput.slice(0, 3)).toEqual(history); + expect(rawInput.slice(3)).toEqual([generatedItem(guidanceB), current]); + expect(parsed.context.messages.map(message => message.role)).toEqual([ + "developer", "user", "assistant", "developer", "user", + ]); + const reparsed = parseRequest(raw); + expect(reparsed.context.messages.map(({ role, content }) => ({ role, content }))).toEqual( + parsed.context.messages.map(({ role, content }) => ({ role, content })), + ); + }); + test("keeps raw and parsed stateful placement aligned across reconstructed compaction history", () => { const rawInput = [ { type: "message", role: "user", content: "current turn" }, diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index a872c5c99b..bc579cdffb 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -3,11 +3,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; +import { writeDesktop3pConfig, removeDesktop3pStandardPivot } from "../../src/claude/desktop-3p"; import { setIntegrationEnabled } from "../../src/codex/desired-state"; import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; -import { isolatedDiskManagementPersistence } from "../helpers/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let root = ""; @@ -33,7 +33,12 @@ async function dispatch(path: string, init?: RequestInit, deps: ManagementApiDep return handleManagementAPI(new Request(url, { ...init, headers: { Host: url.host, ...(init?.headers ?? {}) }, - }), url, inputConfig, { ...isolatedDiskManagementPersistence(), ...deps }); + }), url, inputConfig, { + writeDesktop3pConfig: (port, slugs, models, key, mode, profile, cap) => + writeDesktop3pConfig(port, slugs, models, key, mode, profile, cap, { lockPath: join(root, "lifecycle.sqlite") }), + removeDesktop3pStandardPivot: options => removeDesktop3pStandardPivot({ ...options, lifecycleLockDeps: { lockPath: join(root, "lifecycle.sqlite") } }), + ...deps, + }); } async function toggle(enabled: boolean, deps: ManagementApiDeps = {}) { @@ -211,7 +216,6 @@ test("post-commit unsafe and incomplete refusals disclose desired OFF without co }); expect(unsafe.status).toBe(409); expect(unsafe.body).toMatchObject({ reason: "metadata_unreadable", desiredEnabled: false }); - expect(persistedIntent()).toBe(false); writeFileSync(join(root, "config.json"), JSON.stringify(config())); const incomplete = await toggle(false, { @@ -225,7 +229,6 @@ test("post-commit unsafe and incomplete refusals disclose desired OFF without co desiredEnabled: false, residualPaths: [join(library, "owned.json.bak")], }); - expect(persistedIntent()).toBe(false); }); test("auto-apply re-reads desired state after catalog fetch and skips a concurrent OFF", async () => { diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts index 52044cde5a..0ac18f1ad2 100644 --- a/tests/codex-integration/native-model-toggle.test.ts +++ b/tests/codex-integration/native-model-toggle.test.ts @@ -296,7 +296,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { const over = nativeModelRows({ providerContextCaps: { openai: 2_000_000 } }); expect(over.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); expect(raised.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); - expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(1_000_000); + expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(922_000); }); test("nativeModelRows applies providerContextCaps.openai as a ceiling (#1430)", () => { @@ -313,6 +313,16 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(other.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); }); + test("remembered disabled caps do not narrow native windows or input budgets", () => { + const config = makeConfig({ providerContextCapValues: { openai: 128_000 } }); + expect(nativeContextLimits(config)).toEqual({}); + expect(nativeModelRows(config)).toEqual(nativeModelRows(makeConfig())); + expect(nativeModelRows(config).find(row => row.slug === "gpt-5.6-sol")).toMatchObject({ + contextWindow: 272_000, + maxInputTokens: 272_000, + }); + }); + test("native aliases suppress their native dashboard row and activate Desktop allowlist pruning", () => { const config = makeConfig({ disabledModels: ["gpt-5.6-sol", "gpt-5.5"], diff --git a/tests/codex-integration/native-profile-manager.test.ts b/tests/codex-integration/native-profile-manager.test.ts index 425f0a6c08..d65b02abe9 100644 --- a/tests/codex-integration/native-profile-manager.test.ts +++ b/tests/codex-integration/native-profile-manager.test.ts @@ -132,11 +132,12 @@ async function leavePendingJournal(f: Awaited } /** - * The first Bun child a busy windows-latest shard spawns can take several seconds just to - * boot the TS helper; on run 33595585136 that alone burned a private 5 s wait while the - * child was healthy. The crash case, which is the first spawn in the file, gets a wait - * sized inside its 15 s test budget. On timeout the child's stderr is part of the error so - * a real crash is not mistaken for a slow start. + * Readiness includes booting the Bun child and its TypeScript graph. The first Windows + * spawn can outlast an internal-operation deadline, so the crash case reserves 30 s of + * its existing 45 s spawn budget, leaving 15 s for exit and successor checks. + * Controlled probe 34051272609 reproduced a healthy 17 s readiness delay and still + * rejected a successor-lock-denial mutation; no lock assertion or outer budget changed. + * On timeout the child's stderr distinguishes a reported crash from a missing marker. */ // Gates on a spawned child reaching its marker: 8-19 s on windows-latest (run 33930757649). async function waitForPath(path: string, child?: ReturnType, waitMs = INTERNAL_DEADLINE_MS): Promise { @@ -198,7 +199,11 @@ describe("native main profile transactions", () => { const f = fixture(); const readyPath = join(f.root, "crash-ready"); const child = spawnLockHolder(f, readyPath, join(f.root, "unused-release"), { crash: true }); - await waitForPath(readyPath, child, INTERNAL_DEADLINE_MS); + await waitForPath( + readyPath, + child, + process.platform === "win32" ? SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS : INTERNAL_DEADLINE_MS, + ); expect(await child.exited).toBe(87); const successor = new NativeProfileManager({ ...f.options, lockWaitMs: 250 }); diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index 0a2ff01817..61a98c4091 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { ConfigMutationLockError, loadConfig, saveConfig, withConfigMutationLockSync } from "../../src/config"; +import { ConfigMutationLockError, deleteConfigTopLevelKey, getConfigPath, initializePersistedConfigIfMissing, loadConfig, observeInitialConfigState, readConfigGeneration, saveConfig, withConfigMutationLockSync } from "../../src/config"; +import { InitialConfigPublicationError, publishInitialConfigNoReplace } from "../../src/config/initialize"; +import { nextAtomicTempSequence } from "../../src/config/atomic-write"; import { CodexCredentialRefreshLockTimeoutError, getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import type { OcxConfig } from "../../src/types"; import { ManagementRequest, managementHeaders } from "../helpers/management-auth"; @@ -14,7 +16,13 @@ let testRoot = ""; let previousOpencodexHome: string | undefined; function config(port = 10100): OcxConfig { - return { port, providers: {}, defaultProvider: "openai" }; + // Initial publication validates the candidate before reaching the filesystem; + // unlike a replacing save, it cannot accept a dangling default provider. + return { + port, + providers: { openai: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + defaultProvider: "openai", + }; } // A cold `bun -e` child that imports the full src/config.ts graph took longer than @@ -94,6 +102,11 @@ test("a live cross-process holder is not stolen and runtime writers fail immedia } const startedAt = performance.now(); expect(() => saveConfig(config(20200))).toThrow(ConfigMutationLockError); + // A busy initializer must not steal the holder even when its target is absent. + unlinkSync(getConfigPath()); + expect(() => initializePersistedConfigIfMissing(config(20200))).toThrow(ConfigMutationLockError); + expect(existsSync(getConfigPath())).toBe(false); + writeFileSync(getConfigPath(), JSON.stringify(config())); expect(() => saveCodexAccountCredential("busy-account", { accessToken: "busy-access", refreshToken: "busy-refresh", @@ -154,6 +167,194 @@ test("a throwing mutation releases the lock and leaves writers available", () => expect(loadConfig().port).toBe(50500); }); +const initTemps = () => readdirSync(testRoot).filter(name => name.includes(".ocx.") && name.endsWith(".tmp")); + +test("initial publication rejects a missing default provider before writing candidate bytes", () => { + let wrote = false; + expect(() => initializePersistedConfigIfMissing({ ...config(), providers: {} }, { + write() { wrote = true; }, + })).toThrow("Initial configuration is invalid."); + expect(wrote).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); +}); + +test("initial creation keeps candidate values and existing bytes; the explicit saver still updates", () => { + const candidate = { ...config(21001), operatorNote: "keep unknown fields" }; + expect(initializePersistedConfigIfMissing(candidate)).toBe("created"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(candidate); + if (process.platform !== "win32") expect(lstatSync(getConfigPath()).mode & 0o777).toBe(0o600); + expect(readConfigGeneration()).toMatchObject({ generation: { value: 1 } }); + const bytes = readFileSync(getConfigPath(), "utf8"); + expect(initializePersistedConfigIfMissing(config(21002))).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + saveConfig(config(21003)); + expect(loadConfig().port).toBe(21003); + expect(initTemps()).toEqual([]); +}); + +test.each(["", "not-json\n", '{"port":"broken"}', '\uFEFF{ "port":21002, "providers":{}, "defaultProvider":"openai", "unknown":42 }\n'])( + "init preserves occupied bytes without lock or backup creation: %j", bytes => { + writeFileSync(getConfigPath(), bytes); + const candidate = config(21001); + const original = structuredClone(candidate); + expect(initializePersistedConfigIfMissing(candidate)).toBe(bytes.startsWith("\uFEFF") ? "exists" : "invalid"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(candidate).toEqual(original); + expect(readdirSync(testRoot)).toEqual(["config.json"]); + }, +); + +test("init refuses a directory and a dangling symlink without following either", () => { + mkdirSync(getConfigPath()); + expect(observeInitialConfigState()).toBe("invalid"); + expect(initializePersistedConfigIfMissing(config())).toBe("invalid"); + removeTreeWithRetry(getConfigPath()); + const absent = join(testRoot, "absent"); + symlinkSync(absent, getConfigPath(), "file"); + expect(initializePersistedConfigIfMissing(config())).toBe("invalid"); + expect(lstatSync(getConfigPath()).isSymbolicLink()).toBe(true); + expect(existsSync(absent)).toBe(false); +}); + +test("real link collision preserves the winner and does not advance generation or mutate the candidate", () => { + withConfigMutationLockSync(() => {}); + const generation = readConfigGeneration(); + const winner = JSON.stringify(config(21002)) + "\n"; + const candidate = config(21001); + const original = structuredClone(candidate); + expect(initializePersistedConfigIfMissing(candidate, { + link(temp, target) { + writeFileSync(target, winner, { flag: "wx" }); + linkSync(temp, target); + }, + })).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(winner); + expect(readConfigGeneration()).toEqual(generation); + expect(candidate).toEqual(original); + expect(initTemps()).toEqual([]); +}); + +test("exclusive temp collision does not remove or modify somebody else's file", () => { + const sequence = nextAtomicTempSequence() + 1; + const occupied = `${getConfigPath()}.ocx.${process.pid}.${sequence}.tmp`; + writeFileSync(occupied, "other staged bytes", { flag: "wx" }); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes")).toThrow(InitialConfigPublicationError); + expect(readFileSync(occupied, "utf8")).toBe("other staged bytes"); + expect(existsSync(getConfigPath())).toBe(false); +}); + +test("failed hardening occurs before candidate bytes are written", () => { + let wrote = false; + expect(() => initializePersistedConfigIfMissing(config(), { + harden(_fd, temp) { + expect(readFileSync(temp, "utf8")).toBe(""); + throw new Error("ACL denied"); + }, + write() { wrote = true; }, + })).toThrow(InitialConfigPublicationError); + expect(wrote).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("partial write failure removes only the unpublished temporary name", () => { + expect(() => initializePersistedConfigIfMissing(config(), { + write(fd, bytes) { writeFileSync(fd, bytes.slice(0, 10)); throw new Error("disk full"); }, + })).toThrow(InitialConfigPublicationError); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test.each(["EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV", "EPERM"])("unsupported/denied link %s never falls back to replacement", code => { + try { + initializePersistedConfigIfMissing(config(), { + link() { throw Object.assign(new Error("do not print raw error"), { code }); }, + }); + throw new Error("expected link refusal"); + } catch (error) { + expect(error).toBeInstanceOf(InitialConfigPublicationError); + expect((error as InitialConfigPublicationError).hardLinkUnavailable).toBe(true); + } + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("a syscall error after a real link leaves the entire published candidate intact", () => { + const bytes = 'complete candidate bytes\n'; + expect(() => publishInitialConfigNoReplace(getConfigPath(), bytes, { + link(temp, target) { linkSync(temp, target); throw Object.assign(new Error("uncertain completion"), { code: "EIO" }); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(initTemps()).toEqual([]); +}); + +test("post-link identity failure cannot remove a concurrent replacement", () => { + expect(() => initializePersistedConfigIfMissing(config(21001), { + link(temp, target) { + linkSync(temp, target); + const replacement = join(testRoot, "replacement"); + writeFileSync(replacement, "concurrent-winner\n"); + renameSync(replacement, target); + }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe("concurrent-winner\n"); +}); + +test("cleanup failure retains full published/shared bytes and closes the descriptor", () => { + let closed = false; + let failure: unknown; + try { + publishInitialConfigNoReplace(getConfigPath(), "complete bytes", { + unlink() { throw new Error("sharing violation"); }, + close(fd) { closed = true; closeSync(fd); }, + }); + } catch (error) { failure = error; } + expect(failure).toMatchObject({ publication: "published", residualTemp: true }); + expect(closed).toBe(true); + expect(readFileSync(getConfigPath(), "utf8")).toBe("complete bytes"); + const temps = initTemps(); + expect(temps).toHaveLength(1); + expect(readFileSync(join(testRoot, temps[0]!), "utf8")).toBe("complete bytes"); +}); + +test("a shared unpublished inode is never scrubbed", () => { + const otherName = join(testRoot, "shared-candidate"); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + link(temp) { linkSync(temp, otherName); throw new Error("publication failed"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(otherName, "utf8")).toBe("candidate bytes"); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("a swapped temporary symlink is neither written through nor removed as our inode", () => { + const victim = join(testRoot, "victim"); + writeFileSync(victim, "untouched"); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + harden(_fd, temp) { unlinkSync(temp); symlinkSync(victim, temp, "file"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(victim, "utf8")).toBe("untouched"); + expect(existsSync(getConfigPath())).toBe(false); + expect(lstatSync(join(testRoot, initTemps()[0]!)).isSymbolicLink()).toBe(true); +}); + +test("descriptor close failure cannot scrub an already published config", () => { + expect(() => publishInitialConfigNoReplace(getConfigPath(), "complete bytes", { + close(fd) { closeSync(fd); throw new Error("close failed"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe("complete bytes"); +}); + +test("successful init adopts deletion provenance before a subsequent explicit save", () => { + const candidate = config(); + deleteConfigTopLevelKey(candidate, "hostname"); + expect(initializePersistedConfigIfMissing(candidate)).toBe("created"); + expect(candidate.configRebaseProvenance).toEqual({ version: 1, deletedTopLevelKeys: ["hostname"] }); + candidate.hostname = "127.0.0.1"; + saveConfig(candidate); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).hostname).toBe("127.0.0.1"); +}); + test("management API maps config mutation lock contention to retryable 503", async () => { saveConfig(config()); const readyPath = join(testRoot, "mgmt-holder-ready"); diff --git a/tests/fixtures/claude-desktop-network-guard.ts b/tests/fixtures/claude-desktop-network-guard.ts new file mode 100644 index 0000000000..25bac6e707 --- /dev/null +++ b/tests/fixtures/claude-desktop-network-guard.ts @@ -0,0 +1,34 @@ +/** Process-fixture guard: unexpected traffic must never reach a real provider. */ +import { appendFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const allowed = new Set(JSON.parse(process.env.OCX_TEST_ALLOWED_ORIGINS ?? "[]")); +const deniedFile = process.env.OCX_TEST_DENIED_REQUESTS; +const nativeFetch = globalThis.fetch; + +function permit(input: Parameters[0]): void { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (allowed.has(url.origin)) return; + // The real CLI can consult its own management endpoint during startup. + if (["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) { + try { + const record = JSON.parse(readFileSync(join(process.env.OPENCODEX_HOME!, "runtime-port.json"), "utf8")); + if (record.pid === process.pid && record.port === Number(url.port)) return; + } catch { /* no owned listener yet */ } + } + if (deniedFile) appendFileSync(deniedFile, url.origin + "\n"); + throw new Error("OCX_TEST_EXTERNAL_REQUEST_BLOCKED"); +} + +globalThis.fetch = Object.assign( + (...args: Parameters) => { + permit(args[0]); + return nativeFetch(...args); + }, + { + preconnect: (...args: Parameters) => { + permit(args[0]); + return nativeFetch.preconnect(...args); + }, + }, +); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4c94de762f..6565f12821 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -4,7 +4,6 @@ "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", "acl-error-classification.test.ts": "lib", - "actionlint-runner.test.ts": "ci-workflows", "active-registry-admission.test.ts": "codex-integration", "adapter-buffered-tool-conformance.test.ts": "adapters", "adapter-error-inline.test.ts": "adapters", @@ -14,19 +13,11 @@ "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", - "agent-roles-sync.test.ts": "routing", "agent-task-recovery-cache.test.ts": "server", "agent-task-recovery-combo.test.ts": "server", "agent-task-recovery-fallback.test.ts": "server", "agent-task-recovery-security.test.ts": "server", "agent-task-recovery.test.ts": "server", - "aistudio-bridge-endpoint.test.ts": "adapters/google", - "aistudio-credentials.test.ts": "adapters/google", - "aistudio-extension.test.ts": "adapters/google", - "aistudio-login-cli.test.ts": "adapters/google", - "aistudio-login-flow.test.ts": "adapters/google", - "aistudio-native-webkit.test.ts": "adapters/google", - "aistudio-session-sync.test.ts": "adapters/google", "alias-management-api.test.ts": "server", "alibaba-intl-token-plan.test.ts": "gui", "alibaba-region-backup.test.ts": "providers", @@ -78,16 +69,17 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", - "audit-high.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", - "auto-release-workflow.test.ts": "fork", "autostart-health.test.ts": "service", "azure-adapter.test.ts": "providers", "azure-model-router-tool-schema.test.ts": "providers", "baseten-provider.test.ts": "providers", "bearer-admission-routed-provider.test.ts": "codex-integration", - "benchmark-claude-tokens-script.test.ts": "claude-integration", "bounded-body.test.ts": "server", "bridge-legacy-shell-normalization.test.ts": "adapters", "bridge-lifecycle.test.ts": "adapters", @@ -104,6 +96,8 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", @@ -111,10 +105,11 @@ "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", + "chat-json-sse-fallback.test.ts": "responses", + "chat-refusal.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", - "check-hygiene.test.ts": "ci-workflows", "chutes-provider.test.ts": "providers", "ci-workflows.test.ts": "ci-workflows", "citation-markers.test.ts": "responses", @@ -128,21 +123,17 @@ "claude-auth-detect.test.ts": "claude-integration", "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", - "claude-certification.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", - "claude-client-version.test.ts": "claude-integration", - "claude-code-compatibility-manifest.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", + "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", - "claude-directive-auth.test.ts": "claude-integration", - "claude-directive-fallback.test.ts": "claude-integration", - "claude-directive-lifecycle.test.ts": "claude-integration", + "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", @@ -154,14 +145,9 @@ "claude-models-discovery.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", - "claude-reasoning-roundtrip.test.ts": "claude-integration", - "claude-route-callback.test.ts": "claude-integration", - "claude-session.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", - "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", - "claude-token-benchmark.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", "cli-account-pool-verbs.test.ts": "cli", @@ -202,6 +188,8 @@ "client-config-export.test.ts": "config", "client-config-new-clients.test.ts": "config", "client-connect.test.ts": "clients", + "client-injection-guard.test.ts": "codex-integration", + "client-lifecycle-lock.test.ts": "clients", "client-export-modality-enum.test.ts": "clients", "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", @@ -298,12 +286,11 @@ "codex-prompt-lock.test.ts": "codex-integration", "codex-prompt-route.test.ts": "codex-integration", "codex-prompt-text-probe.test.ts": "codex-integration", - "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", - "codex-quota-auto-refresh.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", + "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", - "codex-quota.test.ts": "providers", "codex-refresh.test.ts": "codex-integration", "codex-reset-credit-auto-redeem.test.ts": "codex-integration", "codex-reset-credit-operation-ledger.test.ts": "codex-integration", @@ -343,6 +330,7 @@ "command-code-quota.test.ts": "providers", "command-code-workspace-cache.test.ts": "providers", "commandcode-provider.test.ts": "providers", + "compaction-progress.test.ts": "responses", "compatibility-manifest.test.ts": "codex-integration", "compatibility-provider-equivalence.test.ts": "routing", "compatibility-version.test.ts": "ci-workflows", @@ -357,8 +345,6 @@ "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", "continuation-dedup.test.ts": "responses", - "conversation-progress.test.ts": "usage", - "core-fork-preservation.test.ts": "responses", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", "cost-scoring.test.ts": "usage", @@ -442,11 +428,10 @@ "desktop-3p-guard.test.ts": "clients", "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", + "desktop-remote-store.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", - "dev-auto-release-workflow.test.ts": "fork", - "dev-promotion-workflow.test.ts": "fork", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", @@ -535,8 +520,6 @@ "identity-neutralize.test.ts": "adapters", "init-backup-cleanup.test.ts": "service", "init-eof.test.ts": "service", - "initial-model-selection.test.ts": "providers", - "initial-selection-write-fence.test.ts": "providers", "injection-model-api.test.ts": "codex-integration", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", @@ -587,6 +570,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", @@ -622,29 +606,25 @@ "lab-read-filter-validation.test.ts": "lab", "lab-read-surfaces.test.ts": "lab", "legacy-shell-compat.test.ts": "responses", - "live-inference-workflow.test.ts": "ci-workflows", - "live-smoke-ci.test.ts": "ci-workflows", - "live-smoke-cli.test.ts": "ci-workflows", - "live-smoke-report.test.ts": "ci-workflows", "local-management-attestation.test.ts": "server", "local-management-capability.test.ts": "server", "local-management-direct-transport.test.ts": "server", "local-provider-reload-client.test.ts": "server", "local-token-detect.test.ts": "oauth", - "logs-filter.test.ts": "gui", "logs-model-tier-confirmation.test.ts": "gui", "logs-timezone.test.ts": "server", "loop-reasoning-replay.test.ts": "images", "loop.test.ts": "images", "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", + "macos-serial-lanes.test.ts": "ci-workflows", + "management-api-logs-metrics.test.ts": "server", "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", "main-account-hard-lock-recovery.test.ts": "codex-integration", "main-quota-evidence-validation.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", "main-quota-window-observation.test.ts": "codex-integration", - "management-api-logs-metrics.test.ts": "server", "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", @@ -663,11 +643,9 @@ "model-cache.test.ts": "codex-integration", "model-discovery-management-api.test.ts": "server", "model-display-names-management-api.test.ts": "codex-integration", - "model-metadata-resolver.test.ts": "codex-integration", "model-metadata-sync.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", - "model-selection-guidance.test.ts": "cli", "model-visibility-management-api.test.ts": "codex-integration", "models-page-groups.test.ts": "gui", "models-workspace-tabs.test.ts": "gui", @@ -701,6 +679,9 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", + "initial-model-selection.test.ts": "providers", + "initial-selection-write-fence.test.ts": "providers", + "model-selection-guidance.test.ts": "cli", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", @@ -712,7 +693,6 @@ "oauth-callback-binds.test.ts": "oauth", "oauth-callback-server.test.ts": "oauth", "oauth-device-code-contract.test.ts": "oauth", - "oauth-failover-optout-security.test.ts": "oauth", "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", @@ -730,7 +710,6 @@ "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", - "oauth-transport.test.ts": "oauth", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", @@ -764,6 +743,7 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", + "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -792,17 +772,13 @@ "policy-execution.test.ts": "routing", "port-reclaim.test.ts": "server", "ports.test.ts": "server", - "post-release-decision.test.cjs": "fork", "prime-client.test.ts": "clients", "privacy-mask-account.test.ts": "lib", "privacy-scan-meta-key.test.ts": "ci-workflows", - "privacy-scan.test.ts": "ci-workflows", "process-control-graceful.test.ts": "lib", "process-control.test.ts": "lib", "process-state.test.ts": "service", "project-config-warnings.test.ts": "codex-integration", - "promotion-audit-reuse.test.ts": "ci-workflows", - "promotion-backmerge.test.ts": "fork", "provider-account-quota-persistence.test.ts": "providers", "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", @@ -825,7 +801,6 @@ "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", "provider-static-model-discovery.test.ts": "providers", - "provider-tls-profile.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", @@ -845,6 +820,17 @@ "quota-scoring.test.ts": "usage", "qwen-cloud-endpoints.test.ts": "gui", "qwen38-preserve-reasoning.test.ts": "providers", + "reserve-availability.test.ts": "codex-integration", + "reserve-auth-context.test.ts": "codex-integration", + "reserve-catalog.test.ts": "codex-integration", + "reserve-catalog-lifecycle.test.ts": "codex-integration", + "reserve-claude-policy.test.ts": "server", + "reserve-dispatch.test.ts": "codex-integration", + "reserve-dispatch-ws.test.ts": "responses", + "reserve-helper-boundary.test.ts": "codex-integration", + "reserve-ingress.test.ts": "server", + "reserve-passive-revocation.test.ts": "codex-integration", + "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", "reasoning-effort.test.ts": "codex-integration", @@ -852,18 +838,13 @@ "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", - "register.test.ts": "fork", "relay-eager.test.ts": "server", - "release-candidate-publish-workflow.test.ts": "fork", - "release-candidate-workflow.test.ts": "fork", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", - "release-pr-workflow.test.ts": "fork", "release-version-line.test.ts": "ci-workflows", + "version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", "remove-tree-helper.test.ts": "lib", - "replit-pair-install-response.test.ts": "providers", - "replit-provider-setup.test.ts": "providers", "repo-hygiene.test.ts": "ci-workflows", "request-decompress.test.ts": "usage", "request-evidence.test.ts": "usage", @@ -872,17 +853,6 @@ "request-log-estimate-cap.test.ts": "usage", "request-log.test.ts": "usage", "request-pacing.test.ts": "usage", - "reserve-auth-context.test.ts": "codex-integration", - "reserve-availability.test.ts": "codex-integration", - "reserve-catalog-lifecycle.test.ts": "codex-integration", - "reserve-catalog.test.ts": "codex-integration", - "reserve-claude-policy.test.ts": "server", - "reserve-dispatch-ws.test.ts": "responses", - "reserve-dispatch.test.ts": "codex-integration", - "reserve-helper-boundary.test.ts": "codex-integration", - "reserve-ingress.test.ts": "server", - "reserve-passive-revocation.test.ts": "codex-integration", - "reserve-quota-scope.test.ts": "codex-integration", "response-model-identity.test.ts": "server", "responses-account-label.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", @@ -890,6 +860,8 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", + "responses-forward-incomplete-quota.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", @@ -949,10 +921,12 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", + "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", @@ -978,8 +952,8 @@ "service.test.ts": "service", "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", - "settings-main-account-hard-lock.test.ts": "config", "settings-oauth-open-browser.test.ts": "config", + "settings-main-account-hard-lock.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", "shutdown-drain.test.ts": "service", @@ -996,8 +970,6 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", - "smoke-fingerprint-cache.test.ts": "ci-workflows", - "smoke-runner.test.ts": "ci-workflows", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", "sse-failed-tail.test.ts": "responses", @@ -1033,25 +1005,7 @@ "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", "subagent-roster-retention.test.ts": "routing", - "sync-cli.test.ts": "fork", "sync-client-integrations.test.ts": "clients", - "sync-contained.test.ts": "fork", - "sync-detect.test.ts": "fork", - "sync-generic-cli.test.ts": "fork", - "sync-generic-http.test.ts": "fork", - "sync-lane.test.ts": "fork", - "sync-notify.test.ts": "fork", - "sync-overlap.test.ts": "fork", - "sync-ownership.test.ts": "fork", - "sync-pin.test.ts": "fork", - "sync-pr-mergeable.test.ts": "fork", - "sync-prepare.test.ts": "fork", - "sync-preservation.test.ts": "fork", - "sync-publish.test.ts": "fork", - "sync-pull-request.test.ts": "fork", - "sync-vendor-atomic.test.ts": "fork", - "sync-webhook.test.ts": "fork", - "sync-workflow.test.ts": "fork", "synthetic-tool.test.ts": "images", "system-env.test.ts": "server", "system-restart-client.test.ts": "cli", @@ -1059,10 +1013,6 @@ "system-restart.test.ts": "server", "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", - "telemetry-dispatcher.test.ts": "usage", - "telemetry-fingerprint.test.ts": "usage", - "telemetry-hook.test.ts": "usage", - "telemetry-ledger.test.ts": "usage", "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", @@ -1114,7 +1064,6 @@ "user-cost-overlay-provider-delete.test.ts": "usage", "v2-agent-message-failfast.test.ts": "server", "vercel-gateway-provider-routing.test.ts": "providers", - "version-line.test.ts": "ci-workflows", "vertex-catalog.test.ts": "adapters/google", "vision-anthropic.test.ts": "vision", "vision-backend-union.test.ts": "vision", @@ -1157,7 +1106,6 @@ "windows-user-principal.test.ts": "windows", "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", - "workflow-policy.test.ts": "ci-workflows", "ws-endpoint.test.ts": "responses", "ws-upstream-reuse.test.ts": "responses", "ws-upstream.test.ts": "responses", diff --git a/tests/gui/provider-workspace-auth.test.ts b/tests/gui/provider-workspace-auth.test.ts index edc407906b..e121142c01 100644 --- a/tests/gui/provider-workspace-auth.test.ts +++ b/tests/gui/provider-workspace-auth.test.ts @@ -82,13 +82,18 @@ async function providersPageSeam(): Promise { describe("workspace account integration seam", () => { test("passes account state and handlers into provider details", async () => { const source = await providersPageSeam(); + const page = await Bun.file("gui/src/pages/Providers.tsx").text(); + // Additional type imports must not obscure the runtime hook binding and its caller. + const poolBindings = page.match(/import\s*\{([^}]+)\}\s*from\s*["']\.\.\/hooks\/useProviderAccountPools["']/)?.[1]; + expect(poolBindings?.split(",").map(binding => binding.trim())).toContain("useProviderAccountPools"); + expect(page).toContain("const pools = useProviderAccountPools({"); expect(source).toContain("accountLoadState={accountLoadStates[item.name]"); expect(source).toContain("switchingAccountId={switchingAccount?.provider === item.name"); expect(source).toContain("onRetryAccounts: async provider => { await fetchAccountSets([provider]); }"); expect(source).toContain("key={item.name}"); expect(source).toContain("switchingAccountRef.current"); - expect(source).toContain("const refreshed = await fetchAccountSets([provider])"); - expect(source).toContain("if (!refreshed)"); + expect(source).toContain('const refreshed = await refreshAccountRosters({ provider, kind: "oauth" })'); + expect(source).toContain('if (!refreshed) { notify(t("pws.accountsLoadFailed"), false); return; }'); }); test("owns an accessible dynamic account panel instead of nesting auth in Settings", async () => { diff --git a/tests/gui/quota-bars-rows.test.ts b/tests/gui/quota-bars-rows.test.ts index 9933d5201c..8ca5a27579 100644 --- a/tests/gui/quota-bars-rows.test.ts +++ b/tests/gui/quota-bars-rows.test.ts @@ -3,6 +3,7 @@ import { barWidth, buildQuotaRows, formatResetFuture, + isCustomQuotaWindowIncomplete, isQuotaExhausted, isQuotaWarn, maxQuotaUtilisation, @@ -23,8 +24,6 @@ describe("buildQuotaRows (WP070)", () => { test("five-hour-only and weekly-only render single rows", () => { expect(buildQuotaRows(quota({ fiveHourPercent: 12 }), null, t).map(r => r.limitLabel)) .toEqual(["quota.fiveHourLimit"]); - expect(buildQuotaRows(quota({ shortPercent: 15, shortResetAt: 1780000000 }), null, t).map(r => r.limitLabel)) - .toEqual(["quota.fiveHourLimit"]); expect(buildQuotaRows(quota({ weeklyPercent: 30 }), null, t).map(r => r.limitLabel)) .toEqual(["quota.weeklyLimit"]); }); @@ -72,26 +71,99 @@ describe("buildQuotaRows (WP070)", () => { }); test("direct creditsUsd renders Total subscription credits with resetAt", () => { - const rows = buildQuotaRows(quota({ - fiveHourPercent: 0, - weeklyPercent: 0, + const reported = quota({ creditsUsd: { used: 89.96, limit: 90, remaining: 0.04, percent: 99.96, expiresAt: 1790430938000 }, + }); + expect(buildQuotaRows(reported, null, t)).toEqual([{ + customLabel: "Total subscription credits", + label: "quota.totalSubscriptionCredits", + limitLabel: "quota.totalSubscriptionCredits", + percent: 99.96, + resetAt: 1790430938000, + }]); + expect(maxQuotaUtilisation(reported)).toBe(99.96); + }); + + test("zero direct credit usage remains a row without an invented expiry", () => { + const reported = quota({ + creditsUsd: { used: 0, limit: 100, remaining: 100, percent: 0 }, + }); + const rows = buildQuotaRows(reported, null, t); + expect(rows).toHaveLength(1); + expect(rows[0]?.customLabel).toBe("Total subscription credits"); + expect(rows[0]?.percent).toBe(0); + expect(rows[0]?.resetAt).toBeUndefined(); + expect(maxQuotaUtilisation(reported)).toBe(0); + }); + + test("subscription credits rank after monthly and before other custom windows", () => { + const rows = buildQuotaRows(quota({ + fiveHourPercent: 10, + weeklyPercent: 40, + monthlyPercent: 70, + customWindows: [ + { label: "Gem", percent: 1 }, + { label: "API usage", percent: 55 }, + { label: "First-party models", percent: 25 }, + ], + creditsUsd: { used: 80, limit: 100, remaining: 20, percent: 80 }, }), null, t); expect(rows.map(r => r.limitLabel)).toEqual([ "quota.fiveHourLimit", "quota.weeklyLimit", + "quota.cursorFirstParty", + "quota.cursorApiUsage", + "quota.monthlyLimit", "quota.totalSubscriptionCredits", + "Gem", ]); - expect(rows[2]?.percent).toBe(99.96); - expect(rows[2]?.resetAt).toBe(1790430938000); }); - test("direct creditsUsd does not duplicate an existing credits custom window", () => { - const rows = buildQuotaRows(quota({ - customWindows: [{ label: "Total subscription credits", percent: 50 }], + test.each(["Total subscription credits", " TOTAL SUBSCRIPTION CREDITS "])( + "direct creditsUsd does not duplicate the canonical custom window: %s", + label => { + const customOnly = quota({ customWindows: [{ label, percent: 25, resetAt: 1790430938000 }] }); + const withDirect = quota({ + ...customOnly, + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99, expiresAt: 1790517338000 }, + }); + for (const reported of [customOnly, withDirect]) { + expect(buildQuotaRows(reported, null, t)).toEqual([{ + customLabel: "Total subscription credits", + label: "quota.totalSubscriptionCredits", + limitLabel: "quota.totalSubscriptionCredits", + percent: 25, + resetAt: 1790430938000, + }]); + } + }, + ); + + test("unrelated credit windows keep their raw identity and do not suppress direct credits", () => { + const reported = quota({ + customWindows: [ + { label: "API credits", percent: 20 }, + { label: " Gem ", percent: 10 }, + ], creditsUsd: { used: 50, limit: 100, remaining: 50, percent: 50 }, - }), null, t); - expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits"]); + }); + const rows = buildQuotaRows(reported, null, t); + expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits", "API credits", " Gem "]); + expect(rows.map(r => r.customLabel)).toEqual(["Total subscription credits", "API credits", " Gem "]); + expect(maxQuotaUtilisation(reported)).toBe(50); + }); + + test.each(["go", "free"])("30-day plan %s retains direct credits after normalization", plan => { + const rows = buildQuotaRows(quota({ + shortPercent: 10, + shortWindowSeconds: 5 * 60 * 60, + weeklyPercent: 30, + monthlyPercent: 60, + customWindows: [{ label: "Total subscription credits", percent: 99 }], + creditsUsd: { used: 80, limit: 100, remaining: 20, percent: 80 }, + }), plan, t); + expect(rows.map(r => r.limitLabel)).toEqual(["quota.monthlyLimit", "quota.totalSubscriptionCredits"]); + expect(rows.map(r => r.percent)).toEqual([60, 80]); }); test("null and empty quotas produce no rows; 30-day plans strip to monthly", () => { @@ -114,7 +186,6 @@ describe("maxQuotaUtilisation", () => { expect(maxQuotaUtilisation(null)).toBe(-1); expect(maxQuotaUtilisation(quota({}))).toBe(-1); expect(maxQuotaUtilisation(quota({ weeklyPercent: 30, monthlyPercent: 80 }))).toBe(80); - expect(maxQuotaUtilisation(quota({ shortPercent: 40, weeklyPercent: 30 }))).toBe(40); expect(maxQuotaUtilisation(quota({ fiveHourPercent: 10, customWindows: [{ label: "x", percent: 95 }], @@ -125,6 +196,46 @@ describe("maxQuotaUtilisation", () => { creditsUsd: { used: 90, limit: 90, remaining: 0, percent: 100 }, }))).toBe(100); }); + + test.each(["Total subscription credits", " total subscription credits "])( + "subscription-credit urgency follows the visible custom window: %s", + label => { + expect(maxQuotaUtilisation(quota({ + customWindows: [{ label, percent: 25 }], + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99 }, + }))).toBe(25); + }, + ); +}); + +describe("isCustomQuotaWindowIncomplete", () => { + test("canonical subscription rows retain raw-label coverage metadata", () => { + const rows = buildQuotaRows(quota({ + customWindows: [{ label: " TOTAL SUBSCRIPTION CREDITS ", percent: 25 }], + }), null, t); + expect(rows[0]?.customLabel).toBe("Total subscription credits"); + expect(isCustomQuotaWindowIncomplete( + rows[0]?.customLabel, + new Set(["API credits", " TOTAL SUBSCRIPTION CREDITS "]), + )).toBe(true); + expect(isCustomQuotaWindowIncomplete( + " total subscription credits ", + new Set(["Total subscription credits"]), + )).toBe(true); + }); + + test("absent and unrelated coverage does not mark a subscription row incomplete", () => { + expect(isCustomQuotaWindowIncomplete(undefined, new Set(["Total subscription credits"]))).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits")).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits", new Set())).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits", new Set(["API credits"]))).toBe(false); + }); + + test("unknown window coverage preserves exact raw-label matching", () => { + expect(isCustomQuotaWindowIncomplete(" Gem ", new Set([" Gem "]))).toBe(true); + expect(isCustomQuotaWindowIncomplete(" Gem ", new Set(["Gem"]))).toBe(false); + expect(isCustomQuotaWindowIncomplete("Gem", new Set(["gem"]))).toBe(false); + }); }); describe("barWidth", () => { diff --git a/tests/helpers/agent-task-recovery.ts b/tests/helpers/agent-task-recovery.ts index 0311d6811d..6514ac47d1 100644 --- a/tests/helpers/agent-task-recovery.ts +++ b/tests/helpers/agent-task-recovery.ts @@ -151,7 +151,7 @@ export async function post( input: unknown[], headers: HeadersInit = {}, abortSignal?: AbortSignal, - options: { tools?: unknown[]; translatorBudget?: TranslatorBudget } = {}, + options: { tools?: unknown[]; translatorBudget?: TranslatorBudget; promptCacheKeyIsSharedCohort?: boolean } = {}, ): Promise { return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", @@ -160,7 +160,11 @@ export async function post( ...Object.fromEntries(new Headers(headers)), }, body: JSON.stringify({ model, input, stream: false, ...(options.tools ? { tools: options.tools } : {}) }), - }), config, { model: "", provider: "" }, { abortSignal, translatorBudget: options.translatorBudget }); + }), config, { model: "", provider: "" }, { + abortSignal, + translatorBudget: options.translatorBudget, + promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort, + }); } export function encryptedInput(options: { diff --git a/tests/lab/lab-fabric-producer-deadline.test.ts b/tests/lab/lab-fabric-producer-deadline.test.ts new file mode 100644 index 0000000000..f4895b90cf --- /dev/null +++ b/tests/lab/lab-fabric-producer-deadline.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { setImmediate as nextTurn } from "node:timers"; +import { runIsolatedFabricProducer } from "../../src/lab/fabric/producer-isolate"; +import type { IsolatedProducerResult } from "../../src/lab/fabric/producer-protocol"; +import { FabricTaskError, type FabricTaskRunResult, type SyntheticPatchV1 } from "../../src/lab/fabric/types"; +import { runFabricSyntheticPatchTaskForRoute } from "../../src/lab/fabric/executor"; +import { createLabDestination } from "../../src/lab/live/destination"; +import { fabricCorrectPatchExecutor, fabricMockRoute } from "../helpers/fabric-task-test"; + +// Hand-written valid fixture: an always-reject supervisor must fail the controls. +const PATCH: SyntheticPatchV1 = { + schemaVersion: 1, + operations: [{ op: "replace", path: "src/value.txt", contentUtf8: "after\n" }], +}; +const RESULT = JSON.stringify({ type: "result", patch: PATCH }); +const ACTIVITY = '{"type":"activity"}\n'; +const START = 1_000; +const IDLE_MS = 100; +const TOTAL_MS = 250; + +class DeadlineChild extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly signals: Array = []; + closed = false; + + kill(signal?: NodeJS.Signals | number): boolean { + this.signals.push(signal); + return true; // Buffered data can arrive after kill; only the test emits close. + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.emit("close", 0, null); + } +} + +type CapturedTimer = { callback: () => void; delay: number; cleared: boolean }; +type Outcome = + | { status: "pending" } + | { status: "resolved"; value: T } + | { status: "rejected"; error: unknown }; + +// Drain promise adoption and stream nextTicks, without sleeping or advancing time. +const drain = () => new Promise((resolve) => nextTurn(resolve)); + +function installTimers(restorers: Array<() => void>) { + const timers: CapturedTimer[] = []; + const handles = new Map, CapturedTimer>(); + const setSpy = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, delay: number) => { + const timer = { callback, delay, cleared: false }; + // Only timer identity/unref are consumed by this supervisor; no real handle. + const handle = { unref() { return this; } } as unknown as ReturnType; + timers.push(timer); + handles.set(handle, timer); + return handle; + }) as typeof setTimeout); + restorers.push(() => setSpy.mockRestore()); + const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation((handle) => { + const timer = handles.get(handle as ReturnType); + if (timer) timer.cleared = true; + }); + restorers.push(() => clearSpy.mockRestore()); + return timers; +} + +type ExpectedFailure = + | [code: "inactivity_timeout" | "timeout"] + | [code: "harness_failure", attribution: "harness", message: string]; + +type Harness = { + child: DeadlineChild; + timers: CapturedTimer[]; + at: (time: number) => void; + result: (newline?: boolean) => void; + pending: () => Promise; + failure: (...expected: ExpectedFailure) => Promise; + success: (lastActivityAt?: number) => Promise; +}; + +async function withProducer(body: (h: Harness) => Promise, totalTimeoutMs = TOTAL_MS) { + const scratchRoot = mkdtempSync(join(tmpdir(), "ocx-fabric-deadline-")); + const child = new DeadlineChild(); + const originals = { spawn: childProcess.spawn, set: globalThis.setTimeout, clear: globalThis.clearTimeout }; + const restorers: Array<() => void> = []; + let time = START; + let outcome: Outcome = { status: "pending" }; + try { + // Repository namespace-spy precedent; never delegates to the original spawn. + const spawnSpy = spyOn(childProcess, "spawn").mockImplementation(() => child as unknown as childProcess.ChildProcess); + restorers.push(() => spawnSpy.mockRestore()); + const timers = installTimers(restorers); + void runIsolatedFabricProducer({ + scratchRoot, harnessKind: "deterministic_correct", totalTimeoutMs, + inactivityTimeoutMs: IDLE_MS, now: () => time, + }).then( + (value) => { outcome = { status: "resolved", value }; }, + (error: unknown) => { outcome = { status: "rejected", error }; }, + ); + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.mock.results[0]?.value).toBe(child); + expect(child.stdout.listenerCount("data")).toBe(1); + expect(child.listenerCount("close")).toBe(1); + expect(timers.map(({ delay }) => delay).sort((a, b) => a - b)).toEqual([IDLE_MS, totalTimeoutMs]); + await body({ + child, timers, at: (value) => { time = value; }, + result: (newline = true) => { child.stdout.write(RESULT + (newline ? "\n" : "")); }, + pending: async () => { await drain(); expect(outcome.status).toBe("pending"); }, + failure: async (...expected) => { + const [code] = expected; + const attribution = code === "harness_failure" ? expected[1] : "environment"; + const message = code === "harness_failure" ? expected[2] + : code === "inactivity_timeout" ? "inactivity timeout exceeded" : "total timeout exceeded"; + child.close(); + await drain(); + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("producer did not reject after close"); + expect(outcome.error).toBeInstanceOf(FabricTaskError); + expect(outcome.error).toMatchObject({ code, attribution, message }); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + }, + success: async (lastActivityAt = START) => { + await drain(); + expect(outcome).toEqual({ status: "resolved", value: { patch: PATCH, lastActivityAt } }); + expect(child.signals).toEqual([]); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + }, + }); + expect(spawnSpy).toHaveBeenCalledTimes(1); + } finally { + // Always reap the fake before removing its scratch, including failed assertions. + try { + child.close(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + } finally { + for (const restore of restorers.reverse()) restore(); + rmSync(scratchRoot, { recursive: true, force: true }); + expect(childProcess.spawn).toBe(originals.spawn); + expect(globalThis.setTimeout).toBe(originals.set); + expect(globalThis.clearTimeout).toBe(originals.clear); + } + } +} + +describe("isolated fabric producer deadline admission", () => { + test("idle timer then buffered result cannot settle before child close", async () => { + await withProducer(async (h) => { + h.at(1_100); + h.timers[0]!.callback(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.pending(); + h.result(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + for (const time of [1_100, 1_101]) { + test(`result at ${time} rejects even when no timer callback ran`, async () => { + await withProducer(async (h) => { + h.at(time); + h.result(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("inactivity_timeout"); + }); + }); + } + + test("late activity and result in the same chunk cannot renew idle", async () => { + await withProducer(async (h) => { + h.at(1_101); + h.child.stdout.write(ACTIVITY + RESULT + "\n"); + await h.pending(); + expect(h.timers).toHaveLength(2); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("inactivity_timeout"); + }); + }); + + test("total deadline is fixed despite accepted activity", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_180]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + expect(h.child.signals).toEqual([]); + } + h.at(1_250); + h.result(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("timeout"); + }); + }); + + test("a delayed total callback chooses the earlier elapsed idle deadline", async () => { + await withProducer(async (h) => { + h.at(1_251); + h.timers[1]!.callback(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + test("inactivity wins an exact deadline tie even if total callback runs first", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_150]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + } + h.at(1_250); + h.timers[1]!.callback(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + test("first timeout survives later timers, protocol and process/stream errors", async () => { + await withProducer(async (h) => { + h.at(1_100); + h.timers[0]!.callback(); + await h.pending(); + h.at(1_251); + const laterEvents = [ + () => h.timers[1]!.callback(), + () => h.child.stdout.write('{"type":"error","code":"sandbox_violation","message":"later protocol error","attribution":"harness"}\n'), + () => h.child.stdout.write("not-json\n"), + () => h.child.stdout.emit("error", new Error("later stdout error")), + () => h.child.stderr.emit("error", new Error("later stderr error")), + () => h.child.stdin.emit("error", new Error("later stdin error")), + () => h.child.emit("error", new Error("later child error")), + () => h.child.stdout.write(ACTIVITY), + () => h.result(), + () => h.timers[0]!.callback(), + ]; + for (const event of laterEvents) { + event(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + } + expect(h.timers).toHaveLength(2); + await h.failure("inactivity_timeout"); + }); + }); + + test("valid result just before idle boundary succeeds", async () => { + await withProducer(async (h) => { + h.at(1_099); + h.result(); + await h.success(); + }); + }); + + test("stderr failure stays authoritative until close across later data, errors and timers", async () => { + await withProducer(async (h) => { + h.child.stderr.emit("error", new Error("first stderr read failure")); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + h.at(1_251); + const laterEvents = [ + () => h.child.stdout.write(ACTIVITY + RESULT + "\n"), + () => h.child.stdout.write('{"type":"error","code":"sandbox_violation","message":"later protocol error","attribution":"harness"}\n'), + () => h.child.stderr.emit("error", new Error("second stderr error")), + () => h.child.stdout.emit("error", new Error("later stdout error")), + () => h.child.stdin.emit("error", new Error("later stdin error")), + () => h.child.emit("error", new Error("later child error")), + () => h.timers[0]!.callback(), + () => h.timers[1]!.callback(), + ]; + for (const event of laterEvents) { + event(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + } + expect(h.timers).toHaveLength(2); + await h.failure("harness_failure", "harness", "first stderr read failure"); + }); + }); + + test("valid activity renews idle and reports its accepted timestamp", async () => { + await withProducer(async (h) => { + h.at(1_090); + h.child.stdout.write(ACTIVITY); + await h.pending(); + expect(h.timers).toHaveLength(3); + expect(h.timers[0]!.cleared).toBe(true); + expect(h.timers[1]!.cleared).toBe(false); + h.at(1_189); + h.result(); + await h.success(1_090); + }); + }); + + test("valid result just before the fixed total deadline succeeds", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_180]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + } + h.at(1_249); + h.result(); + await h.success(1_180); + }); + }); + + for (const closeAt of [1_099, 1_100, 1_101]) { + test(`unterminated result is admitted at close time ${closeAt}`, async () => { + await withProducer(async (h) => { + h.at(1_099); + h.result(false); + await h.pending(); + h.at(closeAt); + h.child.close(); + if (closeAt < 1_100) await h.success(); + else await h.failure("inactivity_timeout"); + }); + }); + } +}); + +test("trusted route keeps scratch until stderr-failed child closes, then cleans it", async () => { + const configDir = mkdtempSync(join(tmpdir(), "ocx-fabric-consumer-deadline-")); + const child = new DeadlineChild(); + const originals = { spawn: childProcess.spawn, set: globalThis.setTimeout, clear: globalThis.clearTimeout }; + const restorers: Array<() => void> = []; + const proxyNames = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; + const proxyEnv = proxyNames.map((name) => [name, process.env[name]] as const); + const outer: { outcome: Outcome } = { outcome: { status: "pending" } }; + try { + for (const name of proxyNames) delete process.env[name]; + // Resolve through the existing destination contract before capturing producer timers. + const destination = await createLabDestination({ + baseUrl: "https://api.example.com/v1", labRunApproval: true, configDir, + resolve: async () => [{ address: "93.184.216.34", family: 4 }], + }); + const spawnSpy = spyOn(childProcess, "spawn").mockImplementation(() => child as unknown as childProcess.ChildProcess); + restorers.push(() => spawnSpy.mockRestore()); + const timers = installTimers(restorers); + void runFabricSyntheticPatchTaskForRoute({ + routeContext: fabricMockRoute(), destination, configDir, now: () => START, + patchExecutor: fabricCorrectPatchExecutor(), + }).then( + (value) => { outer.outcome = { status: "resolved", value }; }, + (error: unknown) => { outer.outcome = { status: "rejected", error }; }, + ); + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.mock.results[0]?.value).toBe(child); + const scratchRoot = spawnSpy.mock.calls[0]?.[2]?.env?.OCX_FABRIC_SCRATCH_ROOT; + expect(typeof scratchRoot).toBe("string"); + if (!scratchRoot) throw new Error("producer spawn omitted its scratch root"); + expect(child.listenerCount("close")).toBe(1); + expect(timers).toHaveLength(2); + expect(existsSync(scratchRoot)).toBe(true); + await drain(); + expect(outer.outcome.status).toBe("pending"); + + child.stderr.emit("error", new Error("consumer stderr failure")); + const assertPendingScratch = async () => { + await drain(); + expect(outer.outcome.status).toBe("pending"); + expect(child.closed).toBe(false); + expect(child.signals).toEqual(["SIGKILL"]); + expect(existsSync(scratchRoot)).toBe(true); + expect(readFileSync(join(scratchRoot, "src/value.txt"), "utf8")).toBe("before\n"); + }; + await assertPendingScratch(); + const afterFailure = [ + () => child.stdout.write(ACTIVITY + RESULT + "\n"), + () => child.stderr.emit("error", new Error("later stderr failure")), + () => timers[0]!.callback(), + () => timers[1]!.callback(), + ]; + for (const event of afterFailure) { + event(); + await assertPendingScratch(); + } + child.close(); + await drain(); + expect(outer.outcome.status).toBe("resolved"); + if (outer.outcome.status !== "resolved") throw new Error("route did not settle after child close"); + expect(outer.outcome.value).toMatchObject({ + executionAuthority: "trusted_route", + outcome: { + outcome: "inconclusive", + failure: { class: "harness_failure", code: "harness_failure", attribution: "harness", retryable: false }, + verifier: { passed: false, reason: "harness_failure" }, + usage: { outputBytes: 0, patchOperations: 0, filesTouched: 0 }, + }, + }); + expect(existsSync(scratchRoot)).toBe(false); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + expect(spawnSpy).toHaveBeenCalledTimes(1); + } finally { + try { + child.close(); + await drain(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + } finally { + for (const restore of restorers.reverse()) restore(); + for (const [name, value] of proxyEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(configDir, { recursive: true, force: true }); + expect(childProcess.spawn).toBe(originals.spawn); + expect(globalThis.setTimeout).toBe(originals.set); + expect(globalThis.clearTimeout).toBe(originals.clear); + } + } +}); diff --git a/tests/oauth/adapter-event-oauth-failover.test.ts b/tests/oauth/adapter-event-oauth-failover.test.ts index c344fc6780..a96c948ed9 100644 --- a/tests/oauth/adapter-event-oauth-failover.test.ts +++ b/tests/oauth/adapter-event-oauth-failover.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ProviderAdapter } from "../../src/adapters/base"; import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; -import { saveCredential } from "../../src/oauth/store"; +import { getAccountSet, getCredential, saveCredential, setActiveAccount } from "../../src/oauth/store"; import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -12,8 +12,12 @@ const actualResolver = await import("../../src/server/adapter-resolve"); const actualResolveAdapter = actualResolver.resolveAdapter; let attempts: AdapterEvent[][] = []; let attemptKeys: string[] = []; +let attemptProjects: Array = []; /** Set by the delivery test: an attempt that emits, then blocks before completing the turn. */ let slowAttempt: ((emit: (event: AdapterEvent) => void) => Promise) | undefined; +let beforePhysicalSend: (() => Promise) | undefined; +let physicalSends = 0; +const originalFetch = globalThis.fetch; function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { return { @@ -22,9 +26,18 @@ function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { async *parseStream() { yield { type: "error", message: "fixture uses runTurn" } as AdapterEvent; }, - async runTurn(_parsed, _incoming, emit) { + async runTurn(_parsed, incoming, emit) { const index = attemptKeys.length; attemptKeys.push(provider.apiKey ?? ""); + attemptProjects.push(provider.project); + const gate = beforePhysicalSend; + beforePhysicalSend = undefined; + await gate?.(); + for (let send = 0; send < physicalSends; send++) { + await incoming.providerFetch!(provider.baseUrl, { + method: "POST", headers: { Authorization: `Bearer ${provider.apiKey}` }, body: "{}", + }); + } if (slowAttempt) return await slowAttempt(emit); for (const event of attempts[index] ?? []) emit(event); }, @@ -34,14 +47,13 @@ function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { mock.module("../../src/server/adapter-resolve", () => ({ ...actualResolver, resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { - if (provider.adapter === "cursor") return fixtureAdapter(provider); + if (provider.adapter === "cursor" || provider.googleMode === "cloud-code-assist") return fixtureAdapter(provider); return actualResolveAdapter(provider, cacheRetention); }, })); const { handleResponses } = await import("../../src/server/responses"); const originalHome = process.env.OPENCODEX_HOME; -const originalFetch = globalThis.fetch; let home = ""; /** @@ -89,35 +101,87 @@ beforeEach(() => { clearGenericFailoverHealth(); attempts = []; attemptKeys = []; + attemptProjects = []; slowAttempt = undefined; + beforePhysicalSend = undefined; + physicalSends = 0; }); afterEach(() => { + globalThis.fetch = originalFetch; clearGenericFailoverHealth(); if (originalHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = originalHome; - globalThis.fetch = originalFetch; removeTreeWithRetry(home); }); describe("#2568 adapter-event OAuth failover", () => { - test("single-account preflight 401 refreshes once before replay", async () => { - await seedAccounts(1); - attempts = [ - [{ type: "error", message: "Cursor authentication failed: expired token" }], - [{ type: "text_delta", text: "refreshed answer" }, { type: "done" }], - ]; - let refreshes = 0; - globalThis.fetch = (async () => { - refreshes += 1; - return new Response(JSON.stringify({ accessToken: "cursor-access-refreshed" }), { status: 200 }); + test.each([false, true])("runTurn first physical send follows a changed selection (image loop=%s)", async imageLoop => { + await seedAccounts(2); + const accounts = getAccountSet("cursor")!.accounts; + beforePhysicalSend = async () => { await setActiveAccount("cursor", accounts[0]!.id); }; + physicalSends = 1; + slowAttempt = async emit => { emit({ type: "text_delta", text: "selected answer" }); emit({ type: "done" }); }; + const sent: string[] = []; + globalThis.fetch = (async (_input, init) => { + sent.push(new Headers(init?.headers).get("authorization") ?? ""); + return new Response("{}"); + }) as typeof fetch; + const cfg = config(false); + if (imageLoop) { + cfg.images = { bridgeEnabled: true }; + cfg.providers.xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "synthetic-image-key" }; + } + const req = imageLoop ? new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cursor/model", input: "answer", stream: true, tools: [{ type: "image_generation" }] }), + }) : request(true); + const response = await handleResponses(req, cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("selected answer"); + expect(sent).toEqual(["Bearer cursor-access-0"]); + }); + + test("an already started multi-message turn keeps its original credential", async () => { + await seedAccounts(2); + const accounts = getAccountSet("cursor")!.accounts; + physicalSends = 2; + slowAttempt = async emit => { emit({ type: "text_delta", text: "same turn" }); emit({ type: "done" }); }; + const sent: string[] = []; + globalThis.fetch = (async (_input, init) => { + sent.push(new Headers(init?.headers).get("authorization") ?? ""); + if (sent.length === 1) await setActiveAccount("cursor", accounts[0]!.id); + return new Response("{}"); }) as typeof fetch; - const response = await handleResponses(request(false), config(), { model: "", provider: "" }); - expect(await response.text()).toContain("refreshed answer"); - expect(refreshes).toBe(1); - expect(attemptKeys).toEqual(["cursor-access-0", "cursor-access-refreshed"]); + const response = await handleResponses(request(false), config(false), { model: "", provider: "" }); + expect(await response.text()).toContain("same turn"); + expect(sent).toEqual(["Bearer cursor-access-1", "Bearer cursor-access-1"]); + expect(getCredential("cursor")?.access).toBe("cursor-access-0"); }); + test("every CCA request pairs the persisted active account with its own project", async () => { + for (const id of ["a", "b"]) await saveCredential("google-antigravity", { + access: `ga-access-${id}`, refresh: `ga-refresh-${id}`, expires: Date.now() + 3_600_000, + accountId: id, projectId: `project-${id}`, + }); + const cfg = config(); + cfg.defaultProvider = "google-antigravity"; + cfg.providers = { "google-antigravity": { ...cfg.providers.cursor!, googleMode: "cloud-code-assist", project: "project-a" } }; + attempts = [ + [{ type: "text_delta", text: "first" }, { type: "done" }], + [{ type: "text_delta", text: "second" }, { type: "done" }], + ]; + for (let i = 0; i < 2; i++) { + const req = new Request("http://localhost/v1/responses", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "google-antigravity/model", input: "answer", stream: false }), + }); + const res = await handleResponses(req, cfg, { model: "", provider: "" }); + const responseBody = await res.text(); + expect(res.status, responseBody).toBe(200); + } + expect(attemptKeys).toEqual(["ga-access-b", "ga-access-b"]); + expect(attemptProjects).toEqual(["project-b", "project-b"]); + }); for (const stream of [true, false]) { test(`${stream ? "streaming" : "non-streaming"} first-event 429 rotates and replays`, async () => { await seedAccounts(2); @@ -132,9 +196,28 @@ describe("#2568 adapter-event OAuth failover", () => { expect(attemptKeys).toEqual(["cursor-access-1", "cursor-access-0"]); expect(body).toContain("alternate answer"); expect(body).not.toContain("Cursor rate limit exceeded"); + expect(getCredential("cursor")?.access).toBe("cursor-access-0"); }); } + test("a newer manual choice wins a pending request's 429 proposal", async () => { + await seedAccounts(3); + const accounts = getAccountSet("cursor")!.accounts; + slowAttempt = async emit => { + if (attemptKeys.length === 1) { + await setActiveAccount("cursor", accounts[1]!.id); + emit({ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }); + } else { + emit({ type: "text_delta", text: "manual choice answered" }); + emit({ type: "done" }); + } + }; + const response = await handleResponses(request(false), config(false), { model: "", provider: "" }); + expect(await response.text()).toContain("manual choice answered"); + expect(attemptKeys).toEqual(["cursor-access-2", "cursor-access-1"]); + expect(getCredential("cursor")?.access).toBe("cursor-access-1"); + }); + test("a single account is a strict no-op", async () => { await seedAccounts(1); attempts = [[{ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }]]; @@ -145,7 +228,11 @@ describe("#2568 adapter-event OAuth failover", () => { expect(body).toContain("rate_limit_exceeded"); }); - test("an explicit opt-out refuses adapter-event replay under the second account", async () => { + test("an explicit opt-out no longer strands a 429 when a second account is stored", async () => { + // Reversed deliberately. `enabled: false` used to keep pre-#2568d single-account behaviour + // on a 429; it now governs only the proactive pre-dispatch preference. Stranding a rate + // limit while a second logged-in account sits idle is a defect rather than a preference, + // and the operator who wants one account expresses that by storing one account. await seedAccounts(2); attempts = [ [{ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }], @@ -156,9 +243,9 @@ describe("#2568 adapter-event OAuth failover", () => { const body = await response.text(); expect(response.status).toBe(200); - expect(attemptKeys).toEqual(["cursor-access-1"]); - expect(body).not.toContain("ok"); - expect(body).toContain("rate_limit_exceeded"); + expect(attemptKeys).toEqual(["cursor-access-1", "cursor-access-0"]); + expect(body).toContain("ok"); + expect(body).not.toContain("rate_limit_exceeded"); }); test("the first delta reaches the client before the turn completes", async () => { diff --git a/tests/oauth/generic-oauth-failover.test.ts b/tests/oauth/generic-oauth-failover.test.ts index 2c11d4b223..e78e6c249a 100644 --- a/tests/oauth/generic-oauth-failover.test.ts +++ b/tests/oauth/generic-oauth-failover.test.ts @@ -71,6 +71,45 @@ async function seed(count: number, offset = 0): Promise { } describe("#2568 generic OAuth account failover", () => { + for (const provider of ["xai", "cursor", "kimi", "github-copilot", "google-antigravity", "nous", "kiro", "meta-muse"]) { + test(`manual selection owns healthy dispatch for ${provider}, with pool off or on`, async () => { + for (const accountId of ["selected", "spare"]) { + await saveCredential(provider, { + access: `synthetic-${accountId}`, refresh: `refresh-${accountId}`, + expires: Date.now() + 3_600_000, accountId, + }); + } + const ids = getAccountSet(provider)!.accounts.map(a => a.id); + await setActiveAccount(provider, ids[0]!); + setCachedProviderAccountQuotaForTests(provider, ids[0]!, { weeklyPercent: 30, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests(provider, ids[1]!, { weeklyPercent: 11, updatedAt: Date.now() }); + for (const enabled of [undefined, false, true]) { + const cfg = { providers: { [provider]: { ...OAUTH_PROVIDER, + ...(enabled === undefined ? {} : { oauthAccountFailover: { enabled } }), + } } } as OcxConfig; + expect(preferredInitialAccount(cfg, provider)).toBeNull(); + } + clearAccountQuotaCache(provider); + }); + } + + test("proactive exhaustion avoidance requires explicit pool enablement", async () => { + const [selected, spare] = await seed(2); + await setActiveAccount("xai", selected!); + setCachedProviderAccountQuotaForTests("xai", selected!, { weeklyPercent: 100, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", spare!, { weeklyPercent: 11, updatedAt: Date.now() }); + expect(preferredInitialAccount(config(), "xai")).toBeNull(); + expect(preferredInitialAccount(config(false), "xai")).toBeNull(); + expect(preferredInitialAccount(config(true), "xai")).toBe(spare); + }); + + test("unknown selected quota is not permission to replace the account", async () => { + const [selected, spare] = await seed(2); + await setActiveAccount("xai", selected!); + setCachedProviderAccountQuotaForTests("xai", spare!, { weeklyPercent: 11, updatedAt: Date.now() }); + expect(preferredInitialAccount(config(true), "xai")).toBeNull(); + }); + test("two logged-in accounts rotate with NO configuration at all (#2568d)", async () => { // The reported workflow: three xAI accounts are logged in, the active one hits its limit, and // the operator never went looking for a toggle. Presence supplies the default only while @@ -141,7 +180,7 @@ describe("#2568 generic OAuth account failover", () => { const ids = await seed(2); await setActiveAccount("xai", ids[0]!); clearGenericFailoverHealth("xai"); - setCachedProviderAccountQuotaForTests("xai", ids[0]!, { fiveHourPercent: 99 }); + setCachedProviderAccountQuotaForTests("xai", ids[0]!, { fiveHourPercent: 100 }); setCachedProviderAccountQuotaForTests("xai", ids[1]!, { fiveHourPercent: 1 }); expect(preferredInitialAccount(config(false, true), "xai")).toBe(ids[1]); @@ -386,8 +425,11 @@ describe("sidecar on429 wiring", () => { // to the configured account's project — #2841 in its original shape. const start = coreSource.indexOf("const preferredAccountId ="); expect(start).toBeGreaterThan(-1); - const region = coreSource.slice(start, start + 6000); - expect(region).toContain("usedPreferredAccount && resolved.projectId"); + const end = coreSource.indexOf("\n route.provider = resolveProviderTransport(", start); + expect(end).toBeGreaterThan(start); + const region = coreSource.slice(start, end); + expect(region).toContain("project: resolved.projectId"); + expect(region).not.toContain("!route.provider.project"); // A project-less preferred account falls BACK to the ordinary active-account resolution // rather than erroring: a preference must never turn a working request into a failure, // and Antigravity tolerates project discovery failing, so an account with no project is diff --git a/tests/oauth/key-login-live-update.test.ts b/tests/oauth/key-login-live-update.test.ts index 9fa31d7722..2d96c6e773 100644 --- a/tests/oauth/key-login-live-update.test.ts +++ b/tests/oauth/key-login-live-update.test.ts @@ -1,22 +1,19 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getConfigPath, loadConfig, saveConfig, setPersistedConfigInitializationBeforePublishForTests, writePid, writeRuntimePort } from "../../src/config"; +import { loadConfig, saveConfig, writePid, writeRuntimePort } from "../../src/config"; import { commitKeyLoginProvider, providerConfigFromKeyLoginProvider } from "../../src/oauth/login-cli"; import { KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers"; import { startServer } from "../../src/server"; import { createLocalAttestationSecret } from "../../src/lib/local-management-attestation"; -import { LOCAL_PROVIDER_RELOAD_TIMEOUT_MS } from "../../src/server/local-provider-reload-client"; +import type { LocalProviderReloadResult } from "../../src/server/local-provider-reload-client"; import type { OcxConfig } from "../../src/types"; import { refreshUserCostOverlays } from "../../src/usage/user-cost-overlays"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { managementFetch as fetch } from "../helpers/management-auth"; -import { watchdogMs } from "../helpers/ci-watchdog"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -const LIVE_UPDATE_TIMEOUT_MS = watchdogMs(LOCAL_PROVIDER_RELOAD_TIMEOUT_MS * 2 + 5_000); - /** * Regression: `ocx login ` used to POST the unmerged preset row * into a running proxy. The proxy then saved the replacement without the @@ -26,8 +23,9 @@ const LIVE_UPDATE_TIMEOUT_MS = watchdogMs(LOCAL_PROVIDER_RELOAD_TIMEOUT_MS * 2 + let testDir = ""; let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; +let upstream: ReturnType | undefined; -function umansKeyConfig(port = 0): OcxConfig { +function umansKeyConfig(baseUrl: string, port = 0): OcxConfig { return { port, hostname: "127.0.0.1", @@ -35,7 +33,8 @@ function umansKeyConfig(port = 0): OcxConfig { providers: { umans: { adapter: "anthropic", - baseUrl: "https://api.code.umans.ai", + baseUrl, + allowPrivateNetwork: true, apiKey: "sk-old", }, }, @@ -47,111 +46,33 @@ beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-key-login-live-"); testDir = mkdtempSync(join(tmpdir(), "ocx-key-login-live-")); process.env.OPENCODEX_HOME = testDir; - saveConfig(umansKeyConfig()); + // Reload validates the provider destination before adopting disk state. Use an + // owned literal address so this persistence regression cannot wait on public DNS. + upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => Response.json({ data: [{ id: "umans-coder", type: "model" }] }), + }); + saveConfig(umansKeyConfig(upstream.url.toString())); }); -afterEach(() => { - setPersistedConfigInitializationBeforePublishForTests(null); - // The overlay registry is module-level; reset it so rows added through the - // live provider update path cannot leak into later tests in a shared run. - refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - isolatedCodexHome?.restore(); - isolatedCodexHome = null; - if (testDir) removeTreeWithRetry(testDir); +afterEach(async () => { + try { + await upstream?.stop(true); + } finally { + upstream = undefined; + // The overlay registry is module-level; reset it so rows added through the + // live provider update path cannot leak into later tests in a shared run. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); + } }); describe("CLI key-login live-update overlay preservation", () => { - test("fresh key and AI Studio logins initialize a missing config without losing OpenAI", async () => { - for (const [name, provider] of [ - ["umans", providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-fresh")], - ["google-aistudio", { - adapter: "google", - authMode: "local", - googleMode: "ai-studio-web", - baseUrl: "https://alkalimakersuite-pa.clients6.google.com", - }], - ] as const) { - if (existsSync(getConfigPath())) unlinkSync(getConfigPath()); - const config = loadConfig(); - await commitKeyLoginProvider(config, name, provider as OcxConfig["providers"][string]); - const disk = loadConfig(); - expect(disk.defaultProvider).toBe("openai"); - expect(disk.providers.openai).toBeDefined(); - expect(disk.providers[name]).toBeDefined(); - } - }); - - test("fresh key login retries a lost initialization race and rejects the winner's namespace collision", async () => { - unlinkSync(getConfigPath()); - const config = loadConfig(); - const winner = structuredClone(config); - winner.codexAccountNamespaces = { umans: "pool-a" }; - const winnerBytes = `${JSON.stringify(winner, null, 2)}\n`; - setPersistedConfigInitializationBeforePublishForTests(() => { - writeFileSync(getConfigPath(), winnerBytes, { flag: "wx", mode: 0o600 }); - }); - - await expect(commitKeyLoginProvider( - config, - "umans", - providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-fresh"), - )).rejects.toThrow("must not collide with a configured Codex account namespace"); - expect(readFileSync(getConfigPath(), "utf8")).toBe(winnerBytes); - }); - - test("key-login commit updates one provider without replacing sibling providers on disk", async () => { - const richConfig = umansKeyConfig(); - richConfig.providers.extra = { - adapter: "openai-chat", - baseUrl: "https://extra.example/v1", - apiKey: "extra-key", - }; - writeFileSync(join(testDir, "config.json"), `${JSON.stringify(richConfig, null, 2)}\n`); - - const staleConfig = umansKeyConfig(); - const replacement = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-rotated"); - await commitKeyLoginProvider(staleConfig, "umans", replacement); - - const disk = JSON.parse(readFileSync(join(testDir, "config.json"), "utf-8")) as OcxConfig; - expect(disk.providers.umans!.apiKey).toBe("sk-rotated"); - expect(disk.providers.extra).toEqual(richConfig.providers.extra); - }); - - test("key rotation preserves the complete operator-owned provider state and alternate keys", async () => { - const richConfig = umansKeyConfig(); - Object.assign(richConfig.providers.umans!, { - disabled: true, - apiKeyPool: [{ id: "legacy-key", key: "sk-old", label: "fallback" }], - modelAliases: { "umans-coder": "daily" }, - selectedModels: ["umans-coder"], - modelPreset: { mode: "custom" }, - requestPacing: { enabled: true, requestsPerMinute: 12 }, - contextWindow: 123_456, - modelCosts: { "umans-coder": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }, - }); - writeFileSync(getConfigPath(), `${JSON.stringify(richConfig, null, 2)}\n`); - - const live = structuredClone(richConfig); - const merged = await commitKeyLoginProvider( - live, - "umans", - providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-rotated"), - ); - expect(merged).toMatchObject({ - disabled: true, - apiKey: "sk-rotated", - modelAliases: { "umans-coder": "daily" }, - selectedModels: ["umans-coder"], - modelPreset: { mode: "custom" }, - requestPacing: { enabled: true, requestsPerMinute: 12 }, - contextWindow: 123_456, - }); - expect(merged.apiKeyPool?.map(entry => entry.key)).toEqual(["sk-old", "sk-rotated"]); - expect(loadConfig().providers.umans).toEqual(merged); - }); - test("notify after key login pushes the merged row and keeps modelCosts on live and disk", async () => { const localAttestationSecret = createLocalAttestationSecret(); const server = startServer(0, { localAttestationSecret }); @@ -169,16 +90,21 @@ describe("CLI key-login live-update overlay preservation", () => { saveConfig(boot); // The proxy booted before the overlay existed; a hand-edit then adds - // modelCosts before the key-login commit. + // modelCosts to disk only, so the live in-memory row has no overlay yet. const edited = loadConfig(); edited.providers.umans!.modelCosts = { "umans-coder": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, }; saveConfig(edited); - const config = edited; - const replacement = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-rotated"); - const merged = await commitKeyLoginProvider(config, "umans", replacement); + const config = loadConfig(); + const replacement = { + ...providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-rotated", config.providers.umans!.baseUrl), + allowPrivateNetwork: true, + }; + const reloads: Array = []; + const merged = await commitKeyLoginProvider(config, "umans", replacement, result => { reloads.push(result); }); + expect(reloads).toEqual([{ kind: "reloaded" }]); expect(merged.modelCosts).toEqual(edited.providers.umans!.modelCosts); // Reload treats disk as authoritative and never re-saves it. @@ -189,12 +115,14 @@ describe("CLI key-login live-update overlay preservation", () => { // The running proxy must also carry the overlay in its live config: // A silent early return or failed reload would leave the in-memory DTO stale // even though disk is correct. - const live = (await fetch(new URL("/api/config", server.url)).then(r => r.json())) as { + const response = await fetch(new URL("/api/config", server.url)); + expect(response.status).toBe(200); + const live = (await response.json()) as { providers: Record }>; }; expect(live.providers.umans?.modelCosts).toEqual(edited.providers.umans!.modelCosts); } finally { await server.stop(true); } - }, LIVE_UPDATE_TIMEOUT_MS); + }, 15_000); }); diff --git a/tests/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index 15fa30263e..d9750893b6 100644 --- a/tests/oauth/oauth-accounts-api.test.ts +++ b/tests/oauth/oauth-accounts-api.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { managementFetch as fetch } from "../helpers/management-auth"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,6 +15,35 @@ import { getAccountSet } from "../../src/oauth/store"; import { bindAntigravitySessionAffinity, resolveAntigravityAccountForSession } from "../../src/oauth/antigravity-routing"; import { ACCOUNT_IMPORT_DEADLINE_MS, ACCOUNT_IMPORT_MAX_BYTES, ACCOUNT_IMPORT_MAX_REQUEST_BYTES } from "../../src/oauth/account-import/types"; import { handleOauthAccountRoutes } from "../../src/server/management/oauth-account-routes"; +import { createManagementSessionControl, requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; +import { handleSessionRoutes } from "../../src/server/management/session-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { publishAccountSelection } from "../../src/lib/account-selection-events"; + +function selectionSessionFixture() { + const origin = "http://127.0.0.1:10100"; + const token = "ocx_session_selection_liveness_test"; + const state: Extract = { + available: true, token: "ocx_admin_selection_test", source: "environment", + sessions: new Map([[token, { + serverOrigin: origin, browserOrigin: origin, csrfToken: "selection-csrf", + expiresAt: Date.now() + 60_000, issuance: "loopback", + }]]), pairingGrants: new Map(), + }; + const req = new Request(`${origin}/api/accounts/events`, { headers: { + Host: "127.0.0.1:10100", Origin: origin, "x-opencodex-gui-origin": origin, + "x-opencodex-api-key": token, "x-opencodex-csrf-token": "selection-csrf", + } }); + const ctx: ManagementContext = { + req, url: new URL(req.url), config: baseConfig(), deps: {}, version: "test", + principal: "gui-session", sessionControl: createManagementSessionControl(state), + convergeCodexCatalog: async () => ({ status: "failed", reason: "disk" }), + syncClaudeAgentDefsBestEffort: async () => {}, + }; + // Cache this Request's original admission: subsequent stream checks must ignore it. + expect(requireManagementAuth(req, state, ctx.config)).toBeNull(); + return { ctx, state, token }; +} let testDir = ""; let previousHome: string | undefined; @@ -67,6 +96,142 @@ afterEach(() => { }); describe("multiauth accounts API", () => { + test("selection events require management authentication", async () => { + const server = startServer(0); + try { + const response = await originalFetch(new URL("/api/accounts/events", server.url)); + expect(response.status).toBe(401); + await response.body?.cancel(); + } finally { await server.stop(true); } + }); + + test("selection event streams bound subscribers and release cancelled connections", async () => { + const { accountSelectionStream } = await import("../../src/server/management/account-selection-stream"); + const streams: Response[] = []; + try { + for (let i = 0; i < 64; i++) { + const response = accountSelectionStream(new Request("http://localhost/api/accounts/events"), () => true); + expect(response.status).toBe(200); + streams.push(response); + } + expect(accountSelectionStream(new Request("http://localhost/api/accounts/events"), () => true).status).toBe(429); + } finally { + await Promise.all(streams.map(response => response.body!.cancel())); + } + const response = accountSelectionStream(new Request("http://localhost/api/accounts/events"), () => true); + expect(response.status).toBe(200); + await response.body!.cancel(); + }); + + test("selection event route denies admission without a current session validator", async () => { + const { ctx } = selectionSessionFixture(); + const response = await handleOauthAccountRoutes({ ...ctx, sessionControl: undefined }); + try { expect(response?.status).toBe(401); } + finally { await response?.body?.cancel(); } + }); + + test.each(["false", "throw"] as const)("selection stream denies an initial validator result of %s", async result => { + const { accountSelectionStream } = await import("../../src/server/management/account-selection-stream"); + const response = accountSelectionStream(new Request("http://localhost/api/accounts/events"), () => { + if (result === "throw") throw new Error("validator unavailable"); + return false; + }); + try { expect(response.status).toBe(401); } + finally { await response.body?.cancel(); } + }); + + test.each(["logout", "expiry"] as const)("selection stream stops publishing after GUI %s despite cached request admission", async change => { + const { ctx, state, token } = selectionSessionFixture(); + const response = await handleOauthAccountRoutes(ctx); + expect(response?.status).toBe(200); + const reader = response!.body!.getReader(); + try { + expect(new TextDecoder().decode((await reader.read()).value)).toContain("event: ready"); + if (change === "logout") { + const req = new Request(new URL("/api/session/logout", ctx.req.url), { method: "POST", headers: ctx.req.headers }); + expect(requireManagementAuth(req, state, ctx.config)).toBeNull(); + expect(handleSessionRoutes({ ...ctx, req, url: new URL(req.url) })?.status).toBe(200); + } else { + state.sessions.get(token)!.expiresAt = Date.now() - 1; + } + expect(requireManagementAuth(ctx.req, state, ctx.config)).toBeNull(); // Deliberately memoized. + const pending = reader.read(); + publishAccountSelection("private-provider", "oauth"); + await expect(pending).rejects.toMatchObject({ name: "NotAllowedError" }); + } finally { await reader.cancel().catch(() => undefined); } + }); + + test("selection heartbeat revalidates expiry without extending a remote session", async () => { + const { ctx, state, token } = selectionSessionFixture(); + const session = state.sessions.get(token)!; + session.issuance = "pairing"; + const expiresAt = session.expiresAt; + const interval = spyOn(globalThis, "setInterval"); + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await handleOauthAccountRoutes(ctx); + reader = response!.body!.getReader(); + await reader.read(); + const tick = interval.mock.calls.find(call => call[1] === 15_000)?.[0]; + if (typeof tick !== "function") throw new Error("selection heartbeat not registered"); + tick(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain(": heartbeat"); + expect(session.expiresAt).toBe(expiresAt); + session.expiresAt = Date.now() - 1; + const pending = reader.read(); + tick(); + await expect(pending).rejects.toMatchObject({ name: "NotAllowedError" }); + } finally { + await reader?.cancel().catch(() => undefined); + interval.mockRestore(); + } + }); + + test("management session liveness rereads revoked sessions and the current admin token", () => { + const { ctx, state, token } = selectionSessionFixture(); + const control = ctx.sessionControl!; + expect(control.isCurrent(ctx.req, ctx.config)).toBe(true); + expect(control.revokeCurrent(ctx.req)).toBe(true); + expect(control.isCurrent(ctx.req, ctx.config)).toBe(false); + expect(state.sessions.has(token)).toBe(false); + const adminReq = new Request(ctx.req.url, { headers: { "x-opencodex-api-key": state.token } }); + expect(requireManagementAuth(adminReq, state, ctx.config)).toBeNull(); + expect(control.isCurrent(adminReq, ctx.config)).toBe(true); + state.token = "ocx_admin_rotated_selection_test"; + expect(control.isCurrent(adminReq, ctx.config)).toBe(false); + expect(createManagementSessionControl({ available: false, reason: "test" }).isCurrent(adminReq, ctx.config)).toBe(false); + }); + + test("selection events notify only after the new active account is committed", async () => { + const server = startServer(0); + const abort = new AbortController(); + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await fetch(new URL("/api/accounts/events", server.url), { signal: abort.signal }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + reader = response.body!.getReader(); + const ready = new TextDecoder().decode((await reader.read()).value); + expect(ready).toContain("event: ready"); + const eventRead = reader.read(); + const selected = await fetch(new URL("/api/oauth/accounts/active", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "anthropic", accountId: "bbbb2222" }), + }); + expect(selected.status).toBe(200); + const notification = new TextDecoder().decode((await eventRead).value); + expect(notification).toContain("event: account-selection"); + expect(notification).toContain('"provider":"anthropic"'); + expect(notification).not.toContain("bbbb2222"); + expect(notification).not.toContain("t2"); + expect(getAccountSet("anthropic")?.activeAccountId).toBe("bbbb2222"); + } finally { + await reader?.cancel(); + abort.abort(); + await server.stop(true); + } + }); + test("GET lists masked accounts with active flag", async () => { const server = startServer(0); try { diff --git a/tests/oauth/oauth-login-cli-live-update.test.ts b/tests/oauth/oauth-login-cli-live-update.test.ts index f3dd2885e7..6106ac6e5e 100644 --- a/tests/oauth/oauth-login-cli-live-update.test.ts +++ b/tests/oauth/oauth-login-cli-live-update.test.ts @@ -3,8 +3,10 @@ import { managementFetch as fetch } from "../helpers/management-auth"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadConfig, mutatePersistedConfig, saveConfig, writePid, writeRuntimePort } from "../../src/config"; -import { upsertOAuthProvider } from "../../src/oauth"; +import { loadConfig, saveConfig, writePid, writeRuntimePort } from "../../src/config"; +import { OAUTH_PROVIDERS, runLogin, upsertOAuthProvider } from "../../src/oauth"; +import { getAccountSet, saveCredential } from "../../src/oauth/store"; +import { clearGenericFailoverHealth, preferredInitialAccount } from "../../src/oauth/generic-account-failover"; import { commitKeyLoginProvider, notifyRunningProxy, @@ -12,19 +14,10 @@ import { } from "../../src/oauth/login-cli"; import { startServer } from "../../src/server"; import { createLocalAttestationSecret } from "../../src/lib/local-management-attestation"; -import { LOCAL_PROVIDER_RELOAD_TIMEOUT_MS } from "../../src/server/local-provider-reload-client"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; -import { watchdogMs } from "../helpers/ci-watchdog"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -/** - * `requestBoundLocalProviderReload` may spend one HTTP ceiling on /healthz and - * another on the reload POST. A 15s bun-test budget dies first on the unsharded - * macOS CI runner (observed 15012ms timeout after the proxy had already bound). - */ -const LIVE_UPDATE_TIMEOUT_MS = watchdogMs(LOCAL_PROVIDER_RELOAD_TIMEOUT_MS * 2 + 5_000); - /** * Regression: CLI OAuth login used to POST the bare OAuth preset into a running proxy. * That wiped the preserved apiKey / apiKeyPool / authMode:"key" that runLogin had just @@ -68,9 +61,88 @@ afterEach(() => { }); describe("CLI OAuth live-update credential preservation", () => { - test("live notify case budget outlasts two reload HTTP ceilings", () => { - expect(LIVE_UPDATE_TIMEOUT_MS).toBeGreaterThan(LOCAL_PROVIDER_RELOAD_TIMEOUT_MS * 2); - }); + test("Antigravity login emits the new active account's bearer and project without proactive preference", async () => { + const providerName = "google-antigravity"; + const cfg: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: providerName, + oauthAccountFailover: { enabled: false }, + providers: { + [providerName]: { + ...structuredClone(OAUTH_PROVIDERS[providerName]!.providerConfig), + project: "project-a", + liveModels: false, + oauthAccountFailover: { enabled: false }, + }, + }, + }; + saveConfig(cfg); + await saveCredential(providerName, { + access: "access-a", refresh: "refresh-a", expires: Date.now() + 3_600_000, + accountId: "account-a", projectId: "project-a", + }); + const accountA = getAccountSet(providerName)!.activeAccountId; + const originalLogin = OAUTH_PROVIDERS[providerName]!.login; + const originalFetch = globalThis.fetch; + const emitted: Array<{ bearer: string | null; project: unknown }> = []; + let server: ReturnType | undefined; + try { + OAUTH_PROVIDERS[providerName]!.login = async () => ({ + access: "access-b", refresh: "refresh-b", expires: Date.now() + 3_600_000, + accountId: "account-b", projectId: "project-b", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "127.0.0.1" || url.hostname === "localhost") return originalFetch(input, init); + if (url.origin !== "https://daily-cloudcode-pa.googleapis.com" + || !["/v1internal:generateContent", "/v1internal:streamGenerateContent"].includes(url.pathname)) { + throw new Error("Unexpected external request in CCA login regression"); + } + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + const body = typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : ""; + emitted.push({ bearer: headers.get("authorization"), project: (JSON.parse(body) as { project?: unknown }).project }); + const payload = { + response: { + candidates: [{ content: { role: "model", parts: [{ text: "account-b response" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + }, + }; + return url.searchParams.get("alt") === "sse" + ? new Response(`data: ${JSON.stringify(payload)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + }) as typeof globalThis.fetch; + + await runLogin(providerName, {}, { forceLogin: true }); + const accounts = getAccountSet(providerName)!; + expect(accounts.activeAccountId).not.toBe(accountA); + expect(accounts.accounts.find(account => account.id === accounts.activeAccountId)?.credential.accountId).toBe("account-b"); + clearGenericFailoverHealth(providerName); + const persisted = loadConfig(); + expect(persisted.providers[providerName]!.oauthAccountFailover?.enabled).toBe(false); + expect(preferredInitialAccount(persisted, providerName)).toBeNull(); + + server = startServer(0); + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-antigravity/gemini-3.8-flash", input: "hello", stream: false }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("account-b response"); + // One successful initial dispatch: no 401 or reactive rotation can repair a bad pair. + expect(emitted).toEqual([{ bearer: "Bearer access-b", project: "project-b" }]); + expect(persisted.providers[providerName]!.project).toBeUndefined(); + } finally { + try { + await server?.stop(true); + } finally { + globalThis.fetch = originalFetch; + OAUTH_PROVIDERS[providerName]!.login = originalLogin; + clearGenericFailoverHealth(providerName); + } + } + }, 15_000); test("does not post provider credentials when a legacy health listener has no verified pid", async () => { const receivedPaths: string[] = []; @@ -178,42 +250,7 @@ describe("CLI OAuth live-update credential preservation", () => { } finally { await server.stop(true); } - }, LIVE_UPDATE_TIMEOUT_MS); - - test("AI Studio login participates in attested live reload", async () => { - const localAttestationSecret = createLocalAttestationSecret(); - const server = startServer(0, { localAttestationSecret }); - try { - const port = server.port!; - writeRuntimePort({ - pid: process.pid, - port, - hostname: "127.0.0.1", - attestationSecret: localAttestationSecret, - }); - writePid(process.pid); - const boot = loadConfig(); - boot.port = port; - saveConfig(boot); - mutatePersistedConfig(fresh => { - fresh.providers["google-aistudio"] = { - adapter: "google", - googleMode: "ai-studio-web", - baseUrl: "https://alkalimakersuite-pa.clients6.google.com", - authMode: "local", - }; - return { changed: true, value: undefined }; - }); - - const result = await notifyRunningProxy("google-aistudio"); - expect(result?.kind).toBe("reloaded"); - - const listed = await fetch(new URL("/api/providers", server.url)).then(r => r.json()) as Array<{ name: string }>; - expect(listed.some(entry => entry.name === "google-aistudio")).toBe(true); - } finally { - await server.stop(true); - } - }, LIVE_UPDATE_TIMEOUT_MS); + }, 15_000); }); /** diff --git a/tests/oauth/oauth-provider-reconcile.test.ts b/tests/oauth/oauth-provider-reconcile.test.ts index 686c7fd1b0..fe624a54f7 100644 --- a/tests/oauth/oauth-provider-reconcile.test.ts +++ b/tests/oauth/oauth-provider-reconcile.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -229,7 +229,7 @@ describe("OAuth provider reconciliation", () => { // holds; only the rows the projection actually changed may be replaced. expect(config.providers.untouched).toBe(liveUntouched); }); - test("refreshes a saved Antigravity 3.5 preset without touching credentials or user fields", async () => { + test("refreshes a saved Antigravity live catalog without touching credentials or user fields", async () => { const home = mkdtempSync(join(tmpdir(), "ocx-gemini-36-reconcile-")); homes.push(home); process.env.OPENCODEX_HOME = home; @@ -261,7 +261,7 @@ describe("OAuth provider reconciliation", () => { expect(reconcileOAuthProviders(config)).toBe(true); const provider = config.providers["google-antigravity"]; - expect(provider.defaultModel).toBe("gemini-3.8-flash"); + expect(provider.defaultModel).toBe("gemini-3.5-flash-low"); expect(provider.models).toEqual([ "gemini-3.8-flash", "gemini-3.7-flash", @@ -288,7 +288,7 @@ describe("OAuth provider reconciliation", () => { }); const persisted = loadConfig(); - expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.8-flash"); + expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.5-flash-low"); expect(persisted.providers["google-antigravity"]?.liveModels).toBe(true); expect(reconcileOAuthProviders(config)).toBe(false); }); @@ -331,10 +331,9 @@ describe("OAuth provider reconciliation", () => { }); test("an explicit 3.7 default survives the 3.8 launch while its capabilities refresh", () => { - // The 3.5 case above starts from a RETIRED id, so it only exercises the stale-default - // healing branch. This one is the opposite claim, and the one that matters for an - // additive rollout: a user who deliberately chose 3.7 must still be on 3.7 afterwards. - // Google still serves it, so healing it onto 3.8 would be silently overriding a choice. + // The earlier live-discovery case preserves an id outside the static seed. This case + // preserves a still-listed choice during an additive catalog rollout, while refreshing + // its capability records. const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-explicit-default-")); homes.push(home); process.env.OPENCODEX_HOME = home; @@ -356,7 +355,7 @@ describe("OAuth provider reconciliation", () => { } satisfies OcxConfig; saveConfig(config); - reconcileOAuthProviders(config, false); + reconcileOAuthProviders(config); const provider = config.providers["google-antigravity"]; expect(provider.defaultModel).toBe("gemini-3.7-flash"); @@ -367,55 +366,51 @@ describe("OAuth provider reconciliation", () => { expect(provider.modelReasoningEfforts?.["gemini-3.8-flash"]).toEqual(["low", "medium", "high"]); }); - test("adopts already-reconciled persisted OAuth state into a stale live config", () => { - const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-unchanged-adopt-")); - homes.push(home); - process.env.OPENCODEX_HOME = home; - const staleLive = { + test("does not validate a live-models default against the static preset", () => { + const config = { port: 10100, defaultProvider: "google-antigravity", - googleAntigravityStaticCatalogVersion: 1, providers: { "google-antigravity": { ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), - defaultModel: "gemini-3.6-flash", - models: [ - "gemini-3.6-flash", - "gemini-3.1-pro", - "gemini-3.1-flash-image", - "claude-sonnet-4-6", - "claude-opus-4-6-thinking", - "gpt-oss-120b-medium", - ], - liveModels: false, - }, - "local-only": { - adapter: "openai", - baseUrl: "http://127.0.0.1:9999/v1", - allowPrivateNetwork: true, - models: ["local-live"], - note: "live-only", + authMode: "oauth", + liveModels: true, + models: ["account-specific-model"], + defaultModel: "account-specific-model", }, }, } satisfies OcxConfig; - const reconciledDisk = structuredClone(staleLive); - reconciledDisk.providers["disk-only"] = { - adapter: "openai", - baseUrl: "http://127.0.0.1:9998/v1", - allowPrivateNetwork: true, - models: ["disk-only"], + + expect(reconcileOAuthProviders(config, false)).toBe(true); + expect(config.providers["google-antigravity"].defaultModel).toBe("account-specific-model"); + + upsertOAuthProvider(config, "google-antigravity"); + expect(config.providers["google-antigravity"].defaultModel).toBe("account-specific-model"); + }); + + test.each(["reconcile", "upsert"] as const)("%s heals an obsolete static default without enabling live discovery", operation => { + const preset = OAUTH_PROVIDERS["google-antigravity"].providerConfig; + const config: OcxConfig = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + ...structuredClone(preset), + liveModels: false, + defaultModel: "retired-static-model", + models: ["retired-static-model"], + }, + }, }; - delete reconciledDisk.providers["local-only"]; - expect(reconcileOAuthProviders(reconciledDisk, false)).toBe(true); - saveConfig(reconciledDisk); - const beforeBytes = readFileSync(getConfigPath(), "utf8"); - - expect(reconcileOAuthProviders(staleLive)).toBe(true); - expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); - expect(staleLive.googleAntigravityStaticCatalogVersion).toBe(2); - expect(staleLive.providers["google-antigravity"]).toEqual(reconciledDisk.providers["google-antigravity"]); - expect(staleLive.providers["local-only"]?.note).toBe("live-only"); - expect(staleLive.providers["disk-only"]).toBeUndefined(); + + if (operation === "reconcile") expect(reconcileOAuthProviders(config, false)).toBe(true); + else upsertOAuthProvider(config, "google-antigravity"); + + const provider = config.providers["google-antigravity"]!; + expect(provider.liveModels).toBe(false); + expect(provider.defaultModel).toBe(preset.defaultModel); + expect(provider.models).toEqual(preset.models); + expect(reconcileOAuthProviders(config, false)).toBe(false); }); test("preserves an explicit Antigravity static opt-out without the legacy migration marker", () => { diff --git a/tests/oauth/oauth-store-multi.test.ts b/tests/oauth/oauth-store-multi.test.ts index 645e69be1a..1edbc45e2c 100644 --- a/tests/oauth/oauth-store-multi.test.ts +++ b/tests/oauth/oauth-store-multi.test.ts @@ -1,7 +1,9 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { INTERNAL_DEADLINE_MS, STORE_BUDGET_MS } from "../helpers/test-budget"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import * as atomicWrite from "../../src/config/atomic-write"; +import * as oauthStore from "../../src/oauth/store"; import { resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, @@ -15,16 +17,19 @@ import { listAccounts, markAccountNeedsReauth, markAccountNeedsReauthIfGeneration, + mergeAccountCredential, mutateStore, OAuthMutationBusyError, oauthMutationTailSnapshot, reconcileOAuthReauthState, removeAccount, removeCredential, + replaceProviderAccountSet, saveAccountCredential, saveCredential, setAccountAlias, setActiveAccount, + upsertCredentialByIdentity, } from "../../src/oauth/store"; import type { OAuthCredentials } from "../../src/oauth/types"; import { bindAntigravitySessionAffinity, clearAntigravityRoutingState, resolveAntigravityAccountForSession } from "../../src/oauth/antigravity-routing"; @@ -40,6 +45,17 @@ const cred = (over: Partial = {}): OAuthCredentials => ({ ...over, }); +const SELECTION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +async function selectionAccounts() { + await saveCredential("xai", cred({ accountId: "selection-a" })); + const idA = getAccountSet("xai")!.activeAccountId; + await saveCredential("xai", cred({ accountId: "selection-b", access: "access-b" })); + const idB = getAccountSet("xai")!.activeAccountId; + await setActiveAccount("xai", idA); + return { idA, idB }; +} + describe("multi-account auth store", () => { beforeEach(() => { previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -342,6 +358,264 @@ describe("multi-account auth store", () => { expect(set.activeAccountId).toBe("ok"); // dangling active healed }); + test("selection revision rejects an automatic promotion after manual A-B-A", async () => { + const { idA, idB } = await selectionAccounts(); + const before = getAccountSet("xai")!.selectionRevision; + expect(before).toMatch(SELECTION_UUID); + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + await setActiveAccount("xai", idB); + await setActiveAccount("xai", idA); + expect(getAccountSet("xai")!.selectionRevision).not.toBe(before); + expect(await oauthStore.commitOAuthAccountSelection("xai", idB, { expectedSelection })).toBeNull(); + expect(oauthStore.captureOAuthAccountSelection("xai")?.accountId).toBe(idA); + }); + + test("selection revision preserves credential-only refresh and unrelated account metadata", async () => { + const { idA, idB } = await selectionAccounts(); + // Seed a persisted revision independently to catch normalization dropping it. + const authPath = join(TEST_DIR, "auth.json"); + const raw = JSON.parse(readFileSync(authPath, "utf8")); + const revision = "f4abbddc-5c7c-4e87-bd8a-b5775a182860"; + raw.xai.selectionRevision = revision; + writeFileSync(authPath, JSON.stringify(raw)); + await saveAccountCredential("xai", idA, cred({ accountId: "selection-a", access: "refreshed-a" })); + expect(getAccountSet("xai")!.selectionRevision).toBe(revision); + await mergeAccountCredential("xai", idB, cred({ accountId: "selection-b", access: "refreshed-b" })); + await setAccountAlias("xai", idA, "Selection test"); + await markAccountNeedsReauth("xai", idB, true); + await upsertCredentialByIdentity("xai", cred({ accountId: "selection-a", access: "import-refreshed" })); + expect(getAccountSet("xai")!.selectionRevision).toBe(revision); + expect(JSON.parse(readFileSync(authPath, "utf8")).xai.selectionRevision).toBe(revision); + expect(oauthStore.captureOAuthAccountSelection("xai")).toEqual({ accountId: idA, revision }); + }); + + test("selection revision advances for removal, recreation, and rollback replacement", async () => { + const { idA, idB } = await selectionAccounts(); + const original = getAccountSet("xai")!; + expect(original.selectionRevision).toMatch(SELECTION_UUID); + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + await removeAccount("xai", idA); + expect(getAccountSet("xai")!.activeAccountId).toBe(idB); + const promoted = getAccountSet("xai")!.selectionRevision; + expect(promoted).not.toBe(original.selectionRevision); + await removeCredential("xai"); + expect(oauthStore.captureOAuthAccountSelection("xai")).toBeNull(); + await saveCredential("xai", cred({ accountId: "selection-a" })); + const recreated = getAccountSet("xai")!; + expect(recreated.activeAccountId).toBe(idA); + expect(recreated.selectionRevision).not.toBe(original.selectionRevision); + await replaceProviderAccountSet("xai", original); + const restored = getAccountSet("xai")!; + expect(restored.selectionRevision).toMatch(SELECTION_UUID); + expect([original.selectionRevision, promoted, recreated.selectionRevision]).not.toContain(restored.selectionRevision); + expect(original.selectionRevision).toBe(expectedSelection.revision); + expect(await oauthStore.commitOAuthAccountSelection("xai", idB, { expectedSelection })).toBeNull(); + }); + + test("selection revision advances on same-id manual reselect but not automatic validation", async () => { + const { idA } = await selectionAccounts(); + const before = getAccountSet("xai")!.selectionRevision; + await setActiveAccount("xai", idA); + const after = getAccountSet("xai")!.selectionRevision; + expect(after).not.toBe(before); + expect(after).toMatch(SELECTION_UUID); + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + expect(await oauthStore.commitOAuthAccountSelection("xai", idA, { + expectedSelection, + expectedCredentialGeneration: credentialGeneration(getAccountCredential("xai", idA)!), + requireUsableAccount: true, + })).toEqual(expectedSelection); + expect(oauthStore.captureOAuthAccountSelection("xai")).toEqual(expectedSelection); + }); + + test("selection commit supports revisionless legacy snapshots and guards the original id", async () => { + const authPath = join(TEST_DIR, "auth.json"); + writeFileSync(authPath, JSON.stringify({ xai: { + activeAccountId: "legacy-a", + accounts: [{ id: "legacy-a", credential: cred() }, { id: "legacy-b", credential: cred({ access: "b" }) }], + } })); + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + expect(expectedSelection).toEqual({ accountId: "legacy-a" }); + expect(await oauthStore.commitOAuthAccountSelection("xai", "legacy-b", { + expectedSelection: { accountId: "wrong-id" }, + })).toBeNull(); + expect(await oauthStore.commitOAuthAccountSelection("xai", "legacy-a", { expectedSelection })).toEqual(expectedSelection); + const committed = await oauthStore.commitOAuthAccountSelection("xai", "legacy-b", { expectedSelection }); + expect(committed?.accountId).toBe("legacy-b"); + expect(committed?.revision).toMatch(SELECTION_UUID); + expect(oauthStore.captureOAuthAccountSelection("xai")).toEqual(committed); + }); + + test.each(["manual", "refresh", "reauth", "remove"] as const)("selection commit rechecks queued %s changes under the writer", async change => { + const { idA, idB } = await selectionAccounts(); + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + const expectedCredentialGeneration = credentialGeneration(getAccountCredential("xai", idB)!); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const blocker = mutateStore(async () => { entered(); await gate; }); + await started; + const mutation = change === "manual" ? setActiveAccount("xai", idA) + : change === "refresh" ? saveAccountCredential("xai", idB, cred({ accountId: "selection-b", access: "fresh-b" })) + : change === "reauth" ? markAccountNeedsReauth("xai", idB, true) + : removeAccount("xai", idB); + const pending = oauthStore.commitOAuthAccountSelection("xai", idB, { + expectedSelection, expectedCredentialGeneration, requireUsableAccount: true, + }); + try { + release(); + await blocker; + await mutation; + expect(await pending).toBeNull(); + expect(getAccountSet("xai")!.activeAccountId).toBe(idA); + } finally { + release(); + await Promise.allSettled([blocker, mutation, pending]); + } + }); + + test("selection commit checks same-account usability and refreshed credential generation", async () => { + const { idA, idB } = await selectionAccounts(); + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + const oldGeneration = credentialGeneration(getAccountCredential("xai", idA)!); + await saveAccountCredential("xai", idA, cred({ accountId: "selection-a", access: "rotated-a" })); + expect(await oauthStore.commitOAuthAccountSelection("xai", idA, { + expectedSelection, expectedCredentialGeneration: oldGeneration, requireUsableAccount: true, + })).toBeNull(); + await markAccountNeedsReauth("xai", idA, true); + expect(await oauthStore.commitOAuthAccountSelection("xai", idA, { + expectedSelection, requireUsableAccount: true, + })).toBeNull(); + const committed = await oauthStore.commitOAuthAccountSelection("xai", idB, { + expectedSelection, + expectedCredentialGeneration: credentialGeneration(getAccountCredential("xai", idB)!), + requireUsableAccount: true, + }); + expect(committed?.accountId).toBe(idB); + expect(committed?.revision).not.toBe(expectedSelection.revision); + expect(oauthStore.captureOAuthAccountSelection("xai")).toEqual(committed); + }); + + test("unchanged selection admission neither joins a busy writer nor persists", async () => { + const { idA } = await selectionAccounts(); + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + const expectedCredentialGeneration = credentialGeneration(getAccountCredential("xai", idA)!); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const blocker = mutateStore(async () => { entered(); await gate; }); + await started; + const write = spyOn(atomicWrite, "atomicWriteFile"); + const pending = oauthStore.commitOAuthAccountSelection("xai", idA, { + expectedSelection, expectedCredentialGeneration, requireUsableAccount: true, + }); + try { + expect(oauthMutationTailSnapshot().active).toBe(1); + expect(await pending).toEqual(expectedSelection); + expect(write).not.toHaveBeenCalled(); + } finally { + write.mockRestore(); + release(); + await Promise.allSettled([blocker, pending]); + } + }); + + test("selection events follow persistence and omit failed commits, refreshes, and credentials", async () => { + const { idA, idB } = await selectionAccounts(); + const { subscribeAccountSelections, currentAccountSelectionRevision } = await import("../../src/lib/account-selection-events"); + const events: unknown[] = []; + const observedSelections: unknown[] = []; + const start = currentAccountSelectionRevision(); + const unsubscribe = subscribeAccountSelections(event => { + events.push(event); + observedSelections.push(oauthStore.captureOAuthAccountSelection("xai")); + }); + try { + const expectedSelection = oauthStore.captureOAuthAccountSelection("xai")!; + await setActiveAccount("xai", idA); + const manual = oauthStore.captureOAuthAccountSelection("xai")!; + expect(events).toEqual([{ provider: "xai", kind: "oauth", revision: start + 1 }]); + expect(observedSelections).toEqual([manual]); + expect(await oauthStore.commitOAuthAccountSelection("xai", idB, { expectedSelection })).toBeNull(); + expect(await oauthStore.commitOAuthAccountSelection("xai", "missing")).toBeNull(); + expect(await setActiveAccount("xai", "missing")).toBe(false); + await oauthStore.commitOAuthAccountSelection("xai", idA, { expectedSelection: manual }); + await saveAccountCredential("xai", idA, cred({ accountId: "selection-a", access: "event-refresh" })); + expect(events).toHaveLength(1); + + // Only the I/O boundary is faulted; the actual commit, locks, and store stay real. + const write = spyOn(atomicWrite, "atomicWriteFile").mockImplementation(() => { throw new Error("selection persist failed"); }); + try { + await expect(oauthStore.commitOAuthAccountSelection("xai", idB, { expectedSelection: manual })).rejects.toThrow("selection persist failed"); + } finally { + write.mockRestore(); + } + expect(oauthStore.captureOAuthAccountSelection("xai")).toEqual(manual); + expect(events).toHaveLength(1); + expect(currentAccountSelectionRevision()).toBe(start + 1); + await expect(saveCredential("xai", cred({ accountId: "blocked-login" }), { + assertBeforePersist: () => { throw new Error("selection pre-persist rejected"); }, + })).rejects.toThrow("selection pre-persist rejected"); + expect(events).toHaveLength(1); + + await oauthStore.commitOAuthAccountSelection("xai", idB, { expectedSelection: manual }); + expect(events).toEqual([ + { provider: "xai", kind: "oauth", revision: start + 1 }, + { provider: "xai", kind: "oauth", revision: start + 2 }, + ]); + unsubscribe(); + unsubscribe(); + await setActiveAccount("xai", idA); + expect(events).toHaveLength(2); + } finally { + unsubscribe(); + } + }); + + test("selection events cover create, inactive removal, replacement, clear, and recreate", async () => { + const { subscribeAccountSelections, currentAccountSelectionRevision, publishAccountSelection } = await import("../../src/lib/account-selection-events"); + const events: unknown[] = []; + const start = currentAccountSelectionRevision(); + const unsubscribe = subscribeAccountSelections(event => { events.push(event); }); + try { + await upsertCredentialByIdentity("xai", cred({ accountId: "selection-a" })); + const original = getAccountSet("xai")!; + await upsertCredentialByIdentity("xai", cred({ accountId: "selection-b" })); + expect(events).toHaveLength(1); // Importing an inactive account preserves the selection. + const inactive = listAccounts("xai").find(account => account.id !== original.activeAccountId)!; + await removeAccount("xai", inactive.id); + expect(getAccountSet("xai")!.selectionRevision).not.toBe(original.selectionRevision); + await replaceProviderAccountSet("xai", original); + await replaceProviderAccountSet("xai", null); + await replaceProviderAccountSet("xai", null); + await saveCredential("xai", cred({ accountId: "selection-a" })); + publishAccountSelection("key-provider", "api-key"); + expect(events).toEqual([ + ...Array.from({ length: 5 }, (_, index) => ({ provider: "xai", kind: "oauth", revision: start + index + 1 })), + { provider: "key-provider", kind: "api-key", revision: start + 6 }, + ]); + } finally { + unsubscribe(); + } + }); + + test("selection subscriber failure cannot fail a persisted selection or block other subscribers", async () => { + const { idA } = await selectionAccounts(); + const { subscribeAccountSelections } = await import("../../src/lib/account-selection-events"); + const stopBroken = subscribeAccountSelections(() => { throw new Error("disconnected consumer"); }); + const seen: unknown[] = []; + const stopHealthy = subscribeAccountSelections(event => { seen.push(event); }); + try { + expect(await setActiveAccount("xai", idA)).toBe(true); + expect(seen).toHaveLength(1); + } finally { + stopBroken(); + stopHealthy(); + } + }); + test("queued generation-checked reauth mutation rechecks liveness after reconciliation", async () => { await saveCredential("xai", cred({ email: "race@example.com", accountId: "race-account" })); const accountId = getAccountSet("xai")!.activeAccountId; diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index cd082e2c1f..a22239dd69 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getConfigPath, mutatePersistedConfig, saveConfig } from "../../src/config"; import { OAUTH_PROVIDERS, upsertOAuthProvider } from "../../src/oauth"; +import { loadConfig, saveConfig } from "../../src/config"; import { migrateXaiResponsesDefault } from "../../src/providers/xai-responses-opt-in"; import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { @@ -39,45 +39,6 @@ function configWithKey(provider: string, adapter: string, baseUrl: string): OcxC } describe("upsertOAuthProvider credential preservation", () => { - test("login, add-account, and reauth preserve non-auth provider settings", () => { - const preserved = { - disabled: true, - requestPacing: { enabled: true, requestsPerMinute: 3, minIntervalMs: 20_000 }, - retryOn429: { enabled: true, attempts: 2 }, - refreshPolicy: "disabled" as const, - selectedModels: ["operator-model"], - note: "operator-note", - }; - const config = { - port: 10100, - defaultProvider: "google-antigravity", - providers: { - "google-antigravity": { - ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), - ...structuredClone(preserved), - adapter: "openai-chat", - baseUrl: "https://stale.example/v1", - authMode: "key", - googleMode: "ai-studio", - apiKey: "stale-key", - apiKeyPool: [{ id: "stale", key: "stale-key" }], - }, - }, - } as OcxConfig; - - for (const action of ["login", "add-account", "reauth"]) { - upsertOAuthProvider(config, "google-antigravity"); - const provider = config.providers["google-antigravity"]!; - expect(provider).toMatchObject(preserved); - expect(provider.adapter).toBe("google"); - expect(provider.baseUrl).toBe("https://daily-cloudcode-pa.googleapis.com"); - expect(provider.authMode).toBe("oauth"); - expect(provider.googleMode).toBe("cloud-code-assist"); - expect(provider.apiKey, action).toBeUndefined(); - expect(provider.apiKeyPool, action).toBeUndefined(); - } - }); - test.each([undefined, 1, 2])("Grok login preserves wire choice and migration version %j", version => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); const before = config.providers.xai!; @@ -274,15 +235,19 @@ describe("upsertOAuthProvider credential preservation", () => { { id: "pool-visible", key: "pool-visible-key" }, { id: activeId, key: "routing-only-key" }, ]); - saveConfig(config); const listed = listProviderApiKeys(config, "xai"); expect(listed.activeId).toBe(activeId); expect(listed.keys.find(entry => entry.id === activeId)?.active).toBe(true); expect(listed.keys.find(entry => entry.id === "pool-visible")?.active).toBe(false); + // runLogin persists the upsert before GUI key mutations. The shared selection + // transaction requires that authoritative file; it must not recreate missing config. + saveConfig(config); + expect(loadConfig().providers.xai!.apiKeyPool).toEqual(provider.apiKeyPool); expect(setActiveProviderApiKey(config, "xai", "pool-visible")).toBe(true); expect(config.providers.xai!.apiKey).toBe("pool-visible-key"); + expect(loadConfig().providers.xai!.apiKey).toBe("pool-visible-key"); expect(listProviderApiKeys(config, "xai").activeId).toBe("pool-visible"); expect(setActiveProviderApiKey(config, "xai", activeId)).toBe(true); @@ -291,6 +256,7 @@ describe("upsertOAuthProvider credential preservation", () => { expect(config.providers.xai!.apiKey).toBe("pool-visible-key"); expect(config.providers.xai!.apiKeyPool).toEqual([{ id: "pool-visible", key: "pool-visible-key" }]); expect(listProviderApiKeys(config, "xai").activeId).toBe("pool-visible"); + expect(loadConfig().providers.xai!.apiKeyPool).toEqual(config.providers.xai!.apiKeyPool); } finally { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -298,43 +264,6 @@ describe("upsertOAuthProvider credential preservation", () => { } }); - test("rejects a derived key ID collision without changing live or persisted config", () => { - const activeKey = "routing-only-key"; - const config = { - port: 10100, - defaultProvider: "xai", - providers: { - xai: { - adapter: "openai-chat", - baseUrl: "https://api.x.ai/v1", - authMode: "key", - apiKey: activeKey, - apiKeyPool: [{ id: apiKeyPoolEntryId(activeKey), key: "different-key" }], - }, - }, - } as OcxConfig; - const previousHome = process.env.OPENCODEX_HOME; - const testHome = mkdtempSync(join(tmpdir(), "ocx-oauth-upsert-collision-")); - process.env.OPENCODEX_HOME = testHome; - try { - const liveBefore = structuredClone(config); - expect(() => upsertOAuthProvider(config, "xai")).toThrow(/pool ID collision/); - expect(config).toEqual(liveBefore); - - saveConfig(config); - const diskBefore = readFileSync(getConfigPath()); - expect(() => mutatePersistedConfig(fresh => { - upsertOAuthProvider(fresh, "xai"); - return { changed: true, value: undefined }; - })).toThrow(/pool ID collision/); - expect(readFileSync(getConfigPath())).toEqual(diskBefore); - } finally { - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - rmSync(testHome, { recursive: true, force: true }); - } - }); - test("drops malformed stored key fields instead of breaking OAuth routing", () => { const config = { port: 10100, @@ -412,12 +341,16 @@ describe("upsertOAuthProvider credential preservation", () => { expect(config.providers.xai!.authMode).toBe("key"); expect(config.providers.xai!.apiKey).toBeUndefined(); expect(config.providers.xai!.apiKeyPool).toBeUndefined(); + expect(loadConfig().providers.xai!.apiKey).toBeUndefined(); + expect(loadConfig().providers.xai!.apiKeyPool).toBeUndefined(); upsertOAuthProvider(config, "xai"); const provider = config.providers.xai!; expect(provider.authMode).toBe("oauth"); expect(provider.apiKey).toBeUndefined(); expect(provider.apiKeyPool).toBeUndefined(); + saveConfig(config); + expect(loadConfig().providers.xai!.authMode).toBe("oauth"); } finally { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -425,7 +358,7 @@ describe("upsertOAuthProvider credential preservation", () => { } }); - test("removes incompatible credentials without dropping unrelated fields for oauth-only providers", () => { + test("applies OAuth credentials while preserving notes for oauth-only providers", () => { const config = { port: 10100, defaultProvider: "anthropic", @@ -436,9 +369,6 @@ describe("upsertOAuthProvider credential preservation", () => { authMode: "key", apiKey: "stale-key", apiKeyPool: [{ id: "stale", key: "stale-key" }], - apiKeyTransport: "bearer", - azureCredential: { type: "default-azure-credential" }, - headers: { Authorization: "Bearer stale-secret" }, note: "stale-note", }, }, @@ -448,18 +378,207 @@ describe("upsertOAuthProvider credential preservation", () => { expect(provider.authMode).toBe("oauth"); expect(provider.apiKey).toBeUndefined(); expect(provider.apiKeyPool).toBeUndefined(); - expect(provider.apiKeyTransport).toBeUndefined(); - expect(provider.azureCredential).toBeUndefined(); - expect(provider.headers).toBeUndefined(); expect(provider.note).toBe("stale-note"); }); + test("replaces stale login transport fields instead of retaining an alternate credential destination", () => { + const config = configWithKey("anthropic", "openai-chat", "https://stale.example.invalid"); + const existing = config.providers.anthropic!; + existing.headers = { Authorization: "Bearer stale-header-sentinel" }; + existing.apiKeyTransport = "x-api-key"; + existing.responsesPath = "/stale-responses"; + existing.googleMode = "vertex"; + existing.keyOptional = true; + const before = structuredClone(existing); + const preset = OAUTH_PROVIDERS.anthropic!.providerConfig; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic!; + expect(provider.adapter).toBe("anthropic"); + expect(provider.baseUrl).toBe("https://api.anthropic.com"); + expect(provider.authMode).toBe("oauth"); + expect(provider.headers).toEqual(preset.headers); + expect(provider.apiKeyTransport).toBe(preset.apiKeyTransport); + expect(provider.responsesPath).toBeUndefined(); + expect(provider.googleMode).toBeUndefined(); + expect(provider.keyOptional).toBeUndefined(); + expect(provider.apiKey).toBeUndefined(); + expect(provider.apiKeyPool).toBeUndefined(); + expect(existing).toEqual(before); + }); + + test("clears the previous CCA project only after resolving the canonical login mode", () => { + const config = configWithKey("google-antigravity", "google", "https://stale.example.invalid"); + const existing = config.providers["google-antigravity"]!; + existing.googleMode = "vertex"; + existing.project = "previous-account-project"; + + upsertOAuthProvider(config, "google-antigravity"); + + expect(config.providers["google-antigravity"]!.googleMode).toBe("cloud-code-assist"); + expect(config.providers["google-antigravity"]!.project).toBeUndefined(); + expect(existing.project).toBe("previous-account-project"); + }); + + test("preserves an operator project when the canonical login mode is not CCA", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + const existing = config.providers.xai!; + existing.googleMode = "cloud-code-assist"; + existing.project = "operator-project"; + + upsertOAuthProvider(config, "xai"); + + expect(config.providers.xai!.googleMode).toBeUndefined(); + expect(config.providers.xai!.project).toBe("operator-project"); + }); + + test("isolates nested operator and unchanged catalog data from the previous row and registry", () => { + const preset = OAUTH_PROVIDERS.anthropic!.providerConfig; + const presetBefore = structuredClone(preset); + const existing = { + ...structuredClone(preset), + modelCosts: { "operator-model": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }, + forwardCompatibleFlag: { labels: ["keep"] }, + }; + const before = structuredClone(existing); + const config: OcxConfig = { port: 10100, defaultProvider: "anthropic", providers: { anthropic: existing } }; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic! as typeof existing; + expect(provider.models).not.toBe(preset.models); + expect(provider.models).not.toBe(existing.models); + provider.modelCosts["operator-model"]!.input = 99; + provider.forwardCompatibleFlag.labels.push("changed"); + provider.models!.push("test-only-model"); + expect(existing).toEqual(before); + expect(preset).toEqual(presetBefore); + }); + + test("refreshes registry-owned catalog fields immediately without losing operator fields", () => { + const config = { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["retired-model"], + defaultModel: "retired-model", + contextWindow: 1, + disabled: true, + note: "operator-note", + }, + }, + } as unknown as OcxConfig; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic!; + const preset = OAUTH_PROVIDERS.anthropic!.providerConfig; + expect(provider.models).toEqual(preset.models); + expect(provider.contextWindow).toBe(preset.contextWindow); + expect(provider.defaultModel).toBe(preset.defaultModel); + expect(provider.disabled).toBe(true); + expect(provider.note).toBe("operator-note"); + }); + + test.each(["login", "add-account", "reauthentication"])( + "preserves operator policy and unknown fields during %s-shaped upsert", + operation => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + const existing = config.providers.xai! as OcxConfig["providers"][string] & Record; + existing.disabled = true; + existing.requestPacing = { enabled: true, minIntervalMs: 250 }; + existing.retryOn429 = { attempts: 4, intervalMs: 900 }; + existing.refreshPolicy = "lazy-only"; + existing.selectedModels = ["grok-4"]; + existing.note = `operator-${operation}`; + existing.modelCosts = { "grok-4": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }; + existing.oauthAccountFailover = { enabled: false }; + existing.forwardCompatibleFlag = { enabled: true }; + + upsertOAuthProvider(config, "xai"); + + const provider = config.providers.xai! as typeof existing; + expect(provider.disabled).toBe(true); + expect(provider.requestPacing).toEqual({ enabled: true, minIntervalMs: 250 }); + expect(provider.retryOn429).toEqual({ attempts: 4, intervalMs: 900 }); + expect(provider.refreshPolicy).toBe("lazy-only"); + expect(provider.selectedModels).toEqual(["grok-4"]); + expect(provider.note).toBe(`operator-${operation}`); + expect(provider.modelCosts).toEqual({ "grok-4": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }); + expect(provider.oauthAccountFailover).toEqual({ enabled: false }); + expect(provider.forwardCompatibleFlag).toEqual({ enabled: true }); + expect(provider.apiKey).toBe("stored-key-sentinel"); + }, + ); + + test("removes incompatible API-key and Azure credentials while preserving unknown fields", () => { + const config = { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "stale-key", + apiKeyPool: [{ id: "stale", key: "stale-key" }], + azureCredential: { token: "stale" }, + disabled: true, + forwardCompatibleFlag: "retain-me", + }, + }, + } as unknown as OcxConfig; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic! as OcxConfig["providers"][string] & Record; + expect(provider.apiKey).toBeUndefined(); + expect(provider.apiKeyPool).toBeUndefined(); + expect(provider.azureCredential).toBeUndefined(); + expect(provider.disabled).toBe(true); + expect(provider.forwardCompatibleFlag).toBe("retain-me"); + expect(provider.authMode).toBe("oauth"); + }); + test("a fresh login on an unconfigured provider gets the untouched preset", () => { const config = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const preset = OAUTH_PROVIDERS.xai!.providerConfig; + const before = structuredClone(preset); upsertOAuthProvider(config, "xai"); const provider = config.providers.xai!; expect(provider.authMode).toBe("oauth"); expect(provider.apiKey).toBeUndefined(); expect(provider.apiKeyPool).toBeUndefined(); + expect(provider.models).not.toBe(preset.models); + provider.models!.push("test-only-model"); + expect(preset).toEqual(before); + }); + + test("promotes the legacy Command Code static catalog during OAuth upsert", () => { + const config = { + port: 10100, + defaultProvider: "command-code", + providers: { + "command-code": { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + liveModels: false, + defaultModel: "deepseek-v4-flash", + models: ["deepseek-v4-flash", "kimi-k3", "glm-5.2"], + note: "operator-note", + }, + }, + } as unknown as OcxConfig; + + upsertOAuthProvider(config, "command-code"); + + expect(config.providers["command-code"]!.liveModels).toBe(true); + expect(config.providers["command-code"]!.note).toBe("operator-note"); }); }); diff --git a/tests/preload.ts b/tests/preload.ts index 1d449d442a..1fa8779137 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -21,7 +21,10 @@ import { acquireTestRunLock, resolveBareTestRunIdentity, resolveInheritedTestRunLock, + resolveWrappedTestRunLockPath, TEST_RUN_ID_ENV, + TEST_RUN_LOCK_PATH_ENV, + TEST_RUN_LOCK_TOKEN_ENV, } from "../scripts/test-run-lock"; import { rmSync } from "node:fs"; @@ -81,17 +84,26 @@ const inheritedLock = resolveInheritedTestRunLock({ env: process.env, }); process.env[TEST_RUN_ID_ENV] = runId; -await acquireTestRunLock({ +// A bare Windows run also parents nested Bun tests. Resolve its validated path +// once, then pass the complete capability to descendants just as the wrapper does. +const lockPath = inheritedLock?.lockPath + ?? (process.platform === "win32" ? resolveWrappedTestRunLockPath() : undefined); +const runLock = await acquireTestRunLock({ runId, ownerPid: bareIdentity.ownerPid, - lockPath: inheritedLock?.lockPath, - validatedRuntimePath: inheritedLock !== undefined, + lockPath, + validatedRuntimePath: lockPath !== undefined, joinExistingOwnerToken: inheritedLock?.ownerToken, onWait: owner => console.warn( `[test] bare Bun worker ${process.pid} is waiting for test run${owner ? ` pid ${owner.pid}` : ""} to release the user lock.`, ), }); +if (process.platform === "win32" && lockPath && runLock.owner) { + process.env[TEST_RUN_LOCK_PATH_ENV] = lockPath; + process.env[TEST_RUN_LOCK_TOKEN_ENV] = runLock.owner.token; +} + // Clean up only the root this preload created. The `bun run test` wrapper owns its own. process.on("exit", () => { try { rmSync(isolated.root, { recursive: true, force: true }); } catch { /* best effort at exit */ } diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index c715bcc96b..a3b81e408d 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -8,8 +8,6 @@ import { resetCommandCodeReasoningEffortsForTest, } from "../../src/providers/command-code-efforts"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; -import { classifyError } from "../../src/lib/errors"; -import { beginRequestAttempt, finishRequestAttempt } from "../../src/server/request-log"; import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -601,38 +599,6 @@ describe("Command Code provider", () => { expect(JSON.parse(built.body).params.tools).toEqual([]); }); - test("sorts tools deterministically so prompt cache prefix stays stable across input order", async () => { - const tool = (name: string, namespace?: string) => ({ - name, - ...(namespace ? { namespace } : {}), - description: name, - parameters: { type: "object" }, - }); - const tools = [ - tool("beta"), - tool("alpha"), - tool("gamma", "mcp"), - ]; - const shuffled = [tools[1], tools[2], tools[0]]; - const reversed = [...tools].reverse(); - const base = parsed(); - const builtA = await builtRequest({ ...base, context: { ...base.context, tools } }); - const builtB = await builtRequest({ ...base, context: { ...base.context, tools: shuffled } }); - const builtC = await builtRequest({ ...base, context: { ...base.context, tools: reversed } }); - const bodyA = JSON.parse(builtA.body); - const bodyB = JSON.parse(builtB.body); - const bodyC = JSON.parse(builtC.body); - expect(bodyB.params.tools).toEqual(bodyA.params.tools); - expect(bodyC.params.tools).toEqual(bodyA.params.tools); - expect(bodyB.params.system).toBe(bodyA.params.system); - expect(bodyC.params.system).toBe(bodyA.params.system); - expect(bodyA.params.tools.map((row: { name: string }) => row.name)).toEqual([ - "alpha", - "beta", - "mcp__gamma", - ]); - }); - test("matches a forced namespaced tool choice by dot or unique bare alias", async () => { const namespacedParsed = { ...parsed(), @@ -831,79 +797,137 @@ describe("Command Code provider", () => { expect(JSON.parse(built.body).params.stream).toBe(true); }); - test("derives a stable UUID only from trusted conversation identities", async () => { - const threadTurn = await builtRequest({ ...parsed(), _clientThreadId: "thread-abc" }); - const threadFollowup = await builtRequest({ + test("derives an opaque stable session id from trusted conversation identity", async () => { + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/i; + const identities = { + thread: "thread-secret-value", + replay: "replay-secret-value", + cache: "cache-secret-value", + }; + const thread = { ...parsed(), - _clientThreadId: "thread-abc", - context: { ...parsed().context, messages: [...parsed().context.messages, { role: "user", content: "followup", timestamp: 2 }] }, - }); - expect(threadTurn.headers["x-session-id"]).toBe(threadFollowup.headers["x-session-id"]); - expect(threadTurn.headers["x-session-id"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); - - const cursorTurn = await builtRequest({ ...parsed(), _cursorConversationId: "cursor-conv-1" }); - const cursorFollowup = await builtRequest({ + _clientThreadId: ` ${identities.thread} `, + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameThread = { + ...thread, + _reasoningReplayScope: { clientThreadId: "different-replay" }, + options: { ...thread.options, promptCacheKey: "different-cache" }, + }; + const replay = { ...parsed(), - _cursorConversationId: "cursor-conv-1", - context: { ...parsed().context, messages: [...parsed().context.messages, { role: "user", content: "followup", timestamp: 2 }] }, - }); - expect(cursorTurn.headers["x-session-id"]).toBe(cursorFollowup.headers["x-session-id"]); - - const rootTurn = await builtRequest(parsed()); - const rootFollowup = await builtRequest({ + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameReplay = { ...replay, options: { ...replay.options, promptCacheKey: "different-cache" } }; + const cache = { ...parsed(), - context: { ...parsed().context, messages: [...parsed().context.messages, { role: "user", content: "followup", timestamp: 2 }] }, - }); - expect(rootTurn.headers["x-session-id"]).toBe(rootFollowup.headers["x-session-id"]); + options: { ...parsed().options, promptCacheKey: ` ${identities.cache} ` }, + _promptCacheKeyIsSharedCohort: false, + }; + const sameCache = { + ...cache, + options: { ...cache.options, promptCacheKey: identities.cache }, + }; - const otherRoot = await builtRequest({ - ...parsed(), - context: { ...parsed().context, messages: [{ role: "user", content: "different root", timestamp: 1 }] }, - }); - expect(rootTurn.headers["x-session-id"]).not.toBe(otherRoot.headers["x-session-id"]); + const threadId = commandCodeSessionId(thread); + expect(threadId).toBe(commandCodeSessionId(sameThread)); + expect(threadId).not.toBe(commandCodeSessionId({ ...thread, _clientThreadId: "different-thread" })); + expect(commandCodeSessionId(replay)).toBe(commandCodeSessionId(sameReplay)); + expect(commandCodeSessionId(cache)).toBe(commandCodeSessionId(sameCache)); + expect(commandCodeSessionId(replay)).not.toBe(commandCodeSessionId(cache)); + expect(threadId).toMatch(uuid); + expect(commandCodeSessionId(replay)).toMatch(uuid); + expect(commandCodeSessionId(cache)).toMatch(uuid); + for (const raw of Object.values(identities)) expect(threadId).not.toContain(raw); + + const built = await builtRequest(thread); + expect(built.headers["x-session-id"]).toBe(threadId); }); - test("does not use a shared prompt-cache cohort for session affinity", async () => { - const first = await builtRequest({ ...parsed(), options: { ...parsed().options, promptCacheKey: "shared" }, _promptCacheKeyIsSharedCohort: true }); - const second = await builtRequest({ ...parsed(), options: { ...parsed().options, promptCacheKey: "shared" }, context: { ...parsed().context, messages: [{ role: "user", content: "different", timestamp: 1 }] }, _promptCacheKeyIsSharedCohort: true }); - expect(first.headers["x-session-id"]).not.toBe(second.headers["x-session-id"]); + test("whitespace thread and replay identities fall through to the next trusted identity at the wire", async () => { + const replay: OcxParsedRequest = { + ...parsed(), + _clientThreadId: " \t\n ", + _reasoningReplayScope: { clientThreadId: " replay-after-blank-thread " }, + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: "distinct-cache-fallback" }, + }; + const cache: OcxParsedRequest = { + ...replay, + _reasoningReplayScope: { clientThreadId: " \t\n " }, + options: { ...parsed().options, promptCacheKey: " cache-after-blank-replay " }, + }; + const cleanReplay: OcxParsedRequest = { + ...parsed(), + _reasoningReplayScope: { clientThreadId: "replay-after-blank-thread" }, + }; + const cleanCache: OcxParsedRequest = { + ...parsed(), + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: "cache-after-blank-replay" }, + }; + const cases: Array<[OcxParsedRequest, OcxParsedRequest]> = [[replay, cleanReplay], [cache, cleanCache]]; + for (const [withWhitespace, clean] of cases) { + const built = await builtRequest(withWhitespace); + const expected = await builtRequest(clean); + expect(built.headers["x-session-id"]).toBe(expected.headers["x-session-id"]); + expect(commandCodeSessionId(withWhitespace)).toBe(built.headers["x-session-id"]); + } }); - test("memoizes the session identity on a parsed request across compaction", () => { - const request = parsed(); - const first = commandCodeSessionId(request); - request.context.messages = [{ role: "user", content: "compacted history", timestamp: 2 }]; - expect(commandCodeSessionId(request)).toBe(first); - }); - - test("formats credit depletion 400 and classifies as insufficient_quota", () => { - const adapter = createCommandCodeAdapter(provider); - const rawPayload = JSON.stringify({ - success: false, - error: { - code: "BAD_REQUEST", - status: 400, - message: "You have insufficient credits to make this request. Please purchase more credits to continue using the service.", - docs: "https://commandcode.ai/docs/reference/errors/bad_request", + test("whitespace-only trusted identities produce fresh session headers", async () => { + const blank: OcxParsedRequest = { + ...parsed(), + _clientThreadId: " \t ", + _reasoningReplayScope: { clientThreadId: "\n " }, + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: " \t\n " }, + }; + const first = (await builtRequest(blank)).headers["x-session-id"]; + const second = (await builtRequest(blank)).headers["x-session-id"]; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(first).toMatch(uuid); + expect(second).toMatch(uuid); + expect(first).not.toBe(second); + }); + + test("the same literal in thread, replay and cache namespaces yields distinct stable session headers", async () => { + const literal = "same-identity-in-every-kind"; + const requests: OcxParsedRequest[] = [ + { ...parsed(), _clientThreadId: literal }, + { ...parsed(), _reasoningReplayScope: { clientThreadId: literal } }, + { + ...parsed(), + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: literal }, }, - }); - const formatted = adapter.formatErrorBody!(400, new Headers(), rawPayload); - expect(formatted).toContain("insufficient credits"); - const classified = classifyError(400, "upstream_error", formatted); - expect(classified.code).toBe("insufficient_quota"); - expect(classified.type).toBe("insufficient_quota"); - - const attempt = beginRequestAttempt(1, "command-code", "deepseek/deepseek-v4-flash", "command-code"); - finishRequestAttempt(attempt, 400, 50, undefined, formatted); - expect(attempt.errorCode).toBe("insufficient_quota"); - - // Flat error format sent by Command Code API - const flatPayload = JSON.stringify({ - code: "BAD_REQUEST", - status: 400, - message: "You have insufficient credits to make this request. Please purchase more credits to continue using the service.", - }); - const formattedFlat = adapter.formatErrorBody!(400, new Headers(), flatPayload); - expect(formattedFlat).toContain("insufficient credits"); + ]; + const ids: string[] = []; + for (const request of requests) { + const id = (await builtRequest(request)).headers["x-session-id"]!; + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(id).not.toContain(literal); + expect((await builtRequest(request)).headers["x-session-id"]).toBe(id); + expect(commandCodeSessionId(request)).toBe(id); + ids.push(id); + } + expect(new Set(ids).size).toBe(3); + }); + + test("does not derive affinity from a shared cohort or prompt text", () => { + const shared = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "shared-cache-key" }, + _promptCacheKeyIsSharedCohort: true, + }; + expect(commandCodeSessionId(shared)).not.toBe(commandCodeSessionId(shared)); + const unclassifiedCache = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "possibly-shared-cache-key" }, + }; + expect(commandCodeSessionId(unclassifiedCache)).not.toBe(commandCodeSessionId(unclassifiedCache)); + expect(commandCodeSessionId(parsed())).not.toBe(commandCodeSessionId(parsed())); }); }); diff --git a/tests/providers/commandcode-provider.test.ts b/tests/providers/commandcode-provider.test.ts index 925c226a55..f70df51093 100644 --- a/tests/providers/commandcode-provider.test.ts +++ b/tests/providers/commandcode-provider.test.ts @@ -177,15 +177,18 @@ describe("Command Code provider", () => { expect(body).not.toHaveProperty("parallel_tool_calls"); }); - test("forwards prompt_cache_key to chat completions", () => { - const route = routeModel(commandcodeConfig(), "commandcode/deepseek/deepseek-v4-flash"); + test("forwards the enabled prompt cache key to chat completions", () => { + const route = routeModel( + commandcodeConfig(), + "commandcode/deepseek/deepseek-v4-flash", + ); const request = createOpenAIChatAdapter(route.provider).buildRequest({ modelId: route.modelId, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, stream: true, - options: { promptCacheKey: "cmd-session-cache" }, + options: { promptCacheKey: "command-code-session-cache" }, }); - expect(JSON.parse(String(request.body)).prompt_cache_key).toBe("cmd-session-cache"); + expect(JSON.parse(String(request.body)).prompt_cache_key).toBe("command-code-session-cache"); }); test("discovers the live catalog with context windows and preserves slash ids", async () => { diff --git a/tests/providers/cursor/cursor-blob.test.ts b/tests/providers/cursor/cursor-blob.test.ts index 50943663d7..4d14796171 100644 --- a/tests/providers/cursor/cursor-blob.test.ts +++ b/tests/providers/cursor/cursor-blob.test.ts @@ -1754,6 +1754,87 @@ describe("Cursor bounded blob store", () => { resetCursorBlobStateForTests(); expect(cursorBlobMetrics()).toMatchObject({ count: 0, totalBytes: 0, localBytes: 0, pinnedBytes: 0 }); }); + + test("fresh blob inserts accumulate class bytes across remote and local entries", () => { + const originalNow = Date.now; + let now = 1_000; + Date.now = () => now; + try { + setCursorBlobLimitsForTests({ ttlMs: 50, maxEntryBytes: 8, maxTotalBytes: 64 }); + const remoteId = sha256(bytes("rem")); + setBlobReply(remoteId, bytes("rem")); + expect(cursorBlobMetrics()).toMatchObject({ + count: 1, + totalBytes: 3, + keyBytes: 66, + localBytes: 0, + pinnedBytes: 3 + 66, + oldestAt: null, + }); + expect(cursorBlobRetainedStoreSnapshot()).toMatchObject({ + count: 1, + bytes: 3 + 66, + evictableBytes: 0, + pinnedBytes: 3 + 66, + oldestAt: null, + }); + now = 1_001; + const localId = storeCursorBlob(bytes("loc")); + expect(cursorBlobMetrics()).toMatchObject({ + count: 2, + totalBytes: 6, + keyBytes: 132, + localBytes: 3, + pinnedBytes: 3 + 66, + oldestAt: 1_001, + }); + expect(cursorBlobRetainedStoreSnapshot()).toMatchObject({ + count: 2, + bytes: 6 + 132, + evictableBytes: 3 + 66, + pinnedBytes: 3 + 66, + oldestAt: 1_001, + }); + expectBlobHit(remoteId, bytes("rem")); + expectBlobHit(localId, bytes("loc")); + expect(cursorBlobStoreDebugSnapshotForTests().map(row => row.provenance).sort()).toEqual([ + "local-regenerated", + "remote-setBlobArgs", + ]); + } finally { + Date.now = originalNow; + } + }); + + test("releasing an expired pin still TTL-purges that row on the next write", () => { + const originalNow = Date.now; + let now = 100; + Date.now = () => now; + try { + setCursorBlobLimitsForTests({ ttlMs: 10, maxEntryBytes: 8, maxTotalBytes: 64, maxEntries: 8 }); + const scope = createCursorBlobRequestScope(); + const pinnedId = sha256(bytes("pin")); + setBlobReply(pinnedId, bytes("pin"), 1, scope); + sealCursorBlobRequestScope(scope); + now = 105; + const liveId = sha256(bytes("live")); + setBlobReply(liveId, bytes("live")); + now = 111; + releaseCursorBlobRequestScope(scope); + const laterId = sha256(bytes("new")); + setBlobReply(laterId, bytes("new")); + // Observe before getBlob can lazily delete an expired entry itself. + expect(cursorBlobMetrics()).toMatchObject({ count: 2, totalBytes: 7, keyBytes: 132 }); + expect(cursorBlobRetainedStoreSnapshot()).toMatchObject({ + count: 2, bytes: 7 + 132, pinnedBytes: 7 + 132, evictableBytes: 0, + }); + expectBlobMiss(pinnedId); + expectBlobHit(liveId, bytes("live")); + expectBlobHit(laterId, bytes("new")); + } finally { + Date.now = originalNow; + } + }); }); describe("Cursor blob ID key channel bounds", () => { diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index 1a36bb732c..852936a2e5 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -2,12 +2,15 @@ import { describe, expect, test } from "bun:test"; import { fromBinary, toJson } from "@bufbuild/protobuf"; import { ValueSchema } from "@bufbuild/protobuf/wkt"; import { normalizeArgKeys } from "../../../src/adapters/cursor/arg-normalize"; +import { buildTools } from "../../../src/responses/parser-tools"; import { appendCursorGenericToolUseHint, buildCursorToolDefinitions, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote, + CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, CURSOR_EXEC_COMMAND_INPUT_SCHEMA, + CURSOR_FREEFORM_INPUT_SCHEMA, cursorRequestAdvertisesApplyPatch, cursorRequestUsesCodeMode, isCursorCodeModeExecTool, @@ -128,19 +131,216 @@ describe("Cursor tool definitions", () => { expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(CURSOR_EXEC_COMMAND_INPUT_SCHEMA); }); - test("advertises every freeform tool with the required string input schema", () => { - const defs = buildCursorToolDefinitions([{ - name: "exec", - description: "Run code", + test("preserves sandbox escalation controls in shell advertisement and normalization", () => { + const advertised = CURSOR_EXEC_COMMAND_INPUT_SCHEMA.properties; + const normalized = CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA.properties; + + expect(advertised.sandbox_permissions.enum).toEqual(["use_default", "require_escalated"]); + expect(advertised.justification.type).toBe("string"); + expect(advertised.prefix_rule.items).toEqual({ type: "string" }); + expect(advertised.login.type).toBe("boolean"); + expect(normalized.sandbox_permissions.enum).toEqual(["use_default", "require_escalated"]); + expect(normalized.justification.type).toBe("string"); + expect(normalized.prefix_rule.items).toEqual({ type: "string" }); + expect(normalized.login.type).toBe("boolean"); + }); + + test("advertises and normalizes freeform tools as one required string input", () => { + // Independent wire contract: using the production constant as the expected value + // would let an incorrect constant validate both schema selection and protobuf output. + const expectedSchema = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }; + const tool: OcxTool = { + name: "apply_patch", + description: "Apply a patch", parameters: {}, freeform: true, - }]); + }; + + expect(CURSOR_FREEFORM_INPUT_SCHEMA).toEqual(expectedSchema); + expect(cursorToolInputSchema(tool)).toEqual(expectedSchema); + expect(cursorToolArgNormalizeSchema(tool)).toEqual(expectedSchema); + const defs = buildCursorToolDefinitions([tool]); + expect(defs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(expectedSchema); + + const codeModeExec: OcxTool = { name: "exec", description: "Run JavaScript", freeform: true }; + expect(cursorToolInputSchema(codeModeExec)).toEqual(expectedSchema); + expect(cursorToolArgNormalizeSchema(codeModeExec)).toEqual(expectedSchema); + const execDefs = buildCursorToolDefinitions([codeModeExec]); + expect(execDefs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, execDefs[0]!.inputSchema))).toEqual(expectedSchema); + }); - expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual({ + describe("freeform input guidance", () => { + const closedSchema = { type: "object", properties: { input: { type: "string" } }, required: ["input"], + additionalProperties: false, + }; + + test("preserves buildTools guidance through both selectors and protobuf registration", () => { + const tools = buildTools([ + { type: "custom", name: "apply_patch", description: "Apply a patch" }, + { type: "custom", name: "exec", description: "Run JavaScript" }, + { type: "namespace", name: "mcp__custom", tools: [ + { type: "custom", name: "exec_command", description: "Custom input" }, + ] }, + ]); + const descriptions = [ + "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope.", + "Raw freeform input for this tool.", + "Raw freeform input for this tool.", + ]; + expect(tools).toHaveLength(3); + const defs = buildCursorToolDefinitions(tools); + expect(defs.map(def => def.toolName)).toEqual(["apply_patch", "exec", "mcp__custom__exec_command"]); + expect(defs.map(def => def.description)).toEqual(["Apply a patch", "Run JavaScript", "Custom input"]); + for (const [index, description] of descriptions.entries()) { + const expected = { + ...closedSchema, + properties: { input: { type: "string", description } }, + }; + expect(tools![index]).toMatchObject({ + freeform: true, + parameters: { properties: { input: { type: "string", description } } }, + }); + expect(cursorToolInputSchema(tools![index]!)).toEqual(expected); + expect(cursorToolArgNormalizeSchema(tools![index]!)).toEqual(expected); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[index]!.inputSchema))).toEqual(expected); + } }); + + test("isolates per-tool descriptions including empty strings without mutating inputs or defaults", () => { + const descriptions = ["guidance-A", "guidance-B", undefined, ""]; + const tools: OcxTool[] = descriptions.map((description, index) => ({ + name: `custom_${index}`, + description: "Top-level description must not become input guidance", + freeform: true, + parameters: Object.freeze({ + type: "object", + properties: Object.freeze({ + input: Object.freeze({ type: "string", ...(description !== undefined ? { description } : {}) }), + }), + }), + })); + // Collect all results before comparing, so shared-object mutation cannot hide + // behind a check that runs before the next tool overwrites the guidance. + const advertised = tools.map(cursorToolInputSchema); + const normalized = tools.map(cursorToolArgNormalizeSchema); + const defs = buildCursorToolDefinitions(tools); + expect(defs).toHaveLength(4); + for (const [index, description] of descriptions.entries()) { + const expected = description === undefined ? closedSchema : { + ...closedSchema, + properties: { input: { type: "string", description } }, + }; + expect(advertised[index]).toEqual(expected); + expect(normalized[index]).toEqual(expected); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[index]!.inputSchema))).toEqual(expected); + } + expect(CURSOR_FREEFORM_INPUT_SCHEMA).toEqual(closedSchema); + }); + + test("copies only input description while enforcing the canonical closed shape", () => { + const tool: OcxTool = { + name: "custom_shape", + description: "Custom input", + freeform: true, + parameters: { + type: "object", + properties: { + input: { type: "number", description: "guidance-A", enum: [1, 2], default: 1 }, + command: { type: "string" }, + }, + required: ["command"], + additionalProperties: true, + }, + }; + const before = JSON.stringify(tool.parameters); + const expected = { + ...closedSchema, + properties: { input: { type: "string", description: "guidance-A" } }, + }; + expect(cursorToolInputSchema(tool)).toEqual(expected); + expect(cursorToolArgNormalizeSchema(tool)).toEqual(expected); + const defs = buildCursorToolDefinitions([tool]); + expect(defs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(expected); + expect(JSON.stringify(tool.parameters)).toBe(before); + }); + + test.each([ + ["missing properties", {}], + ["null properties", { properties: null }], + ["string properties", { properties: "input" }], + ["array properties", { properties: [{ input: { description: "not guidance" } }] }], + ["missing input", { properties: {} }], + ["null input", { properties: { input: null } }], + ["string input", { properties: { input: "not guidance" } }], + ["array input", { properties: { input: [{ description: "not guidance" }] } }], + ["numeric description", { properties: { input: { description: 42 } } }], + ["null description", { properties: { input: { description: null } } }], + ["boolean description", { properties: { input: { description: false } } }], + ["object description", { properties: { input: { description: { text: "not guidance" } } } }], + ] as const)("uses the canonical fallback for %s", (_label, parameters) => { + const tool: OcxTool = { name: "custom_fallback", description: "Top-level only", freeform: true, parameters }; + expect(cursorToolInputSchema(tool)).toEqual(closedSchema); + expect(cursorToolArgNormalizeSchema(tool)).toEqual(closedSchema); + const defs = buildCursorToolDefinitions([tool]); + expect(defs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(closedSchema); + expect(CURSOR_FREEFORM_INPUT_SCHEMA).toEqual(closedSchema); + }); + }); + + test("rejects freeform tools that reuse bare shell bridge names", () => { + for (const name of ["exec_command", "shell_command"]) { + const tool: OcxTool = { name, description: "Custom", parameters: {}, freeform: true }; + + expect(() => cursorToolInputSchema(tool)).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + expect(() => cursorToolArgNormalizeSchema(tool)).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + expect(() => buildCursorToolDefinitions([tool])).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + } + }); + + test("preserves namespaced shell names and ordinary freeform/non-freeform contracts", () => { + const expectedFreeformSchema = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }; + const namespacedFreeform: OcxTool = { + name: "exec_command", + namespace: "mcp__custom", + description: "Custom", + parameters: {}, + freeform: true, + }; + expect(cursorToolInputSchema(namespacedFreeform)).toEqual(expectedFreeformSchema); + expect(cursorToolArgNormalizeSchema(namespacedFreeform)).toEqual(expectedFreeformSchema); + const defs = buildCursorToolDefinitions([namespacedFreeform]); + expect(defs).toHaveLength(1); + expect(defs[0]?.toolName).toBe("mcp__custom__exec_command"); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(expectedFreeformSchema); + + const ordinaryFreeform: OcxTool = { name: "apply_patch", description: "Patch", parameters: {}, freeform: true }; + expect(cursorToolInputSchema(ordinaryFreeform)).toEqual(expectedFreeformSchema); + expect(cursorToolArgNormalizeSchema(ordinaryFreeform)).toEqual(expectedFreeformSchema); + + const ordinaryFunction: OcxTool = { + name: "exec_command", + description: "Run", + parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] }, + }; + expect(cursorToolInputSchema(ordinaryFunction)).toEqual(CURSOR_EXEC_COMMAND_INPUT_SCHEMA); + expect(cursorToolArgNormalizeSchema(ordinaryFunction)).toEqual(ordinaryFunction.parameters); }); test("normalizes advertised shell_command cmd args to Responses command before Codex sees them", () => { @@ -169,6 +369,19 @@ describe("Cursor tool definitions", () => { expect(normalizeArgKeys({ command: "git status" }, cursorToolArgNormalizeSchema(tool))).toEqual({ command: "git status", }); + expect(normalizeArgKeys({ + cmd: "git status", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }, cursorToolArgNormalizeSchema(tool))).toEqual({ + command: "git status", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }); }); test("preserves cmd-only exec_command schemas during Responses normalization", () => { @@ -197,6 +410,19 @@ describe("Cursor tool definitions", () => { cmd: "git status", workdir: "C:/repo", }); + expect(normalizeArgKeys({ + cmd: "git fetch", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }, cursorToolArgNormalizeSchema(tool))).toEqual({ + cmd: "git fetch", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }); }); test("shell bridge command validation honors the schema-required command key", () => { @@ -431,7 +657,6 @@ describe("Cursor tool definitions", () => { expect(note).toContain("Get-Content"); expect(note).toContain("`cat`/`ls`/`rg`"); expect(note).toContain("Codex client host"); - expect(note).toContain("do not stop or narrate intended future actions in plain text"); }); test("adds codex-native edit guidance only when apply_patch is advertised", () => { @@ -546,7 +771,6 @@ describe("Cursor code mode tool guidance", () => { expect(note).toContain("no further asterisks"); expect(note).not.toContain("*** Begin Patch ***"); expect(note).toContain("OpenCodex does not rewrite JavaScript inside exec"); - expect(note).toContain("Tool-selection commentary is forbidden"); // The flat-catalog shell-bridge guidance must NOT appear: naming a top-level // `exec_command` in code mode sends the model after a tool that does not exist. diff --git a/tests/providers/github-copilot/github-copilot-account-origin.test.ts b/tests/providers/github-copilot/github-copilot-account-origin.test.ts index 5f464a1d7e..4da89c816e 100644 --- a/tests/providers/github-copilot/github-copilot-account-origin.test.ts +++ b/tests/providers/github-copilot/github-copilot-account-origin.test.ts @@ -1,10 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearGenericFailoverHealth } from "../../../src/oauth/generic-account-failover"; import { getAccountSet, saveCredential, setActiveAccount } from "../../../src/oauth/store"; import { handleResponses } from "../../../src/server/responses"; +import { saveConfig } from "../../../src/config"; +import { setActiveProviderApiKey } from "../../../src/providers/api-keys"; import type { OcxConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; @@ -16,6 +18,37 @@ const GITHUB_USER_URL = "https://api.github.com/user"; const originalFetch = globalThis.fetch; const originalHome = process.env.OPENCODEX_HOME; let home = ""; +let beforeBuildReturns: (() => Promise) | undefined; +let beforePacingReturns: (() => Promise) | undefined; +const actualPacing = await import("../../../src/providers/request-pacing"); +const originalWaitForSlot = actualPacing.waitForProviderRequestSlot; +mock.module("../../../src/providers/request-pacing", () => ({ + ...actualPacing, + waitForProviderRequestSlot: async (...args: Parameters) => { + const result = await originalWaitForSlot(...args); + const gate = beforePacingReturns; + beforePacingReturns = undefined; + await gate?.(); + return result; + }, +})); +const actualAdapterResolver = await import("../../../src/server/adapter-resolve"); +const originalResolveAdapter = actualAdapterResolver.resolveAdapter; +mock.module("../../../src/server/adapter-resolve", () => ({ + ...actualAdapterResolver, + resolveAdapter: (...args: Parameters) => { + const adapter = originalResolveAdapter(...args); + const build = adapter.buildRequest.bind(adapter); + adapter.buildRequest = async (...buildArgs) => { + const built = await build(...buildArgs); + const gate = beforeBuildReturns; + beforeBuildReturns = undefined; + await gate?.(); + return built; + }; + return adapter; + }, +})); type Wire = "chat" | "responses"; @@ -40,12 +73,12 @@ function config(wire: Wire): OcxConfig { } as OcxConfig; } -function request(wire: Wire): Request { +function request(wire: Wire, extra: Record = {}): Request { const model = wire === "chat" ? "gpt-4o" : "gpt-5.4"; return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: `github-copilot/${model}`, input: "hello", stream: false }), + body: JSON.stringify({ model: `github-copilot/${model}`, input: "hello", stream: false, ...extra }), }); } @@ -104,6 +137,7 @@ function installFetch(options: { statuses: number[]; switchToAccountId?: string; switchOn: "refresh" | "first-dispatch" | "never"; + emptyFirst?: boolean; }): { dispatches: { origin: string; authorization: string }[] } { const dispatches: { origin: string; authorization: string }[] = []; let refreshSwitched = false; @@ -138,6 +172,14 @@ function installFetch(options: { headers: status === 429 ? { "retry-after": "1" } : undefined, }); } + if (options.emptyFirst && dispatches.length === 1) { + return Response.json({ choices: [{ index: 0, message: { role: "assistant", content: "" }, finish_reason: "stop" }] }); + } + if (JSON.parse(String(init?.body ?? "{}")).stream === true && options.wire === "chat") { + return new Response('data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + } return successResponse(options.wire); } return originalFetch(input, init); @@ -146,6 +188,8 @@ function installFetch(options: { } beforeEach(() => { + beforeBuildReturns = undefined; + beforePacingReturns = undefined; home = mkdtempSync(join(tmpdir(), "ocx-copilot-origin-")); process.env.OPENCODEX_HOME = home; clearGenericFailoverHealth(); @@ -160,8 +204,154 @@ afterEach(() => { }); describe("GitHub Copilot bearer/origin snapshot atomicity", () => { + test.each(["chat", "responses", "image", "web-search"] as const)("%s API-key dispatch uses a selection committed during pacing", async path => { + const cfg = config("chat"); + cfg.defaultProvider = "fixture"; + cfg.providers = { fixture: { + adapter: path === "responses" ? "openai-responses" : "openai-chat", authMode: "key", + baseUrl: "https://fixture.invalid/v1", apiKey: "synthetic-a", models: ["model"], + apiKeyPool: [{ id: "a", key: "synthetic-a" }, { id: "b", key: "synthetic-b" }], + } }; + if (path === "image") { + cfg.images = { bridgeEnabled: true }; + cfg.providers.xai = { adapter: "openai-chat", authMode: "key", apiKey: "synthetic-image-key", baseUrl: "https://api.x.ai/v1" }; + } else if (path === "web-search") cfg.webSearchSidecar = { enabled: true, backend: "exa", exaApiKey: "synthetic-search-key" }; + saveConfig(cfg); + beforePacingReturns = async () => { expect(setActiveProviderApiKey(cfg, "fixture", "b")).toBe(true); }; + const sent: string[] = []; + globalThis.fetch = (async (_input, init) => { + sent.push(new Headers(init?.headers).get("authorization") ?? ""); + if (path === "image" || path === "web-search") return new Response('data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } }); + return successResponse(path); + }) as typeof fetch; + const response = await handleResponses(request("chat", { + model: "fixture/model", stream: path === "image" || path === "web-search", + ...(path === "image" || path === "web-search" ? { tools: [{ type: path === "image" ? "image_generation" : "web_search" }] } : {}), + }), cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("ok"); + expect(sent).toEqual(["Bearer synthetic-b"]); + }); + + test("a pacing switch to B keeps B when Anthropic rebuilds an image after 413", async () => { + for (const id of ["a", "b"]) await saveCredential("anthropic", { + access: `synthetic-anthropic-${id}`, refresh: `synthetic-refresh-${id}`, + expires: Date.now() + 3_600_000, accountId: id, + }); + const rows = getAccountSet("anthropic")!.accounts; + await setActiveAccount("anthropic", rows[0]!.id); + beforePacingReturns = async () => { await setActiveAccount("anthropic", rows[1]!.id); }; + const sent: string[] = []; + globalThis.fetch = (async (_input, init) => { + sent.push(new Headers(init?.headers).get("authorization") ?? ""); + if (sent.length === 1) return Response.json({ error: { type: "request_too_large", message: "too large" } }, { status: 413 }); + return Response.json({ id: "message-selection", type: "message", role: "assistant", + content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", usage: { input_tokens: 1, output_tokens: 1 } }); + }) as typeof fetch; + const cfg = config("chat"); + cfg.providers = { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com", models: ["claude-fable-5"] } }; + cfg.defaultProvider = "anthropic"; + const response = await handleResponses(request("chat", { + model: "anthropic/claude-fable-5", + input: [{ role: "user", content: [ + { type: "input_text", text: "look" }, + { type: "input_image", image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" }, + ] }], + }), cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("ok"); + expect(sent).toEqual(["Bearer synthetic-anthropic-b", "Bearer synthetic-anthropic-b"]); + }); + + test("a pacing switch to B keeps B for an empty-completion continuation", async () => { + const accounts = await seedAccounts(); + beforePacingReturns = async () => { await setActiveAccount("github-copilot", accounts.b); }; + const observed = installFetch({ wire: "chat", statuses: [200, 200], switchOn: "never", emptyFirst: true }); + const cfg = config("chat"); + cfg.emptyCompletionRetry = true; + const response = await handleResponses(request("chat"), cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("ok"); + expect(observed.dispatches).toEqual([ + { origin: ACCOUNT_B_ORIGIN, authorization: bearer("copilot-access-b") }, + { origin: ACCOUNT_B_ORIGIN, authorization: bearer("copilot-access-b") }, + ]); + }); + + test.each(["image", "web-search"] as const)("%s main-model dispatch follows a manual choice made during pacing", async path => { + const accounts = await seedAccounts(); + beforePacingReturns = async () => { await setActiveAccount("github-copilot", accounts.b); }; + const observed = installFetch({ wire: "chat", statuses: [200], switchOn: "never" }); + const cfg = config("chat"); + if (path === "image") { + cfg.images = { bridgeEnabled: true }; + cfg.providers.xai = { adapter: "openai-chat", authMode: "key", apiKey: "synthetic-image-key", baseUrl: "https://api.x.ai/v1" }; + } else { + cfg.webSearchSidecar = { enabled: true, backend: "exa", exaApiKey: "synthetic-search-key" }; + } + const response = await handleResponses(request("chat", { + stream: true, tools: [{ type: path === "image" ? "image_generation" : "web_search" }], + }), cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("ok"); + expect(observed.dispatches).toEqual([{ origin: ACCOUNT_B_ORIGIN, authorization: bearer("copilot-access-b") }]); + }); + + test("a cached search-loop adapter cannot bless A wire with B's current snapshot", async () => { + const accounts = await seedAccounts(); + beforePacingReturns = async () => { await setActiveAccount("github-copilot", accounts.b); }; + const sent: string[] = []; + const bodies: Array<{ messages: Array<{ role: string }> }> = []; + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "api.exa.ai") return Response.json({ results: [] }); + if (!url.hostname.endsWith(".githubcopilot.com")) throw new Error("Unexpected fixture request"); + sent.push(new Headers(init?.headers).get("authorization") ?? ""); + bodies.push(JSON.parse(String(init?.body))); + const delta = sent.length === 1 + ? { tool_calls: [{ index: 0, id: "search-1", type: "function", function: { name: "web_search", arguments: '{"query":"fixture"}' } }] } + : { content: "ok after search" }; + return new Response(`data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: sent.length === 1 ? "tool_calls" : "stop" }] })}\n\ndata: [DONE]\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + const cfg = config("chat"); + cfg.webSearchSidecar = { enabled: true, backend: "exa", exaApiKey: "synthetic-search-key" }; + const response = await handleResponses(request("chat", { stream: true, tools: [{ type: "web_search" }] }), cfg, { model: "", provider: "" }); + expect(await response.text()).toContain("ok after search"); + expect(sent).toEqual(["Bearer copilot-access-b", "Bearer copilot-access-b"]); + expect(bodies[1]!.messages.some(message => message.role === "tool")).toBe(true); + }); + + test("a key removed during pacing is never dispatched", async () => { + const cfg = config("chat"); + cfg.defaultProvider = "fixture"; + cfg.providers = { fixture: { adapter: "openai-chat", authMode: "key", baseUrl: "https://fixture.invalid/v1", apiKey: "synthetic-a" } }; + beforePacingReturns = async () => { delete cfg.providers.fixture; }; + let sends = 0; + globalThis.fetch = (async () => { sends++; return successResponse("chat"); }) as typeof fetch; + const response = await handleResponses(request("chat", { model: "fixture/model" }), cfg, { model: "", provider: "" }); + await response.text(); + expect(response.status).not.toBe(200); + expect(sends).toBe(0); + }); + for (const wire of ["chat", "responses"] as const) { - test(`${wire} initial refresh keeps account A's origin after B becomes active`, async () => { + test(`${wire} revalidates selection after pacing and before physical dispatch`, async () => { + const accounts = await seedAccounts(); + beforePacingReturns = async () => { await setActiveAccount("github-copilot", accounts.b); }; + const observed = installFetch({ wire, statuses: [200], switchOn: "never" }); + const response = await handleResponses(request(wire), config(wire), { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(observed.dispatches).toEqual([{ origin: ACCOUNT_B_ORIGIN, authorization: bearer("copilot-access-b") }]); + }); + test(`${wire} rebuilds after a manual selection during asynchronous request building`, async () => { + const accounts = await seedAccounts(); + beforeBuildReturns = async () => { await setActiveAccount("github-copilot", accounts.b); }; + const observed = installFetch({ wire, statuses: [200], switchOn: "never" }); + const response = await handleResponses(request(wire), config(wire), { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(observed.dispatches).toEqual([{ origin: ACCOUNT_B_ORIGIN, authorization: bearer("copilot-access-b") }]); + }); + test(`${wire} initial admission follows a newer manual selection with its matching origin`, async () => { const accounts = await seedAccounts(0); const observed = installFetch({ wire, @@ -175,12 +365,12 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => { expect(response.status).toBe(200); expect(observed.dispatches).toEqual([{ - origin: ACCOUNT_A_ORIGIN, - authorization: bearer("copilot-access-a-refreshed"), + origin: ACCOUNT_B_ORIGIN, + authorization: bearer("copilot-access-b"), }]); }); - test(`${wire} 401 replay keeps refreshed account A's origin after B becomes active`, async () => { + test(`${wire} 401 replay follows a newer manual selection with its matching origin`, async () => { const accounts = await seedAccounts(); const observed = installFetch({ wire, @@ -195,7 +385,7 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => { expect(response.status).toBe(200); expect(observed.dispatches).toEqual([ { origin: ACCOUNT_A_ORIGIN, authorization: bearer("copilot-access-a") }, - { origin: ACCOUNT_A_ORIGIN, authorization: bearer("copilot-access-a-refreshed") }, + { origin: ACCOUNT_B_ORIGIN, authorization: bearer("copilot-access-b") }, ]); }); } diff --git a/tests/providers/kiro/kiro-adapter.test.ts b/tests/providers/kiro/kiro-adapter.test.ts index cb530a8d51..f4a9aa83e6 100644 --- a/tests/providers/kiro/kiro-adapter.test.ts +++ b/tests/providers/kiro/kiro-adapter.test.ts @@ -1079,6 +1079,275 @@ describe("kiro adapter — buildRequest", () => { ); }); + describe("adjacent Kiro result coalescing (#3734)", () => { + const execTool = { name: "exec", description: "Run JavaScript", parameters: { type: "object" } }; + const pngBytes = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const pngData = "data:image/png;base64," + pngBytes; + const emptyExecWrapper = "Script completed\nWall time 0.1 seconds\nOutput:\n"; + const failedExecWrapper = "Script failed\nWall time 0.1 seconds\nOutput:\n"; + const longRawId = "call_" + "x".repeat(64); + const truncatedRawId = "call_" + "x".repeat(59); + + function execCall(id: string) { + return { role: "assistant", content: [{ type: "toolCall", id, name: "exec", arguments: {} }] }; + } + function execResult(id: string, content: unknown, extra: Record = {}) { + return { role: "toolResult" as const, toolCallId: id, toolName: "exec", content, isError: false, ...extra }; + } + function currentUser(body: string) { + return JSON.parse(body).conversationState.currentMessage.userInputMessage as { + images?: Array<{ format: string; source: { bytes: string } }>; + userInputMessageContext: { toolResults: Array & { content: Array<{ text: string }>; status: string; toolUseId: string }> }; + }; + } + function expectWireResults(body: string, expected: Array<{ content: Array<{ text: string }>; status: string; toolUseId: string }>) { + const results = currentUser(body).userInputMessageContext.toolResults; + expect(results).toEqual(expected); + for (const result of results) { + expect(result).not.toHaveProperty("rawId"); + expect(result).not.toHaveProperty("count"); + expect(result).not.toHaveProperty("texts"); + expect(result).not.toHaveProperty("hasImages"); + expect(Object.keys(result).sort()).toEqual(["content", "status", "toolUseId"]); + } + return results; + } + + test("parseRequest keeps a complete external task input then coalesces custom exec notify/notify/final", async () => { + const raw = { + model: "claude-sonnet-4.5", + input: [ + { + type: "function_call_output", + id: "task_3735", + name: "Launch Task", + namespace: "agent.workspace", + output: "inspect grouping", + }, + { type: "custom_tool_call", call_id: "call_exec_group", name: "exec", input: "await tools.exec_command({cmd: 'ls'})" }, + { type: "custom_tool_call_output", call_id: "call_exec_group", output: "notify-one" }, + { type: "custom_tool_call_output", call_id: "call_exec_group", output: "notify-two" }, + { type: "custom_tool_call_output", call_id: "call_exec_group", output: "final-text" }, + ], + tools: [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "text" } }], + }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + expect(raw).toEqual(original); + expect(parsed.context.messages).toMatchObject([ + { role: "user", content: "inspect grouping" }, + { role: "assistant", content: [{ type: "toolCall", id: "call_exec_group", name: "exec" }] }, + { role: "toolResult", toolCallId: "call_exec_group", toolName: "exec", content: "notify-one" }, + { role: "toolResult", toolCallId: "call_exec_group", toolName: "exec", content: "notify-two" }, + { role: "toolResult", toolCallId: "call_exec_group", toolName: "exec", content: "final-text" }, + ]); + + const messagesBefore = structuredClone(parsed.context.messages); + const { body } = await createKiroAdapter(provider).buildRequest(parsed); + expect(raw).toEqual(original); + expect(parsed.context.messages).toEqual(messagesBefore); + expectWireResults(body, [{ + content: [{ text: "notify-one" }, { text: "notify-two" }, { text: "final-text" }], + status: "success", + toolUseId: "call_exec_group", + }]); + }); + + test("direct Ocx three same-id results coalesce in order", async () => { + const messages = [ + { role: "user", content: "run it" }, + execCall("call-x"), + execResult("call-x", "notify-one"), + execResult("call-x", "notify-two"), + execResult("call-x", "final-text"), + ]; + const { body } = await createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool])); + expectWireResults(body, [{ + content: [{ text: "notify-one" }, { text: "notify-two" }, { text: "final-text" }], + status: "success", + toolUseId: "call-x", + }]); + }); + + test.each([ + { + name: "image and error stay sticky after a later success", + id: "call-sticky", + results: [ + execResult("call-sticky", [ + { type: "text", text: "caption" }, + { type: "image", imageUrl: pngData, detail: "high" }, + ], { isError: true }), + execResult("call-sticky", "later-ok"), + ], + content: [{ text: "caption" }, { text: "later-ok" }], + status: "error", + images: [{ format: "png", source: { bytes: pngBytes } }], + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "image-only later output does not inject empty placeholders into text", + id: "call-img", + results: [ + execResult("call-img", "visible"), + execResult("call-img", [{ type: "image", imageUrl: pngData, detail: "high" }]), + ], + content: [{ text: "visible" }], + status: "success", + images: [{ format: "png", source: { bytes: pngBytes } }], + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "initial empty placeholder is removed once later text exists", + id: "call-empty-then-text", + results: [execResult("call-empty-then-text", ""), execResult("call-empty-then-text", "later-text")], + content: [{ text: "later-text" }], + status: "success", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "whitespace before and between meaningful chunks is preserved", + id: "call-ws", + results: [execResult("call-ws", " "), execResult("call-ws", "alpha"), execResult("call-ws", "\n\t "), execResult("call-ws", "beta")], + content: [{ text: " " }, { text: "alpha" }, { text: "\n\t " }, { text: "beta" }], + status: "success", + }, + { + name: "later successful empty exec wrapper is skipped", + id: "call-skip-empty", + results: [execResult("call-skip-empty", "keep-me"), execResult("call-skip-empty", emptyExecWrapper)], + content: [{ text: "keep-me" }], + status: "success", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE, emptyExecWrapper], + }, + { + name: "all-empty success group keeps one empty-exec fallback", + id: "call-all-empty", + results: [execResult("call-all-empty", ""), execResult("call-all-empty", emptyExecWrapper)], + content: [{ text: EMPTY_EXEC_OUTPUT_MESSAGE }], + status: "success", + forbidden: [KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "all-empty image group uses neutral KIRO_EMPTY without an error", + id: "call-empty-img", + results: [ + execResult("call-empty-img", [{ type: "image", imageUrl: pngData, detail: "high" }]), + execResult("call-empty-img", ""), + ], + content: [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }], + status: "success", + images: [{ format: "png", source: { bytes: pngBytes } }], + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE], + }, + { + name: "all-empty error group uses neutral KIRO_EMPTY without images", + id: "call-empty-error", + results: [execResult("call-empty-error", ""), execResult("call-empty-error", "", { isError: true })], + content: [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }], + status: "error", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE], + }, + { + name: "failed empty wrapper in a multi group stays raw", + id: "call-failed-multi", + results: [execResult("call-failed-multi", emptyExecWrapper), execResult("call-failed-multi", failedExecWrapper)], + content: [{ text: failedExecWrapper }], + status: "success", + forbidden: [EMPTY_EXEC_OUTPUT_MESSAGE, FAILED_EXEC_OUTPUT_MESSAGE, KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "single empty exec group keeps option-aware fallback", + id: "call-single-empty", + results: [execResult("call-single-empty", emptyExecWrapper)], + content: [{ text: EMPTY_EXEC_OUTPUT_MESSAGE }], + status: "success", + forbidden: [KIRO_EMPTY_TOOL_RESULT_MESSAGE], + }, + { + name: "exact unusual raw pair still normalizes and coalesces", + id: "pipe|raw", + results: [execResult("pipe|raw", "left"), execResult("pipe|raw", "right")], + content: [{ text: "left" }, { text: "right" }], + status: "success", + toolUseId: "pipe_raw", + }, + ])("$name", async ({ id, results, content, status, images, forbidden, toolUseId }) => { + const messages = [{ role: "user", content: "run it" }, execCall(id), ...results]; + const original = structuredClone(messages); + const { body } = await createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool])); + expect(messages).toEqual(original); + const current = currentUser(body); + expectWireResults(body, [{ content, status, toolUseId: toolUseId ?? id }]); + if (images) expect(current.images).toEqual(images); + const joined = current.userInputMessageContext.toolResults.flatMap(result => result.content.map(part => part.text)).join("\n"); + for (const token of forbidden ?? []) expect(joined).not.toContain(token); + }); + + test("A/B/A same-id repeat still fails the final validator", async () => { + const messages = [ + { role: "user", content: "run it" }, + { role: "assistant", content: [ + { type: "toolCall", id: "call-a", name: "exec", arguments: {} }, + { type: "toolCall", id: "call-b", name: "exec", arguments: {} }, + ] }, + execResult("call-a", "first-a"), + execResult("call-b", "first-b"), + execResult("call-a", "second-a"), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "Kiro tool result has no matching tool use", + ); + }); + + test.each([ + { name: "user", barrier: { role: "user", content: "steer" } }, + { name: "developer", barrier: { role: "developer", content: "note" } }, + { name: "assistant", barrier: { role: "assistant", content: [{ type: "text", text: "mid" }] } }, + { name: "reasoning-only assistant", barrier: { role: "assistant", content: [{ type: "thinking", thinking: "plan" }] } }, + ])("$name barrier prevents coalescing the later same-id result", async ({ barrier }) => { + const messages = [ + { role: "user", content: "run it" }, + execCall("call-x"), + execResult("call-x", "before"), + barrier, + execResult("call-x", "after"), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "Kiro tool result has no matching tool use", + ); + }); + + test("later encrypted output throws before grouping even with the same raw id", async () => { + const messages = [ + { role: "user", content: "run it" }, + execCall("call-x"), + execResult("call-x", "before"), + execResult("call-x", "opaque", { containsEncryptedContent: true }), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "cannot translate encrypted output", + ); + }); + + test.each([ + { name: "pipe vs underscore", callId: "call|raw", resultId: "call_raw" }, + { name: "whitespace vs underscore", callId: "call raw", resultId: "call_raw" }, + { name: "truncation", callId: longRawId, resultId: truncatedRawId }, + { name: "case", callId: "Call-Raw", resultId: "call-Raw" }, + { name: "empty result id", callId: "call-x", resultId: "" }, + ])("raw id mismatch ($name) remains orphaned", async ({ callId, resultId }) => { + const messages = [ + { role: "user", content: "run it" }, + execCall(callId), + execResult(resultId, "nope"), + ]; + await expect(createKiroAdapter(provider).buildRequest(parsedWith(messages, [execTool]))).rejects.toThrow( + "orphaned tool result", + ); + }); + }); + test("adjacent user/developer and assistant items normalize without synthetic prose", async () => { const messages = [ { role: "developer", content: "first" }, diff --git a/tests/providers/kiro/kiro-pool-rank.test.ts b/tests/providers/kiro/kiro-pool-rank.test.ts index b42eb969d0..554972704a 100644 --- a/tests/providers/kiro/kiro-pool-rank.test.ts +++ b/tests/providers/kiro/kiro-pool-rank.test.ts @@ -143,7 +143,7 @@ describe("pre-dispatch account preference", () => { authMode: "oauth", } as unknown as OcxProviderConfig; - const config = { providers: { xai: OAUTH_PROVIDER } } as unknown as OcxConfig; + const config = { providers: { xai: OAUTH_PROVIDER }, oauthAccountFailover: { enabled: true } } as unknown as OcxConfig; const originalHome = process.env.OPENCODEX_HOME; let home: string; @@ -159,7 +159,7 @@ describe("pre-dispatch account preference", () => { return getAccountSet(providerName)?.accounts.map(a => a.id) ?? []; } - test("the account with more headroom is chosen before the first request", async () => { + test("an enabled pool avoids a known-exhausted selected account", async () => { home = mkdtempSync(join(tmpdir(), "ocx-predispatch-")); process.env.OPENCODEX_HOME = home; clearGenericFailoverHealth(); @@ -167,7 +167,7 @@ describe("pre-dispatch account preference", () => { try { const ids = await seedAccounts(2); await setActiveAccount("xai", ids[0]!); - setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 95, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 100, updatedAt: Date.now() }); setCachedProviderAccountQuotaForTests("xai", ids[1]!, { monthlyPercent: 5, updatedAt: Date.now() }); expect(preferredInitialAccount(config, "xai")).toBe(ids[1]); } finally { @@ -365,14 +365,7 @@ describe("pre-dispatch account preference", () => { } }); - test("neither a redirecting nor a non-redirecting selection touches the credential store", async () => { - // loadAuthStore chmods the config dir, chmods the secret, and re-parses the whole - // credential file on every call — and this runs on the initial resolution of EVERY - // request. The steady state of this feature is a pool where one account consistently - // ranks higher, so the REDIRECTING path must be cached too — validating the winner here - // would put a second uncached read in front of every such request. Deleting the store - // proves it: an uncached path could not answer at all. Staleness is caught at - // resolution instead, inside a store read the resolver already performs. + test("removing the credential store invalidates an earlier selection proposal", async () => { home = mkdtempSync(join(tmpdir(), "ocx-predispatch-")); process.env.OPENCODEX_HOME = home; clearGenericFailoverHealth(); @@ -381,11 +374,11 @@ describe("pre-dispatch account preference", () => { const ids = await seedAccounts(2); await setActiveAccount("xai", ids[0]!); // Redirecting: the other account holds more headroom on every call. - setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 95, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 100, updatedAt: Date.now() }); setCachedProviderAccountQuotaForTests("xai", ids[1]!, { monthlyPercent: 5, updatedAt: Date.now() }); expect(preferredInitialAccount(config, "xai")).toBe(ids[1]); rmSync(join(home, "auth.json"), { force: true }); - for (let i = 0; i < 4; i++) expect(preferredInitialAccount(config, "xai")).toBe(ids[1]); + for (let i = 0; i < 4; i++) expect(preferredInitialAccount(config, "xai")).toBeNull(); // Non-redirecting: the active account already ranks best. setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 5, updatedAt: Date.now() }); @@ -412,15 +405,14 @@ describe("pre-dispatch account preference", () => { try { const ids = await seedAccounts(2); await setActiveAccount("xai", ids[0]!); - setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 95, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 100, updatedAt: Date.now() }); setCachedProviderAccountQuotaForTests("xai", ids[1]!, { monthlyPercent: 5, updatedAt: Date.now() }); expect(preferredInitialAccount(config, "xai")).toBe(ids[1]); await removeAccount("xai", ids[1]!); - // Selection is a cached PREFERENCE, so it may still name the removed account... - expect(preferredInitialAccount(config, "xai")).toBe(ids[1]); - // ...and resolution is where that is caught. The request path absorbs this throw and - // falls back to the active account. + // Selection reads the authoritative roster; a removed target is never proposed. + expect(preferredInitialAccount(config, "xai")).toBeNull(); + // The credential resolver independently rejects the removed identity. await expect( getValidAccessSnapshotForAccount("xai", ids[1]!, { requireUsableAccount: true }), ).rejects.toThrow(); @@ -446,11 +438,12 @@ describe("pre-dispatch account preference", () => { try { const ids = await seedAccounts(2); await setActiveAccount("xai", ids[0]!); - setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 95, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", ids[0]!, { monthlyPercent: 100, updatedAt: Date.now() }); setCachedProviderAccountQuotaForTests("xai", ids[1]!, { monthlyPercent: 5, updatedAt: Date.now() }); expect(preferredInitialAccount(config, "xai")).toBe(ids[1]); await markAccountNeedsReauth("xai", ids[1]!, true); + expect(preferredInitialAccount(config, "xai")).toBeNull(); // An ordinary resolve SUCCEEDS — the credential is still readable — which is exactly // why the flag must be checked inside the resolver rather than trusted to throw. await expect(getValidAccessSnapshotForAccount("xai", ids[1]!)).resolves.toBeDefined(); diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index 80dfe5f8c3..a568a7068b 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearModelCache } from "../../src/codex/model-cache"; @@ -16,6 +17,7 @@ import { buildOpencodeProviderBlockFromCatalog, buildOpencodeProviderBlocksFromCatalog, buildOpencodeV2ProviderBlock, + cmdOpencode, fetchOpencodeProxyModels, isOpencodeRuntimeConfigError, mergeOpencodeRuntimeConfig, @@ -33,6 +35,7 @@ import { serializeOpencodeRuntimeConfig, } from "../../src/cli/opencode"; import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; function cfg(extra?: Partial): OcxConfig { return { @@ -236,6 +239,69 @@ describe("ocx opencode proxy model catalog", () => { const RESOLVED = "proxy-only-resolved-key"; const PROVIDER = "proxyenv"; + test("the first launcher reads selection persisted during /api/models before building both provider blocks", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-opencode-discovery-selection-")); + const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", OPENCODE_CONFIG_CONTENT_ENV]; + const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + const configPath = join(home, "config.json"); + const pending = cfg({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(opencodeCatalogFromProxyRows(rows, pending)).toEqual([]); + const liveness = await import("../../src/server/proxy-liveness"); + const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ + port: 10123, hostname: "127.0.0.1", pid: null, source: "config", + }); + const fetcher = spyOn(globalThis, "fetch").mockImplementation(async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending"); + writeFileSync(configPath, JSON.stringify(ready)); + return Response.json(rows); + }); + let inline = ""; + // Exercise cmdOpencode through env construction without launching an installed + // OpenCode or proxy process. All config reads still use the actual temp files. + const spawn = spyOn(childProcess, "spawn").mockImplementation((...args) => { + inline = args[2]?.env?.[OPENCODE_CONFIG_CONTENT_ENV] ?? ""; + const child = new childProcess.ChildProcess(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + process.env.XDG_CONFIG_HOME = join(home, "xdg"); + delete process.env[OPENCODE_CONFIG_CONTENT_ENV]; + mkdirSync(process.env.CODEX_HOME); + writeFileSync(configPath, JSON.stringify(pending)); + expect(await cmdOpencode([])).toBe(0); + expect(finder).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledTimes(1); + const injected = JSON.parse(inline); + expect(Object.keys(injected.provider.opencodex.models)).toEqual(["pending/chosen"]); + expect(Object.keys(injected.providers.opencodex.models)).toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + finder.mockRestore(); fetcher.mockRestore(); spawn.mockRestore(); stderr.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }); + test("uses /api/models namespaced selectors and resolves env-backed provider keys only in the proxy", async () => { const originalFetch = globalThis.fetch; let requestedAuth: string | undefined; diff --git a/tests/providers/opencode-go-agent-messages.test.ts b/tests/providers/opencode-go-agent-messages.test.ts new file mode 100644 index 0000000000..f79f529a5e --- /dev/null +++ b/tests/providers/opencode-go-agent-messages.test.ts @@ -0,0 +1,180 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; +import { parseRequest } from "../../src/responses/parser"; +import { routeModel } from "../../src/router"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; + +const base: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "synthetic-key" }; +const body = () => ({ model: "muse-spark-1.3-contributor", input: [{ type: "agent_message", id: "amsg_test", author: "/root/reader", recipient: "/root/checker", content: [{ type: "input_text", text: "Exact assignment\nwith lines." }] }], stream: true }); + +test("Responses converts plaintext task and peer messages without mutating replay or losing routing identities", async () => { + const raw = body(); const original = structuredClone(raw); const budget = createTranslatorBudget(); + const request = await createResponsesPassthroughAdapter(base).buildRequest(parseRequest(raw), { headers: new Headers(), translatorBudget: budget }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0].type).toBe("message"); + expect(sent.input[0].role).toBe("user"); + expect(sent.input[0].content[0].text).toContain('"author":"/root/reader"'); + expect(sent.input[0].content[0].text).toContain('"recipient":"/root/checker"'); + expect(sent.input[0].content[1]).toEqual(raw.input[0]!.content[0]); + expect(sent.input[0].id).toBeUndefined(); + expect(raw).toEqual(original); + budget.dispose(); +}); + +test("ciphertext and unknown content are never reclassified as plaintext", () => { + for (const part of [{ type: "encrypted_content", encrypted_content: "opaque" }, { type: "future_type", text: "opaque" }]) { + const raw = { input: [{ type: "agent_message", content: [part] }] }; + expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + } +}); + +test("image parts stay intact beside the assignment", () => { + const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; + const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] }; + const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw; + expect(result.input[0]!.content[1]).toBe(image); +}); + +test("native forward keeps agent_message and auth/session headers unchanged", async () => { + const budget = createTranslatorBudget(); + const provider = { ...base, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }; + const request = await createResponsesPassthroughAdapter(provider).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "native-id", authorization: "Bearer native-test" }), translatorBudget: budget }); + expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + expect(new Headers(request.headers).get("session-id")).toBe("native-id"); + expect(new Headers(request.headers).get("authorization")).toBe("Bearer native-test"); + budget.dispose(); +}); + +test("other destinations do not get Go normalization or session identity", async () => { + const budget = createTranslatorBudget(); + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget }); + expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + budget.dispose(); +}); + +test("canonical Go forward auth preserves private agent messages and the raw replay body", async () => { + const raw = body(); + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, authMode: "forward" }).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + expect(request.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(JSON.parse(request.body as string).input[0]).toMatchObject({ + type: "agent_message", author: "/root/reader", recipient: "/root/checker", + content: original.input[0]!.content, + }); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +test.each(["https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1/"])( + "a renamed provider at %s still converts plaintext agent messages", + async baseUrl => { + const raw = body(); + const original = structuredClone(raw); + const route = routeModel({ + port: 0, defaultProvider: "my-go", providers: { "my-go": { ...base, baseUrl, models: [raw.model] } }, + }, `my-go/${raw.model}`); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter(route.provider).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + const sent = JSON.parse(request.body as string); + expect(request.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } + }, +); + +test.each([ + "https://opencode.ai.evil.test/zen/go/v1", + "http://opencode.ai/zen/go/v1", + "https://opencode.ai/zen/v1", + "https://opencode.ai/zen/go/v10", +])("Go-like destination %s preserves private agent messages", async baseUrl => { + const raw = body(); + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl }).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input[0]).toMatchObject({ + type: "agent_message", content: original.input[0]!.content, + }); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +test.each(["not a URL", "https://", "/zen/go/v1"])( + "malformed destination %s is not classified as Go", + baseUrl => expect(isOpenCodeGo(baseUrl)).toBe(false), +); + +test("Go conversion preserves file payloads beside text without mutating raw replay", async () => { + const file = { type: "input_file", filename: "assignment.txt", file_data: "data:text/plain;base64,SGVsbG8=" }; + const message = body().input[0]!; + const raw = { ...body(), input: [{ ...message, content: [...message.content, file] }] }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter(base).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +for (const { name, content } of [ + { name: "empty content", content: [] }, + { name: "text mixed with an unknown part", content: [ + { type: "input_text", text: "Known prefix" }, { type: "future_type", text: "Do not lose this" }, + ] }, + { name: "text mixed with ciphertext", content: [ + { type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }, + ] }, +]) test(`Go preserves ${name} without partially converting it`, async () => { + const raw = { ...body(), input: [{ ...body().input[0]!, content }] }; + const original = structuredClone(raw); + expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter(base).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input[0]).toMatchObject({ type: "agent_message", content }); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 6dfcc2fbfe..989f55a410 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; @@ -9,17 +9,13 @@ import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, - flushProviderQuotaObservationsForTests, getCachedProviderAccountQuota, reconcileProviderAccountQuotaRows, resetProviderQuotaReconcileStateForTests, - setAntigravityAccountQuotaTransportForTests, - setProviderQuotaBeforePublishForTests, supportsPerAccountQuota, providerOAuthAccountQuotaMode, } from "../../src/providers/quota"; -import { setQuotaResetSink } from "../../src/quota/reset-observer"; -import { resetQuotaResetStoreForTests } from "../../src/quota/reset-seen-store"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; @@ -48,22 +44,16 @@ beforeEach(() => { process.env.OPENCODEX_HOME = opencodexHome; clearAccountQuotaCache(); clearProviderQuotaCache(); - resetProviderQuotaReconcileStateForTests(); - resetQuotaResetStoreForTests(); }); afterEach(() => { globalThis.fetch = originalFetch; - setAntigravityAccountQuotaTransportForTests(null); - setProviderQuotaBeforePublishForTests(null); - setQuotaResetSink(null); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; removeTreeWithRetry(opencodexHome); clearAccountQuotaCache(); clearProviderQuotaCache(); resetProviderQuotaReconcileStateForTests(); - resetQuotaResetStoreForTests(); }); describe("fetchProviderAccountQuotas", () => { @@ -270,49 +260,6 @@ describe("fetchProviderAccountQuotas", () => { expect(sibling?.quota?.fiveHourPercent).toBe(3); }); - test("provider quota observations retain the account that produced an in-flight report", async () => { - await seedTwoAccounts(); - const set = getAccountSet("anthropic"); - const first = set?.accounts.find(a => a.credential.email === "first@example.com"); - const second = set?.accounts.find(a => a.credential.email === "second@example.com"); - expect(first && second).toBeTruthy(); - await setActiveAccount("anthropic", first!.id); - - const events: unknown[] = []; - setQuotaResetSink(event => { events.push(event); }); - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - const auth = new Headers(init?.headers).get("authorization") ?? ""; - const body = auth.endsWith("token-first") - ? usageBody(96, 96) - : usageBody(1, 1); - return new Response(body, { status: 200 }); - }) as typeof fetch; - - const config: OcxConfig = { - port: 1455, - defaultProvider: "anthropic", - providers: { - anthropic: { - adapter: "anthropic", - authMode: "oauth", - baseUrl: "https://api.anthropic.com/v1", - }, - }, - }; - // Switch after account A's probe has completed but before its report is published. A later - // low-usage report from B is a new-account baseline, not a reset of A's high-usage window. - setProviderQuotaBeforePublishForTests(async () => { - await setActiveAccount("anthropic", second!.id); - setProviderQuotaBeforePublishForTests(null); - }); - await fetchProviderQuotaReports(config, true); - await flushProviderQuotaObservationsForTests(); - await fetchProviderQuotaReports(config, true); - await flushProviderQuotaObservationsForTests(); - - expect(events).toEqual([]); - }); - test("empty Anthropic usage payloads are treated as probe failures", async () => { await seedTwoAccounts(); globalThis.fetch = (async () => new Response("{}", { status: 200 })) as typeof fetch; @@ -476,100 +423,6 @@ describe("fetchProviderAccountQuotas", () => { expect(getCachedProviderAccountQuota("anthropic", first!.id)).toBeNull(); }); - - test("reports each Google Antigravity account's own rate limits", async () => { - const expires = Date.now() + 60 * 60_000; - await saveCredential("google-antigravity", { access: "token-g1", refresh: "refresh-g1", expires, accountId: "acct-g1", email: "g1@example.com", projectId: "proj-g1" }); - await saveCredential("google-antigravity", { access: "token-g2", refresh: "refresh-g2", expires, accountId: "acct-g2", email: "g2@example.com", projectId: "proj-g2" }); - const set = getAccountSet("google-antigravity")!; - const id1 = set.accounts.find(a => a.credential.accountId === "acct-g1")!.id; - const id2 = set.accounts.find(a => a.credential.accountId === "acct-g2")!.id; - - globalThis.fetch = (async () => { throw new Error("plain fetch must not be used for account bearers"); }) as typeof fetch; - const antigravityBody = (gemRemaining: number) => - JSON.stringify({ - models: { - "gemini-3.7-flash": { displayName: "Gemini 3.7 Flash", quotaInfo: { remainingFraction: gemRemaining, resetTime: "2026-07-05T12:00:00Z" } }, - }, - }); - setAntigravityAccountQuotaTransportForTests({ - resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), - pinnedPost: async (_url, _pinned, body, _signal, requestOptions) => { - const auth = new Headers(requestOptions?.headers).get("authorization") ?? ""; - const gemRemaining = auth.includes("token-g1") ? 0.8 : 0.4; - return new Response(antigravityBody(gemRemaining), { status: 200, headers: { "content-type": "application/json" } }); - }, - }); - - expect(supportsPerAccountQuota("google-antigravity")).toBe(true); - const rows = await fetchProviderAccountQuotas("google-antigravity"); - expect(rows).toHaveLength(2); - expect(rows.find(r => r.accountId === id1)?.quota?.customWindows?.[0]?.percent).toBe(20); - expect(rows.find(r => r.accountId === id2)?.quota?.customWindows?.[0]?.percent).toBe(60); - }); - - test("reports each Command Code account's own rate limits", async () => { - const expires = Date.now() + 60 * 60_000; - await saveCredential("command-code", { access: "token-cc1", refresh: "refresh-cc1", expires, accountId: "acct-cc1", email: "cc1@example.com" }); - await saveCredential("command-code", { access: "token-cc2", refresh: "refresh-cc2", expires, accountId: "acct-cc2", email: "cc2@example.com" }); - const set = getAccountSet("command-code")!; - const id1 = set.accounts.find(a => a.credential.accountId === "acct-cc1")!.id; - const id2 = set.accounts.find(a => a.credential.accountId === "acct-cc2")!.id; - - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const auth = new Headers(init?.headers).get("authorization"); - const url = String(input); - if (url.includes("/alpha/whoami")) { - return Response.json({ org: { id: "test-org" } }); - } - if (url.includes("/alpha/billing/credits")) { - const used = auth?.includes("token-cc1") ? 45 : 85; - return Response.json({ - windowLimits: { - fiveHour: { cap: 100, used, resetAt: 1750000000 }, - weekly: { cap: 100, used: 20, resetAt: 1750000000 }, - }, - }); - } - return Response.json({}, { status: 404 }); - }) as typeof fetch; - - expect(supportsPerAccountQuota("command-code")).toBe(true); - const rows = await fetchProviderAccountQuotas("command-code"); - expect(rows).toHaveLength(2); - expect(rows.find(r => r.accountId === id1)?.quota?.fiveHourPercent).toBe(45); - expect(rows.find(r => r.accountId === id2)?.quota?.fiveHourPercent).toBe(85); - }); - - test("reports each Cursor account's own rate limits", async () => { - const expires = Date.now() + 60 * 60_000; - await saveCredential("cursor", { access: "token-cur1", refresh: "refresh-cur1", expires, accountId: "acct-cur1", email: "cur1@example.com" }); - await saveCredential("cursor", { access: "token-cur2", refresh: "refresh-cur2", expires, accountId: "acct-cur2", email: "cur2@example.com" }); - const set = getAccountSet("cursor")!; - const id1 = set.accounts.find(a => a.credential.accountId === "acct-cur1")!.id; - const id2 = set.accounts.find(a => a.credential.accountId === "acct-cur2")!.id; - - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const auth = new Headers(init?.headers).get("authorization"); - const url = String(input); - if (url.includes("GetCurrentPeriodUsage")) { - const totalPercent = auth?.includes("token-cur1") ? 35 : 75; - return Response.json({ - planUsage: { - totalPercentUsed: totalPercent, - billingCycleEnd: "2026-07-05T12:00:00Z", - }, - }); - } - return Response.json({}, { status: 404 }); - }) as typeof fetch; - - expect(supportsPerAccountQuota("cursor")).toBe(true); - const rows = await fetchProviderAccountQuotas("cursor"); - expect(rows).toHaveLength(2); - expect(rows.find(r => r.accountId === id1)?.quota?.monthlyPercent).toBe(35); - expect(rows.find(r => r.accountId === id2)?.quota?.monthlyPercent).toBe(75); - }); }); describe("explicit OAuth account quota readers", () => { @@ -691,10 +544,6 @@ describe("explicit OAuth account quota readers", () => { expect(Object.keys(rows[0]!)).toEqual(["accountId", "quota"]); expect(JSON.stringify(rows)).not.toContain("quota-first"); expect(JSON.stringify(rows)).not.toContain("identity"); - // A scoped clear for another provider must not invalidate this provider's quota - // identity or temporarily remove its routing headroom evidence. - clearAccountQuotaCache("anthropic"); - expect(rows.every(row => row.isCurrent?.())).toBe(true); clearAccountQuotaCache(fixture.provider); expect(rows.every(row => row.isCurrent?.() === false)).toBe(true); }); @@ -833,7 +682,21 @@ describe("google-antigravity per-account quota (#1082)", () => { }); } - afterEach(() => setAntigravityAccountQuotaTransportForTests(null)); + const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); + const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + const summaryUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + const modelsUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + + beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; + }); + afterEach(() => { + setAntigravityAccountQuotaTransportForTests(null); + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } + }); test("probes each account with its own bearer and project id on the fixed Google host using retrieveUserQuotaSummary", async () => { const expires = Date.now() + 60 * 60_000; @@ -900,6 +763,95 @@ describe("google-antigravity per-account quota (#1082)", () => { expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); }); + for (const fallback of [false, true]) { + test(`Fake-IP ${fallback ? "models fallback" : "summary"} keeps each account bearer and project separate`, async () => { + const expires = Date.now() + 3600_000; + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + await saveCredential("google-antigravity", { access: "agy-second", refresh: "r2", expires, projectId: "proj-second", accountId: "agy-b", email: "b@example.com" }); + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + const resolved: Array<{ url: string; benchmark?: boolean; private?: boolean; mihomo?: boolean }> = []; + const posted: Array<{ url: string; auth: string | null; project: string; address: string; tls?: boolean; signal: boolean }> = []; + setAntigravityAccountQuotaTransportForTests(null); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + const policy = typeof options === "object" ? options : undefined; + resolved.push({ url, benchmark: policy?.allowBenchmarkAddresses, private: policy?.allowPrivateNetwork, mihomo: policy?.allowMihomoIpv6FakeIp }); + if (!policy?.allowBenchmarkAddresses) throw new Error("benchmark address rejected"); + return { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "198.18.56.214", family: 4 }], privateNetwork: false }; + }, + pinnedPost: async (url, pinned, body, signal, options) => { + const auth = new Headers(options?.headers).get("authorization"); + posted.push({ url, auth, project: String(JSON.parse(body).project), address: pinned.address, tls: options?.rejectUnauthorized, signal: signal instanceof AbortSignal }); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + const [gem, cla]: [number, number] = auth === "Bearer agy-first" ? [0.86, 0.38] : [0.97, 0.91]; + return new Response(url === summaryUrl ? antigravitySummaryBody(gem, cla) : antigravityBody(gem, cla)); + }, + }); + const rows = await fetchProviderAccountQuotas("google-antigravity"); + const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + expect(resolved).toHaveLength(urls.length * 2); + expect(posted).toHaveLength(urls.length * 2); + for (const url of urls) { + expect(resolved.filter(row => row.url === url)).toEqual([ + { url, benchmark: true, private: false, mihomo: false }, + { url, benchmark: true, private: false, mihomo: false }, + ]); + } + for (const [auth, project] of [["Bearer agy-first", "proj-first"], ["Bearer agy-second", "proj-second"]]) { + expect(posted.filter(row => row.auth === auth)).toEqual(urls.map(url => ({ url, auth, project, address: "198.18.56.214", tls: true, signal: true }))); + } + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(byId[idFor("a@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [14, 62] : [14, 14, 62, 62]); + expect(byId[idFor("b@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [3, 9] : [3, 3, 9, 9]); + expect(plainFetchCalls).toBe(0); + }); + } + + test("NO_PROXY denial preserves an unavailable account row without sending its bearer", async () => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + process.env.no_proxy = "daily-cloudcode-pa.googleapis.com"; + const admitted: Array = []; + let posted = 0; + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (_url, options) => { + const allow = typeof options === "object" ? options?.allowBenchmarkAddresses : undefined; + admitted.push(allow); + if (!allow) throw new Error("benchmark address rejected"); + return { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "198.18.56.214", family: 4 }], privateNetwork: false }; + }, + pinnedPost: async () => { posted += 1; return new Response(antigravitySummaryBody(0.5, 0.5)); }, + }); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(admitted).toEqual([false, false]); + expect(posted).toBe(0); + expect(plainFetchCalls).toBe(0); + }); + + for (const status of [302, 307, 308, 401, 403]) { + for (const fallback of [false, true]) { + test(`account ${fallback ? "models" : "summary"} ${status} returns unavailable without following Location`, async () => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + const posted: string[] = []; + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => { + posted.push(url); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + return new Response(null, { status, headers: { location: "https://daily-cloudcode-pa.googleapis.com/redirect-target" } }); + }, + }); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : [summaryUrl]); + expect(plainFetchCalls).toBe(0); + }); + } + } + test("a rejected destination never receives a bearer; the row is unavailable, not 0%", async () => { const expires = Date.now() + 60 * 60_000; await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); diff --git a/tests/providers/provider-id-rewrite.test.ts b/tests/providers/provider-id-rewrite.test.ts index 0e38edebe1..625456b969 100644 --- a/tests/providers/provider-id-rewrite.test.ts +++ b/tests/providers/provider-id-rewrite.test.ts @@ -211,3 +211,24 @@ test("removal leaves the custom-model ownership marker untouched", () => { legacyOwnedSlugs: ["agnes-ai/agnes-2.5-flash", "huggingface/DeepSeek-V4-Flash-0731"], }); }); + + test("moves remembered provider caps without activating them", () => { + const config = { providerContextCapValues: { [FROM]: 128_000 } } as unknown as OcxConfig; + expect(rewriteProviderReferences(config, FROM, TO)).toEqual({ changed: 1, collisions: [] }); + expect(config.providerContextCapValues).toEqual({ [TO]: 128_000 }); + expect(providerContextCap(config, TO)).toBeUndefined(); +}); + +test("a remembered cap rename collision preserves both disabled selections", () => { + const config = { + providerContextCapValues: { [FROM]: 128_000, [TO]: 256_000 }, + } as unknown as OcxConfig; + const before = structuredClone(config); + expect(rewriteProviderReferences(config, FROM, TO)).toEqual({ + changed: 0, + collisions: [`providerContextCapValues.${TO}`], + }); + expect(config).toEqual(before); + expect(providerContextCap(config, FROM)).toBeUndefined(); + expect(providerContextCap(config, TO)).toBeUndefined(); +}); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 436b42eda0..9c12869b04 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -13,6 +13,7 @@ import { saveCredential } from "../../src/oauth/store"; import { clearProviderQuotaCache, fetchProviderQuotaReports, + isCanonicalAntigravityQuotaUrl, parseOllamaCloudQuota, parseXaiCreditsResponse, QUOTA_RESPONSE_MAX_BYTES, @@ -21,8 +22,10 @@ import { setProviderQuotaBeforePublishForTests, } from "../../src/providers/quota"; import type { OcxConfig } from "../../src/types"; -import { resetProviderTlsProfileForTests, setProviderTlsRuntimeForTest } from "../../src/lib/provider-tls-profile"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { repoPath } from "../helpers/repo-root"; +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); const originalFetch = globalThis.fetch; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; @@ -76,6 +79,7 @@ function testConfig(): OcxConfig { } beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; opencodexHome = mkdtempSync(join(tmpdir(), "ocx-quota-")); codexHome = mkdtempSync(join(tmpdir(), "codex-quota-")); process.env.OPENCODEX_HOME = opencodexHome; @@ -88,15 +92,14 @@ beforeEach(() => { clearCodexUpstreamHealth(); clearProviderQuotaCache(); setProviderQuotaBeforePublishForTests(null); - setAntigravityAccountQuotaTransportForTests({ - resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), - pinnedPost: async () => new Response("not found", { status: 404 }), - }); }); afterEach(() => { + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } globalThis.fetch = originalFetch; - resetProviderTlsProfileForTests(); clearAccountQuota(); clearProviderQuotaCache(); setProviderQuotaBeforePublishForTests(null); @@ -110,57 +113,6 @@ afterEach(() => { }); describe("fetchProviderQuotaReports", () => { - test("Antigravity quota probes use the opt-in profiled executor", async () => { - await saveCredential("google-antigravity", { - access: "agy-access-secret", - refresh: "agy-refresh-secret", - expires: Date.now() + 3600_000, - projectId: "agy-project-secret", - }); - let nativeCalls = 0; - let bunCalls = 0; - let project: string | undefined; - globalThis.fetch = (async () => { - bunCalls += 1; - return new Response(null, { status: 500 }); - }) as typeof fetch; - setProviderTlsRuntimeForTest({ - importWreq: async () => ({ - createTransport: async () => ({ close: async () => undefined }), - fetch: async (_input, init) => { - nativeCalls += 1; - project = (JSON.parse(String(init?.body)) as { project?: string }).project; - return new Response(JSON.stringify({ - models: { - "gemini-3.6-flash-medium": { - displayName: "Gemini 3.6 Flash", - quotaInfo: { remainingFraction: 0.64 }, - }, - }, - }), { status: 200, headers: { "content-type": "application/json" } }); - }, - }), - }); - const config: OcxConfig = { - defaultProvider: "google-antigravity", - providers: { - "google-antigravity": { - adapter: "google", - authMode: "oauth", - googleMode: "cloud-code-assist", - baseUrl: "https://daily-cloudcode-pa.googleapis.com", - project: "configured-stale-project", - tlsProfile: "antigravity-browser", - }, - }, - } as OcxConfig; - const result = await fetchProviderQuotaReports(config, true); - expect(result.reports[0]?.provider).toBe("google-antigravity"); - expect(project).toBe("agy-project-secret"); - expect(nativeCalls).toBeGreaterThanOrEqual(1); - expect(bunCalls).toBe(0); - }); - test("provider quota probes have no direct Response.json calls", () => { const source = readFileSync(repoPath("src/providers/quota.ts"), "utf8"); expect(source).not.toMatch(/\.\s*json\s*\(/); @@ -260,24 +212,43 @@ describe("fetchProviderQuotaReports", () => { await saveCredential("google-antigravity", { access: "agy-access-secret", refresh: "agy-refresh-secret", expires: Date.now() + 3600_000, projectId: "agy-project-secret" }); await saveCredential("kimi", { access: "kimi-access-secret", refresh: "kimi-refresh-secret", expires: Date.now() + 3600_000 }); - // The Antigravity summary probe is pinned to Google's host through the provider-outbound - // transport and never touches globalThis.fetch; without this seam the test would make a - // real network request. A 404 here exercises the fetchAvailableModels fallback below. + const seen: { url: string; authorization?: string; body?: string }[] = []; + // Both Antigravity accounting requests use the pinned transport. Keep the + // summary unavailable so this fixture still exercises the models fallback. setAntigravityAccountQuotaTransportForTests({ resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), - pinnedPost: async () => new Response("not found", { status: 404 }), + pinnedPost: async (url, _pinned, body, _signal, options) => { + seen.push({ url, authorization: new Headers(options?.headers).get("authorization") ?? undefined, body }); + if (url === "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels") { + return new Response(JSON.stringify({ + models: { + "gemini-3.6-flash-medium": { + displayName: "Gemini 3.6 Flash (Medium)", + quotaInfo: { remainingFraction: 0.64, resetTime: "2026-07-05T14:00:00Z" }, + }, + "claude-sonnet-4.6": { + displayName: "Claude Sonnet", + quotaInfoByTier: { + sonnet: { remainingFraction: 0.21, resetTime: "2026-07-05T15:00:00Z" }, + }, + }, + autocomplete: { + displayName: "Autocomplete", + quotaInfo: { remainingFraction: 0.01, resetTime: "2026-07-05T16:00:00Z" }, + }, + }, + rawProject: "agy-project-secret", + rawToken: "agy-access-secret", + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }, }); - const seen: { url: string; authorization?: string; body?: string; redirect?: RequestRedirect }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const headers = init?.headers as Record | undefined; - seen.push({ - url, - authorization: headers?.Authorization, - body: typeof init?.body === "string" ? init.body : undefined, - redirect: init?.redirect, - }); + seen.push({ url, authorization: headers?.Authorization, body: typeof init?.body === "string" ? init.body : undefined }); if (url === "https://chatgpt.com/backend-api/wham/usage") { return new Response(JSON.stringify({ email: "person@example.com", @@ -320,28 +291,6 @@ describe("fetchProviderQuotaReports", () => { billingCycleEnd: "2026-08-01T00:00:00.000Z", }), { status: 200, headers: { "content-type": "application/json" } }); } - if (url === "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels") { - return new Response(JSON.stringify({ - models: { - "gemini-3.6-flash-medium": { - displayName: "Gemini 3.6 Flash (Medium)", - quotaInfo: { remainingFraction: 0.64, resetTime: "2026-07-05T14:00:00Z" }, - }, - "claude-sonnet-4.6": { - displayName: "Claude Sonnet", - quotaInfoByTier: { - sonnet: { remainingFraction: 0.21, resetTime: "2026-07-05T15:00:00Z" }, - }, - }, - autocomplete: { - displayName: "Autocomplete", - quotaInfo: { remainingFraction: 0.01, resetTime: "2026-07-05T16:00:00Z" }, - }, - }, - rawProject: "agy-project-secret", - rawToken: "agy-access-secret", - }), { status: 200, headers: { "content-type": "application/json" } }); - } if (url === "https://api.kimi.com/coding/v1/usages") { return new Response(JSON.stringify({ user: { userId: "kimi-user-secret", businessId: "kimi-business-secret" }, @@ -407,48 +356,9 @@ describe("fetchProviderQuotaReports", () => { expect(seen.find(row => row.url.includes("anthropic.com"))?.authorization).toBe("Bearer claude-access-secret"); expect(seen.find(row => row.url.includes("cloudcode-pa.googleapis.com"))?.authorization).toBe("Bearer agy-access-secret"); expect(seen.find(row => row.url.includes("cloudcode-pa.googleapis.com"))?.body).toBe(JSON.stringify({ project: "agy-project-secret" })); - expect(seen.find(row => row.url.endsWith("/v1internal:fetchAvailableModels"))?.redirect).toBe("error"); expect(seen.find(row => row.url === "https://api.kimi.com/coding/v1/usages")?.authorization).toBe("Bearer kimi-access-secret"); }); - test("treats remainingPercentage as a percentage at values one and below", async () => { - await saveCredential("google-antigravity", { - access: "agy-access-secret", - refresh: "agy-refresh-secret", - expires: Date.now() + 3600_000, - projectId: "agy-project-secret", - }); - const config = { - defaultProvider: "google-antigravity", - providers: { - "google-antigravity": { - adapter: "google", - authMode: "oauth", - baseUrl: "https://daily-cloudcode-pa.googleapis.com", - }, - }, - } as OcxConfig; - - for (const [remainingPercentage, expectedUsedPercentage] of [[1, 99], [0.75, 99.25], [75, 25]]) { - clearProviderQuotaCache(); - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = String(input); - if (url.includes(":retrieveUserQuota")) return new Response("not found", { status: 404 }); - if (url.endsWith("/v1internal:fetchAvailableModels")) { - return new Response(JSON.stringify({ - models: { - "gemini-test": { quotaInfo: { remainingPercentage } }, - }, - }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }) as typeof fetch; - - const result = await fetchProviderQuotaReports(config, true); - expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(expectedUsedPercentage); - } - }); - function kimiOnlyConfig(baseUrl = "https://api.kimi.com/coding/v1"): OcxConfig { return { defaultProvider: "kimi", @@ -3179,6 +3089,205 @@ describe("fetchProviderQuotaReports", () => { expect(posted).toEqual(["https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"]); }); + describe("Google Antigravity canonical quota transport (#3781)", () => { + const summaryUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + const modelsUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + const summaryBody = JSON.stringify({ groups: [{ displayName: "Gemini", buckets: [{ window: "5h", remainingFraction: 0.6 }] }] }); + const modelsBody = JSON.stringify({ models: { gemini: { quotaInfo: { remainingFraction: 0.75 } } } }); + const publicAddress = { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }; + let plainFetchCalls: string[]; + + function config(baseUrl = "https://daily-cloudcode-pa.googleapis.com"): OcxConfig { + return { + defaultProvider: "google-antigravity", + providers: { "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl, allowPrivateNetwork: true } }, + } as OcxConfig; + } + + beforeEach(async () => { + await saveCredential("google-antigravity", { + access: "agy-canonical-access", refresh: "agy-canonical-refresh", expires: Date.now() + 3600_000, projectId: "agy-canonical-project", + }); + plainFetchCalls = []; + globalThis.fetch = (async (input) => { + plainFetchCalls.push(String(input)); + throw new Error("unexpected quota-owned raw fetch"); + }) as typeof fetch; + }); + + test("canonical proof accepts only the two exact Google accounting URLs", () => { + for (const url of [summaryUrl, modelsUrl]) { + expect(isCanonicalAntigravityQuotaUrl("google-antigravity", url)).toBe(true); + expect(isCanonicalAntigravityQuotaUrl("custom", url)).toBe(false); + for (const candidate of [ + "", "not a URL", url.replace("https:", "http:"), + url.replace(".googleapis.com", ".googleapis.com.evil.example"), + url.replace("daily-cloudcode-pa", "cloudcode-pa"), + url.replace("https://", "https://user:pass@"), + url.replace(".com/", ".com:443/"), url.replace(".com/", ".com:8443/"), + url.replace("https://", "HTTPS://"), `${url}/`, `${url}/extra`, + `${url}?token=secret`, `${url}#fragment`, ` ${url}`, + url.replace("v1internal:", "v1internal%3A"), + url.replace("v1internal:", "prefix/v1internal:"), + "https://daily-cloudcode-pa.googleapis.com/v1internal:other", + "https://198.18.0.1/v1internal:fetchAvailableModels", + "https://127.0.0.1/v1internal:fetchAvailableModels", + "https://169.254.169.254/v1internal:fetchAvailableModels", + ]) expect(isCanonicalAntigravityQuotaUrl("google-antigravity", candidate)).toBe(false); + } + }); + + for (const fallback of [false, true]) { + test(`production proof survives reset for Fake-IP ${fallback ? "fallback" : "summary"}`, async () => { + const resolved: Array<{ url: string; benchmark?: boolean; private?: boolean; mihomo?: boolean }> = []; + const posted: Array<{ url: string; address: string; tls?: boolean; auth: string | null; body: string; signal: boolean }> = []; + setAntigravityAccountQuotaTransportForTests({ isCanonicalUrl: () => false }); + setAntigravityAccountQuotaTransportForTests(null); + // Resolver/pinned-only overrides must retain the production canonical proof. + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + const policy = typeof options === "object" ? options : undefined; + resolved.push({ url, benchmark: policy?.allowBenchmarkAddresses, private: policy?.allowPrivateNetwork, mihomo: policy?.allowMihomoIpv6FakeIp }); + if (!policy?.allowBenchmarkAddresses) throw new Error("benchmark address rejected"); + return { ...publicAddress, addresses: [{ address: "198.18.56.214", family: 4 }] }; + }, + pinnedPost: async (url, pinned, body, signal, options) => { + posted.push({ url, address: pinned.address, tls: options?.rejectUnauthorized, auth: new Headers(options?.headers).get("authorization"), body, signal: signal instanceof AbortSignal }); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + return new Response(url === summaryUrl ? summaryBody : modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(), true); + const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + expect(resolved).toEqual(urls.map(url => ({ url, benchmark: true, private: false, mihomo: false }))); + expect(posted).toEqual(urls.map(url => ({ url, address: "198.18.56.214", tls: true, auth: "Bearer agy-canonical-access", body: JSON.stringify({ project: "agy-canonical-project" }), signal: true }))); + expect(result.reports[0]?.source).toBe(fallback ? "google-antigravity:fetchAvailableModels" : "google-antigravity:retrieveUserQuotaSummary"); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: fallback ? 25 : 40 }]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const baseUrl of ["https://custom.example/v1", "http://127.0.0.1:1/", "https://169.254.169.254/", "https://daily-cloudcode-pa.googleapis.com.evil.example/"]) { + test(`models fallback ignores configured destination ${baseUrl}`, async () => { + const resolved: Array<{ url: string; private?: boolean }> = []; + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + resolved.push({ url, private: typeof options === "object" ? options?.allowPrivateNetwork : undefined }); + return publicAddress; + }, + pinnedPost: async (url) => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response(modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(baseUrl), true); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(resolved).toEqual([{ url: summaryUrl, private: false }, { url: modelsUrl, private: false }]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const noProxy of ["daily-cloudcode-pa.googleapis.com", "*"]) { + test(`NO_PROXY ${noProxy} keeps benchmark DNS blocked`, async () => { + process.env.NO_PROXY = noProxy; + const admitted: Array = []; + let posted = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (_url, options) => { + const allow = typeof options === "object" ? options?.allowBenchmarkAddresses : undefined; + admitted.push(allow); + if (!allow) throw new Error("benchmark address rejected"); + return publicAddress; + }, + pinnedPost: async () => { posted += 1; return new Response(summaryBody); }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(admitted).toEqual([false, false]); + expect(posted).toBe(0); + expect(plainFetchCalls).toEqual([]); + }); + } + + test("resolved-address policy rejection cannot escape to raw fallback fetch", async () => { + // The real classifier's mixed-address cases live in destination-policy-resolved.test.ts; + // this checks that quota cannot bypass its rejection through a second transport. + const resolved: string[] = []; + let posted = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async url => { resolved.push(url); throw new Error("provider URL resolves to metadata"); }, + pinnedPost: async () => { posted += 1; return new Response(modelsBody); }, + }); + expect((await fetchProviderQuotaReports(config("https://custom.example"), true)).reports).toEqual([]); + expect(resolved).toEqual([summaryUrl, modelsUrl]); + expect(posted).toBe(0); + expect(plainFetchCalls).toEqual([]); + }); + + for (const summary of ["{}", "invalid JSON"]) { + test(`unusable summary ${summary} falls back through the fixed models transport`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return new Response(url === summaryUrl ? summary : modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(), true); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [200, 500]) { + test(`unusable models payload with HTTP ${status} produces no fabricated quota`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response("invalid JSON", { status }); + }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [302, 307, 308, 401, 403]) { + test(`summary ${status} terminates without a models request`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { posted.push(url); return new Response(null, { status, headers: { location: modelsUrl } }); }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [302, 307, 308]) { + test(`models ${status} does not follow even a same-host redirect`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response(null, { status, headers: { location: summaryUrl } }); + }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + }); + test("Ollama Cloud maps 5-hour session and weekly windows from /api/usage (legacy plan)", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index e78f76f4d3..353c4e65e1 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -25,13 +25,20 @@ function sliceFn(source: string, start: string, end: string): string { describe("Grok fence lifecycle wiring", () => { test("handleStart syncs the Grok fence outside the Desktop-3P try", () => { const startFn = sliceFn(CLI_SOURCE, "async function handleStart(", "async function handleEnsure("); + const startupAt = startFn.indexOf("await reconcileClientStartupBeforeReady("); const registryAt = startFn.indexOf("buildDesktop3pRegistry("); - const registryCatchAt = startFn.indexOf("/* best-effort — registry rebuilds on first /v1/models call */", registryAt); + const afterStartupAt = startFn.indexOf("if (!startupSync.ran)", registryAt); const grokSyncAt = startFn.indexOf('await import("../grok/sync")'); - expect(registryCatchAt).toBeGreaterThan(registryAt); - // Nested inside the registry try, a catalog throw skipped the fence entirely. - expect(grokSyncAt).toBeGreaterThan(registryCatchAt); + expect(startupAt).toBeGreaterThan(-1); + expect(registryAt).toBeGreaterThan(startupAt); + expect(afterStartupAt).toBeGreaterThan(registryAt); + const initialization = startFn.slice(startupAt, afterStartupAt); + expect(initialization).toMatch(/\}\s*catch\s*(?:\([^)]*\)\s*)?\{/); + expect(initialization).not.toContain('import("../grok/sync")'); + // Grok follows the completed initialization call, outside its callback/try. + // A comment wording change must not masquerade as a lifecycle regression. + expect(grokSyncAt).toBeGreaterThan(afterStartupAt); }); test("ensure passes only the observed live bind host across the mutation boundary", () => { diff --git a/tests/responses/chat-json-sse-fallback.test.ts b/tests/responses/chat-json-sse-fallback.test.ts new file mode 100644 index 0000000000..684917a05b --- /dev/null +++ b/tests/responses/chat-json-sse-fallback.test.ts @@ -0,0 +1,254 @@ +import { afterEach, expect, test } from "bun:test"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { createTranslatorBudget, isTranslatorBudgetExceededError, translatorObservedBufferSnapshot } from "../../src/lib/translator-budget"; +import type { OcxConfig } from "../../src/types"; +import { responsesJsonToChatCompletion, isChatCompletionsStreamError } from "../../src/chat/outbound"; +import { jsonCompletionSse } from "../../src/server/chat-native-sse"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import { readUsageEntries } from "../../src/usage/log"; + +let upstream: ReturnType | undefined; +afterEach(async () => { await upstream?.stop(true); upstream = undefined; }); + +interface Chunk { + choices: Array<{ index: number; delta: { + role?: string; content?: string; reasoning_content?: string; + tool_calls?: Array<{ index: number; id: string; type: string; function: { name: string; arguments: string } }>; + }; finish_reason: string | null }>; + usage?: { prompt_tokens: number; completion_tokens: number }; +} + +async function streamFixture(output: unknown[], status = "completed", cancel = false, reason = "max_output_tokens", delivery: { jsonFinish?: string; error?: boolean; errorCode?: string } = {}): Promise { + const budgetBefore = translatorObservedBufferSnapshot().currentBytes; + const requestId = `chat-json-fixture-${crypto.randomUUID()}`; + let requests = 0; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + expect(new URL(req.url).pathname).toBe("/v1/responses"); + expect((await req.json() as { stream: boolean }).stream).toBe(true); + requests++; + return Response.json({ id: "resp_fixture", status, output, + ...(status === "incomplete" ? { incomplete_details: { reason } } : {}), + usage: { input_tokens: 11, output_tokens: 7 } }); + } }); + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: { + adapter: "openai-responses", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + authMode: "key", apiKey: "fixture-key", allowPrivateNetwork: true, models: ["model"], + } } }; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", stream: !delivery.jsonFinish, messages: [{ role: "user", content: "fixture" }], + tools: [{ type: "function", function: { name: "lookup", parameters: { type: "object" } } }] }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + const assertSingleFinal = () => { + const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe(delivery.error ? 502 : 200); + const persisted = readUsageEntries().filter(entry => entry.requestId === requestId); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.status).toBe(delivery.error ? 502 : 200); + }; + if (delivery.error) { + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ error: { type: "upstream_error", code: delivery.errorCode ?? "upstream_incomplete" } }); + assertSingleFinal(); + expect(requests).toBe(1); + expect(translatorObservedBufferSnapshot().currentBytes).toBe(budgetBefore); + return []; + } + expect(response.status).toBe(200); + if (delivery.jsonFinish) { + expect(await response.json()).toMatchObject({ choices: [{ finish_reason: delivery.jsonFinish }] }); + assertSingleFinal(); + expect(requests).toBe(1); + expect(translatorObservedBufferSnapshot().currentBytes).toBe(budgetBefore); + return []; + } + expect(response.headers.get("content-type")).toContain("text/event-stream"); + if (cancel) { + await response.body!.cancel("fixture cancellation"); + assertSingleFinal(); + expect(requests).toBe(1); + expect(translatorObservedBufferSnapshot().currentBytes).toBe(budgetBefore); + return []; + } + const text = await response.text(); + assertSingleFinal(); + expect(translatorObservedBufferSnapshot().currentBytes).toBe(budgetBefore); + expect(requests).toBe(1); + const payloads = text.split(/\r?\n/).filter(line => line.startsWith("data: ")).map(line => line.slice(6)); + expect(payloads.filter(value => value === "[DONE]")).toHaveLength(1); + expect(payloads.at(-1)).toBe("[DONE]"); + const chunks = payloads.filter(value => value !== "[DONE]").map(value => JSON.parse(value) as Chunk); + expect(chunks.flatMap(chunk => chunk.choices).filter(choice => choice.finish_reason !== null)).toHaveLength(1); + expect(chunks.at(-1)?.usage).toMatchObject({ prompt_tokens: 11, completion_tokens: 7 }); + return chunks; +} + +test.each([1, 2])("JSON-to-SSE keeps %s indexed tool calls and tool_calls finish", async count => { + const calls = Array.from({ length: count }, (_, index) => ({ type: "function_call", + call_id: `call_fixture_${index}`, name: "lookup", arguments: JSON.stringify({ index }) })); + const chunks = await streamFixture(calls); + expect(chunks.flatMap(chunk => chunk.choices.flatMap(choice => choice.delta.tool_calls ?? []))) + .toEqual(calls.map((call, index) => ({ index, id: call.call_id, type: "function", + function: { name: call.name, arguments: call.arguments } }))); + expect(chunks.at(-1)?.choices[0]?.finish_reason).toBe("tool_calls"); +}); + +test("JSON-to-SSE keeps reasoning alongside answer text", async () => { + const chunks = await streamFixture([ + { type: "reasoning", summary: [{ type: "summary_text", text: "Fixture reasoning." }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "Answer." }] }, + ]); + expect(chunks.flatMap(chunk => chunk.choices).map(choice => choice.delta.reasoning_content ?? "").join("")) + .toBe("Fixture reasoning."); + expect(chunks.flatMap(chunk => chunk.choices).map(choice => choice.delta.content ?? "").join("")) + .toBe("Answer."); + expect(chunks.at(-1)?.choices[0]?.finish_reason).toBe("stop"); +}); + +test("JSON-to-SSE preserves length instead of claiming a normal stop", async () => { + const chunks = await streamFixture([ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "Partial answer." }] }, + ], "incomplete"); + expect(chunks.at(-1)?.choices[0]?.finish_reason).toBe("length"); +}); + +test("JSON-to-SSE preserves ordinary text and a single empty completion terminal", async () => { + const chunks = await streamFixture([ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "Ordinary text." }] }, + ]); + expect(chunks.flatMap(chunk => chunk.choices).map(choice => choice.delta.content ?? "").join("")) + .toBe("Ordinary text."); + expect(chunks.at(-1)?.choices[0]?.finish_reason).toBe("stop"); +}); + +test("JSON-to-SSE empty completion still terminates once", async () => { + const chunks = await streamFixture([]); + expect(chunks).toHaveLength(2); + expect(chunks.at(-1)?.choices[0]?.finish_reason).toBe("stop"); +}); + +test("JSON-to-SSE cancellation releases the existing translation budget", async () => { + await streamFixture([{ type: "function_call", call_id: "call_cancel", name: "lookup", arguments: "{}" }], "completed", true); +}); + +// Expected finish values come from the official Chat contract, not the converter. +test.each([ + ["max_output_tokens", "length"], + ["content_filter", "content_filter"], +])("JSON-to-SSE incomplete %s takes precedence over a partial tool call", async (reason, finish) => { + const chunks = await streamFixture([ + { type: "function_call", call_id: "call_partial", name: "lookup", arguments: '{"unfinished":' }, + ], "incomplete", false, reason); + expect(chunks.at(-1)?.choices[0]?.finish_reason).toBe(finish); +}); + +test.each(["max_output_tokens", "content_filter"])("JSON projection preserves incomplete %s with tools", reason => { + const completion = responsesJsonToChatCompletion({ status: "incomplete", incomplete_details: { reason }, + output: [{ type: "function_call", call_id: "call_partial", name: "lookup", arguments: "{}" }], + }, "fixture/model"); + expect(completion.choices).toMatchObject([{ finish_reason: reason === "max_output_tokens" ? "length" : "content_filter" }]); +}); + +test.each([undefined, "max_messages", "steered", "adapter_eof"])("JSON projection does not invent length for %s", reason => { + try { + responsesJsonToChatCompletion({ status: "incomplete", incomplete_details: { reason }, output: [] }, "fixture/model"); + throw new Error("expected typed truncation"); + } catch (error) { + expect(isChatCompletionsStreamError(error)).toBe(true); + expect(error).toMatchObject({ status: 502, type: "upstream_error", code: "upstream_incomplete" }); + } +}); + +test("shared JSON-to-SSE serializer assigns tool indices and charges positive retained output", () => { + const budget = createTranslatorBudget({ maxTurnBytes: 8192 }); + try { + const converted = responsesJsonToChatCompletion({ status: "completed", output: [ + { type: "message", content: [{ type: "output_text", text: "Fixture answer" }] }, + { type: "function_call", call_id: "call_one", name: "lookup", arguments: "{}" }, + ] }, "model", budget); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + const text = jsonCompletionSse(converted, "model", budget); + const chunks = text.split("\n").filter(x => x.startsWith("data: {")).map(x => JSON.parse(x.slice(6)) as Chunk); + const calls = chunks.flatMap(c => c.choices.flatMap(x => x.delta.tool_calls ?? [])); + expect(calls[0]?.index).toBe(0); + expect(budget.snapshot().currentBytes).toBeGreaterThanOrEqual(Buffer.byteLength(text) * 2); + expect(budget.snapshot().highWaterBytes).toBeLessThanOrEqual(8192); + } finally { budget.dispose(); } + expect(budget.snapshot().currentBytes).toBe(0); +}); + +test("shared JSON-to-SSE serializer rejects an oversized terminal batch before returning success", () => { + const budget = createTranslatorBudget({ maxTurnBytes: 128 }); + try { + expect(() => jsonCompletionSse({ choices: [{ message: { content: "fixture" }, finish_reason: "stop" }] }, "model", budget)) + .toThrow(); + expect(budget.snapshot().overflows).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } +}); + +test("JSON projection rejects retained output overflow with the existing typed budget error", () => { + const budget = createTranslatorBudget({ maxTurnBytes: 16 }); + try { + let failure: unknown; + try { responsesJsonToChatCompletion({ output: [{ type: "message", content: [{ type: "output_text", text: "x".repeat(32) }] }] }, "model", budget); } + catch (error) { failure = error; } + expect(isTranslatorBudgetExceededError(failure)).toBe(true); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } +}); + + +test.each(["max_messages", "steered", "adapter_eof"])("handler reports unsupported incomplete %s as a typed error", async reason => { + await streamFixture([], "incomplete", false, reason, { error: true }); +}); + +test.each([["max_output_tokens", "length"], ["content_filter", "content_filter"]])( + "JSON client receives %s boundary rather than tool_calls", async (reason, finish) => { + await streamFixture([{ type: "function_call", call_id: "call_partial", name: "lookup", arguments: "{}" }], + "incomplete", false, reason, { jsonFinish: finish }); + }, +); + + +test("JSON projection accounts split Unicode and ignores empty fragments", () => { + const budget = createTranslatorBudget({ maxTurnBytes: 8192 }); + try { + const completion = responsesJsonToChatCompletion({ output: [ + { type: "message", content: [ + { type: "output_text", text: "\ud83d" }, + ...Array.from({ length: 100 }, () => ({ type: "output_text", text: "" })), + { type: "output_text", text: "\ude00" }, + ] }, + { type: "reasoning", summary: [{ type: "summary_text", text: "\ud83d" }, { type: "summary_text", text: "\ude00" }] }, + ] }, "model", budget); + expect(completion.choices).toMatchObject([{ message: { content: "😀", reasoning_content: "😀" } }]); + expect(budget.snapshot().currentBytes).toBe(8); + } finally { budget.dispose(); } +}); + + +test("buffered calls enforce their per-call cap, including an empty upstream ID", () => { + for (const call_id of ["fixture-call", ""]) { + const budget = createTranslatorBudget({ maxCallArgumentBytes: 4, maxTurnBytes: 8192 }); + try { + let failure: unknown; + try { + responsesJsonToChatCompletion({ output: [{ type: "function_call", call_id, name: "lookup", arguments: "12345" }] }, "model", budget); + } catch (error) { failure = error; } + expect(isTranslatorBudgetExceededError(failure)).toBe(true); + expect(failure).toMatchObject({ code: "translation_buffer_limit", kind: "tool_args", limitBytes: 4 }); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().activeCalls).toBe(0); + const result = responsesJsonToChatCompletion({ output: [{ type: "function_call", call_id, name: "lookup", arguments: "1234" }] }, "model", budget); + expect(result.choices).toMatchObject([{ message: { tool_calls: [{ function: { arguments: "1234" } }] } }]); + expect(budget.snapshot().activeCalls).toBe(0); + } finally { budget.dispose(); } + } +}); + +test("JSON-to-SSE rejects a call above 2 MiB without success output or duplicate usage", async () => { + await streamFixture([{ type: "function_call", call_id: "large-call", name: "lookup", arguments: JSON.stringify({ text: "x".repeat(2 * 1024 * 1024) }) }], + "completed", false, "max_output_tokens", { error: true, errorCode: "translation_buffer_limit" }); +}); diff --git a/tests/responses/chat-refusal.test.ts b/tests/responses/chat-refusal.test.ts new file mode 100644 index 0000000000..27f6ff86ef --- /dev/null +++ b/tests/responses/chat-refusal.test.ts @@ -0,0 +1,474 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + ChatCompletionsStreamError, + collectChatCompletion, + responsesJsonToChatCompletion, + responsesSseToChatCompletionsSse, +} from "../../src/chat/outbound"; +import { jsonCompletionSse, nativeChatSse } from "../../src/server/chat-native-sse"; +import type { OcxConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; + +type Rec = Record; +type Frame = { + error?: { code: string; type: string; message: string }; + choices?: Array<{ delta?: Rec; finish_reason?: string | null }>; +}; +const encoder = new TextEncoder(); +const model = "refusal-fixture/model"; +const event = (type: string, fields: Rec = {}): Rec => ({ type, ...fields }); +const part = (refusal: unknown): Rec => ({ type: "refusal", refusal }); +const message = (content: unknown[], id?: unknown): Rec => ({ + type: "message", role: "assistant", content, ...(id === undefined ? {} : { id }), +}); +const terminal = (output?: unknown[], reason?: string): Rec => event( + reason ? "response.incomplete" : "response.completed", + { response: { + status: reason ? "incomplete" : "completed", + ...(output ? { output } : {}), + ...(reason ? { incomplete_details: { reason } } : {}), + } }, +); +const refusalDelta = (delta: unknown, output_index = 0, content_index = 0, ids: Rec = {}): Rec => + event("response.refusal.delta", { output_index, content_index, delta, ...ids }); +const refusalDone = (refusal: unknown, output_index = 0, content_index = 0, ids: Rec = {}): Rec => + event("response.refusal.done", { output_index, content_index, refusal, ...ids }); +function wireEvent(value: Rec): string { + return `event: ${value.type}\ndata: ${JSON.stringify(value)}\n\n`; +} +function bytesSource(chunks: string[], onCancel = () => {}, close = true): ReadableStream { + let next = 0; + return new ReadableStream({ + pull(controller) { + if (next < chunks.length) controller.enqueue(encoder.encode(chunks[next++]!)); + else if (close) controller.close(); + }, + cancel: onCancel, + }, { highWaterMark: 0 }); +} +function translated(events: Rec[], budget = createTestTranslatorBudget(), onCancel = () => {}, close = true) { + return responsesSseToChatCompletionsSse(bytesSource(events.map(wireEvent), onCancel, close), model, { + translatorBudget: budget, + }); +} +function frames(wire: string): Frame[] { + return wire.split("\n\n").filter(block => block.startsWith("data: ") && block !== "data: [DONE]") + .map(block => JSON.parse(block.slice(6)) as Frame); +} +function refusalText(wire: string): string { + return frames(wire).map(frame => frame.choices?.[0]?.delta?.refusal ?? "").join(""); +} +function firstChoice(completion: Rec) { + return (completion.choices as Array<{ message: Rec; finish_reason: string }>)[0]!; +} +function expectSuccess(wire: string, refusal: string, reason = "stop") { + expect(refusalText(wire)).toBe(refusal); + expect(frames(wire).filter(frame => frame.error)).toHaveLength(0); + expect(frames(wire).filter(frame => frame.choices?.[0]?.finish_reason)).toEqual([ + expect.objectContaining({ choices: [{ index: 0, delta: {}, finish_reason: reason }] }), + ]); + expect(wire.match(/data: \[DONE\]/g)).toHaveLength(1); +} +function expectFailure(wire: string, code: string) { + expect(frames(wire).filter(frame => frame.error)).toEqual([ + { error: expect.objectContaining({ type: "upstream_error", code }) }, + ]); + expect(frames(wire).some(frame => frame.choices?.[0]?.finish_reason)).toBe(false); + expect(wire).not.toContain("data: [DONE]"); +} + +// Independent oracle: OpenAI SDK ChatCompletionMessage.refusal: string | null, +// Choice.Delta.refusal?: string | null; Responses refusal.delta.delta and +// refusal.done.refusal, keyed by raw output_index/content_index. All text is inert. +describe("Chat refusal projection", () => { + test("JSON keeps ordered refusal separate from answer, reasoning and tools", () => { + const completion = responsesJsonToChatCompletion({ output: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "reason" }] }, + message([{ type: "output_text", text: "answer" }, part("fixture A"), part(" + B")]), + { type: "function_call", call_id: "call_fixture", name: "fixture", arguments: "{}" }, + message([part(" + C")]), + ] }, model); + expect(firstChoice(completion)).toMatchObject({ + message: { content: "answer", refusal: "fixture A + B + C", reasoning_content: "reason", + tool_calls: [{ id: "call_fixture", function: { name: "fixture", arguments: "{}" } }] }, + finish_reason: "tool_calls", + }); + expect(firstChoice(responsesJsonToChatCompletion({ output: [] }, model)).message.refusal).toBeNull(); + expect(firstChoice(responsesJsonToChatCompletion({ output: [message([part("")])] }, model)).message.refusal).toBe(""); + expect(() => responsesJsonToChatCompletion({ output: [message([part(null)])] }, model)) + .toThrow(ChatCompletionsStreamError); + }); + + test("split deltas and all repeated final representations contribute each suffix once", async () => { + const wire = await new Response(translated([ + event("response.output_item.added", { output_index: 2, item: message([], "item_fixture") }), + refusalDelta("fixture ", 2, 1, { item_id: "item_fixture" }), + refusalDelta("A", 2, 1), + refusalDone("fixture A", 2, 1), + event("response.content_part.done", { output_index: 2, content_index: 1, part: part("fixture AB") }), + event("response.output_item.done", { output_index: 2, + item: message([{ type: "output_text", text: "" }, part("fixture AB")], "item_fixture") }), + terminal([{}, { type: "reasoning" }, message([{}, part("fixture ABC")], "item_fixture")]), + ])).text(); + expectSuccess(wire, "fixture ABC"); + expect(frames(wire).filter(frame => frame.choices?.[0]?.delta?.refusal !== undefined)).toHaveLength(1); + }); + + for (const representation of ["done", "part", "item", "terminal"] as const) { + test(`${representation}-only refusal survives without deltas`, async () => { + const item = message([part("fixture")]); + const events = representation === "done" ? [refusalDone("fixture")] + : representation === "part" ? [event("response.content_part.done", { output_index: 0, content_index: 0, part: part("fixture") })] + : representation === "item" ? [event("response.output_item.done", { output_index: 0, item })] : []; + events.push(terminal(representation === "terminal" ? [item] : undefined)); + expectSuccess(await new Response(translated(events)).text(), "fixture"); + }); + } + + test("interleaved parts emit in raw output/content order and leave text live", async () => { + const wire = await new Response(translated([ + refusalDelta("C", 3, 0), refusalDelta("B", 1, 2), refusalDelta("A", 1, 0), + event("response.output_text.delta", { delta: "answer" }), + refusalDelta("2", 1, 2), refusalDelta("1", 1, 0), + terminal([{}, message([part("A1"), { type: "output_text", text: "answer" }, part("B2")]), {}, message([part("C")])]), + ])).text(); + expectSuccess(wire, "A1B2C"); + const deltas = frames(wire).flatMap(frame => frame.choices?.map(choice => choice.delta) ?? []); + expect(deltas.filter(delta => delta?.refusal !== undefined).map(delta => delta?.refusal)).toEqual(["A1", "B2", "C"]); + expect(deltas.filter(delta => delta?.content).map(delta => delta?.content)).toEqual(["answer"]); + expect(deltas.findIndex(delta => delta?.content === "answer")).toBeLessThan(deltas.findIndex(delta => delta?.refusal === "A1")); + }); + + test("missing, empty and stale-prefix snapshots preserve text and split Unicode", async () => { + const wire = await new Response(translated([ + refusalDelta("fixture \ud83d"), refusalDelta("\ude00"), + event("response.refusal.done", { output_index: 0, content_index: 0 }), + refusalDone(""), refusalDone("fixture"), + event("response.content_part.done", { output_index: 0, content_index: 0, part: { type: "refusal" } }), + event("response.output_item.done", { output_index: 0, item: message([]) }), + terminal([message([part("fixture ")])]), + ])).text(); + expectSuccess(wire, "fixture 😀"); + }); + + for (const reason of ["max_output_tokens", "content_filter"]) { + test(`valid incomplete ${reason} flushes refusal with truthful live finish`, async () => { + expectSuccess(await new Response(translated([ + refusalDelta("fixture"), terminal([message([part("fixture suffix")])], reason), + ])).text(), "fixture suffix", reason === "max_output_tokens" ? "length" : "content_filter"); + }); + } + + const invalidEvents: Array<[string, Rec]> = [ + ["contradictory done", refusalDone("other")], + ["nonstring delta", refusalDelta(42)], + ["nonstring done", refusalDone(null)], + ["nonstring content part", event("response.content_part.done", { output_index: 0, content_index: 0, part: part([]) })], + ["contradictory item", event("response.output_item.done", { output_index: 0, item: message([part("other")]) })], + ["contradictory terminal", terminal([message([part("other")])])], + ["nonstring terminal", terminal([message([part({})])])], + ["delta ID mismatch", refusalDelta("suffix", 0, 0, { item_id: "other" })], + ["nonstring event ID", refusalDone("fixture", 0, 0, { item_id: null })], + ["snapshot ID mismatch", terminal([message([part("fixture")], "other")])], + ["nonstring snapshot ID", terminal([message([part("fixture")], 5)])], + ["sparse snapshot ID mismatch", terminal([{ id: "other" }])], + ["sparse nonstring snapshot ID", terminal([{ id: null }])], + ["same ID at another position", terminal([{}, {}, message([part("fixture")], "item_fixture")])], + ["sparse same ID at another position", terminal([{}, {}, { id: "item_fixture" }])], + ["different part type", terminal([message([{ type: "output_text", text: "fixture" }])])], + ["negative position", refusalDelta("fixture", -1)], + ["fractional position", refusalDelta("fixture", 0, 0.5)], + ]; + for (const [label, invalid] of invalidEvents) { + test(`${label} fails without refusal or success terminal and cancels upstream`, async () => { + let cancelled = 0; + const wire = await new Response(translated([ + refusalDelta("fixture", 0, 0, { item_id: "item_fixture" }), invalid, terminal(), + ], createTestTranslatorBudget(), () => { cancelled++; }, false)).text(); + expectFailure(wire, "invalid_refusal"); + expect(refusalText(wire)).toBe(""); + expect(cancelled).toBe(1); + }); + } + + test("failure and unknown incomplete terminals discard buffered refusal", async () => { + for (const end of [terminal(undefined, "adapter_eof"), event("response.failed", { + response: { error: { message: "fixture failure" } }, + })]) { + const wire = await new Response(translated([refusalDelta("fixture"), end])).text(); + expect(refusalText(wire)).toBe(""); + expect(frames(wire).filter(frame => frame.error)).toHaveLength(1); + expect(wire).not.toContain("data: [DONE]"); + expect(frames(wire).some(frame => frame.choices?.[0]?.finish_reason)).toBe(false); + } + }); + + test("one item's optional ID constrains all of its content parts", async () => { + const wire = await new Response(translated([ + refusalDelta("A", 0, 0, { item_id: "first" }), + refusalDelta("B", 0, 1, { item_id: "second" }), terminal(), + ])).text(); + expectFailure(wire, "invalid_refusal"); + }); + + test("refusal text overflow is bounded and cancels the source", async () => { + let cancelled = 0; + const budget = createTestTranslatorBudget({ maxTurnBytes: 4096 }); + const wire = await new Response(translated([ + ...Array.from({ length: 50 }, () => refusalDelta("x".repeat(100))), terminal(), + ], budget, () => { cancelled++; }, false)).text(); + expectFailure(wire, "translation_buffer_limit"); + expect(budget.snapshot().highWaterBytes).toBeLessThanOrEqual(4096); + expect(cancelled).toBe(1); + }); + + test("zero-length parts consume metadata budget", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 2048 }); + let cancelled = 0; + const wire = await new Response(translated([ + ...Array.from({ length: 100 }, (_, index) => refusalDone("", 0, index)), terminal(), + ], budget, () => { cancelled++; }, false)).text(); + expectFailure(wire, "translation_buffer_limit"); + expect(budget.snapshot().overflows).toBe(1); + expect(cancelled).toBe(1); + }); + + test("small turn budget rejects the whole final batch, including pending role", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 900 }); + let cancelled = 0; + const wire = await new Response(translated([refusalDone("fixture"), terminal()], budget, + () => { cancelled++; }, false)).text(); + expectFailure(wire, "translation_buffer_limit"); + expect(frames(wire).filter(frame => frame.choices)).toHaveLength(0); + expect(cancelled).toBe(1); + }); + + test("a reservation failure at DONE cannot leak pending tool/refusal/finish frames", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 4096 }); + const reserve = budget.reserveTransient.bind(budget); + let rejectedDone = false; + budget.reserveTransient = (bytes, scope) => { + if (bytes === encoder.encode("data: [DONE]\n\n").byteLength) { + rejectedDone = true; + // Exhaust the real configured budget at this precise admission boundary. + return reserve(4097, scope); + } + return reserve(bytes, scope); + }; + let cancelled = 0; + const wire = await new Response(translated([ + event("response.output_item.added", { output_index: 0, + item: { type: "function_call", id: "tool_fixture", call_id: "call_fixture", name: "f", arguments: "{}" } }), + refusalDone("fixture", 1), terminal(), + ], budget, () => { cancelled++; }, false)).text(); + expect(rejectedDone).toBe(true); + expectFailure(wire, "translation_buffer_limit"); + expect(frames(wire).flatMap(frame => frame.choices ?? []).every(choice => + !choice.delta?.tool_calls && choice.delta?.refusal === undefined)).toBe(true); + expect(budget.snapshot().activeCalls).toBe(0); + expect(cancelled).toBe(1); + }); + + test("successful terminal flush preserves pending tools and releases charged metadata", async () => { + const budget = createTestTranslatorBudget(); + const charge = budget.chargeRetained.bind(budget); + const release = budget.releaseRetained.bind(budget); + let metadataCharged = 0; + let metadataReleased = 0; + budget.chargeRetained = (bytes, scope) => { + charge(bytes, scope); + if (scope.kind === "item_ids") metadataCharged += bytes; + }; + budget.releaseRetained = (bytes, scope) => { + release(bytes, scope); + if (scope.kind === "item_ids") metadataReleased += bytes; + }; + const wire = await new Response(translated([ + event("response.output_item.added", { output_index: 0, + item: { type: "function_call", id: "tool_fixture", call_id: "call_fixture", name: "fixture" } }), + event("response.function_call_arguments.delta", { item_id: "tool_fixture", delta: "{}" }), + refusalDelta("fixture", 2, 0, { item_id: "message_fixture" }), terminal(), + ], budget)).text(); + expectSuccess(wire, "fixture", "tool_calls"); + expect(frames(wire).flatMap(frame => frame.choices?.[0]?.delta?.tool_calls ?? [])).toEqual([ + { index: 0, id: "call_fixture", type: "function", function: { name: "fixture", arguments: "{}" } }, + ]); + expect(metadataCharged).toBeGreaterThan(0); + expect(metadataReleased).toBe(metadataCharged); + }); + + test("cancellation releases buffered refusal text and map metadata", async () => { + const budget = createTestTranslatorBudget(); + let cancelled = 0; + const reader = translated([ + refusalDelta("fixture"), event("response.heartbeat"), + ], budget, () => { cancelled++; }, false).getReader(); + await reader.read(); // Heartbeat role proves the preceding refusal was retained. + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + await reader.cancel(); + reader.releaseLock(); + expect(cancelled).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); + +describe("Chat refusal collection and native serialization", () => { + test("collector preserves nullable refusal and native JSON-to-SSE round trip", async () => { + const completion = responsesJsonToChatCompletion({ output: [message([part("fixture")])] }, model); + const wire = jsonCompletionSse(completion, model); + expectSuccess(wire, "fixture"); + const collected = await collectChatCompletion(bytesSource([wire]), model, createTestTranslatorBudget()); + expect(firstChoice(collected).message).toMatchObject({ content: null, refusal: "fixture" }); + const empty = await collectChatCompletion(bytesSource([jsonCompletionSse({ choices: [{ message: { content: "answer", refusal: null } }] }, model)]), model, createTestTranslatorBudget()); + expect(firstChoice(empty).message).toMatchObject({ content: "answer", refusal: null }); + }); + + test("absent-only refusal evidence stays null while explicit empty refusal stays empty", async () => { + for (const evidence of [{ type: "refusal" }, part("")]) { + const budget = createTestTranslatorBudget(); + const completion = await collectChatCompletion(translated([terminal([message([evidence])])], budget), model, budget); + expect(firstChoice(completion).message.refusal).toBe(Object.hasOwn(evidence, "refusal") ? "" : null); + } + }); + + test("translated SSE collection uses the same ordered refusal contract", async () => { + const budget = createTestTranslatorBudget(); + const completion = await collectChatCompletion(translated([ + refusalDelta("B", 1), refusalDelta("A", 0), terminal(), + ], budget), model, budget); + expect(firstChoice(completion).message).toMatchObject({ content: null, refusal: "AB" }); + }); + + test("native SSE relay leaves refusal deltas intact", async () => { + const budget = createTestTranslatorBudget(); + const wire = [ + 'data: {"choices":[{"index":0,"delta":{"content":"answer","refusal":"fixture"},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + "data: [DONE]\n\n", + ].join(""); + const relayed = nativeChatSse(bytesSource([wire]), { + requestedModel: model, translatorBudget: budget, signal: new AbortController().signal, onUsage() {}, + }); + expectSuccess(await new Response(relayed).text(), "fixture"); + }); + + test("collector processing overflow cancels its reader and never returns partial JSON", async () => { + let cancelled = 0; + const budget = createTestTranslatorBudget({ maxTurnBytes: 1024 }); + const reserve = budget.reserveTransient.bind(budget); + let failedKind = ""; + budget.reserveTransient = (bytes, scope) => { + try { return reserve(bytes, scope); } catch (error) { failedKind = scope.kind; throw error; } + }; + const chunk = `data: ${JSON.stringify({ choices: [{ delta: { refusal: "x".repeat(100) } }] })}\n\n`; + await expect(collectChatCompletion(bytesSource(Array(20).fill(chunk), () => { cancelled++; }, false), model, budget)) + .rejects.toMatchObject({ status: 502, type: "upstream_error", code: "translation_buffer_limit" }); + expect(failedKind).toBe("retained_collectors"); + expect(cancelled).toBe(1); + }); + + test("collector error cancellation reaches an upstream translator", async () => { + const translatorBudget = createTestTranslatorBudget(); + const collectorBudget = createTestTranslatorBudget({ maxTurnBytes: 100 }); + let cancelled = 0; + const stream = translated([refusalDelta("fixture"), event("response.heartbeat")], translatorBudget, + () => { cancelled++; }, false); + await expect(collectChatCompletion(stream, model, collectorBudget)) + .rejects.toMatchObject({ code: "translation_buffer_limit" }); + expect(cancelled).toBe(1); + expect(translatorBudget.snapshot().currentBytes).toBe(0); + }); + + test("malformed native refusal and typed error frames cancel without partial JSON", async () => { + for (const payload of [ + { choices: [{ delta: { refusal: 17 } }] }, + { error: { message: "fixture error", type: "upstream_error", code: "fixture_error" } }, + ]) { + let cancelled = 0; + await expect(collectChatCompletion(bytesSource([`data: ${JSON.stringify(payload)}\n\n`], () => { cancelled++; }, false), + model, createTestTranslatorBudget())).rejects.toBeInstanceOf(ChatCompletionsStreamError); + expect(cancelled).toBe(1); + } + }); +}); + +// Handler coverage uses only an external fetch stub, never a mocked converter/handler. +// It also exercises #3770's shared JSON fallback after the parent commit is applied. +describe("refusal handler delivery matrix", () => { + const originalFetch = globalThis.fetch; + let isolatedHome: IsolatedCodexHome | undefined; + let previousOcxHome: string | undefined; + beforeEach(() => { + previousOcxHome = process.env.OPENCODEX_HOME; + isolatedHome = installIsolatedCodexHome("ocx-refusal-fixture-"); + process.env.OPENCODEX_HOME = isolatedHome.path; + globalThis.fetch = (async () => { throw new Error("unstubbed external transport"); }) as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + resetProviderRequestPacingForTest(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + isolatedHome?.restore(); + }); + for (const native of [true, false]) { + for (const upstreamSse of [true, false]) { + for (const clientSse of [true, false]) { + test(`${native ? "native" : "translated"} upstream ${upstreamSse ? "SSE" : "JSON"} -> client ${clientSse ? "SSE" : "JSON"}`, async () => { + const { handleChatCompletions } = await import("../../src/server/chat-completions"); + const responseJson = { id: "resp_fixture", status: "completed", output: [message([part("fixture")], "item_fixture")] }; + const chatJson = { id: "chatcmpl_fixture", object: "chat.completion", created: 1, model, + choices: [{ index: 0, message: { role: "assistant", content: null, refusal: "fixture" }, finish_reason: "stop" }] }; + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.origin).toBe("https://refusal.example.test"); + seen.push(url.pathname); + if (!upstreamSse) return Response.json(native ? chatJson : responseJson); + const wire = native ? [ + 'data: {"choices":[{"index":0,"delta":{"refusal":"fixture"},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + "data: [DONE]\n\n", + ].join("") : [refusalDelta("fixture", 0, 0, { item_id: "item_fixture" }), terminal(responseJson.output)].map(wireEvent).join(""); + return new Response(wire, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, defaultProvider: "refusal-fixture", providers: { + "refusal-fixture": { adapter: native ? "openai-chat" : "openai-responses", + baseUrl: "https://refusal.example.test/v1", apiKey: "fixture-key", authMode: "key" }, + }, + } as OcxConfig; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model, stream: clientSse, messages: [{ role: "user", content: "inert fixture" }] }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + if (clientSse) expectSuccess(await response.text(), "fixture"); + else expect(firstChoice(await response.json() as Rec).message).toMatchObject({ content: null, refusal: "fixture" }); + expect(seen).toEqual([native ? "/v1/chat/completions" : "/v1/responses"]); + }); + } + } + } +}); + +test("sparse terminal preserves a matching refusal ID", async () => { + const wire = await new Response(translated([ + refusalDelta("fixture", 0, 0, { item_id: "item_fixture" }), + terminal([{ id: "item_fixture" }]), + ])).text(); + expectSuccess(wire, "fixture"); +}); + +test("JSON refusal charges joined surrogate bytes and rejects retained overflow", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 16 }); + const completion = responsesJsonToChatCompletion({ output: [message([part("\ud83d"), part("\ude00")])] }, model, budget); + expect(firstChoice(completion).message.refusal).toBe("😀"); + expect(budget.snapshot().currentBytes).toBe(4); + const small = createTestTranslatorBudget({ maxTurnBytes: 4 }); + expect(() => responsesJsonToChatCompletion({ output: [message([part("fixture")])] }, model, small)).toThrow(); + expect(small.snapshot().overflows).toBe(1); + expect(small.snapshot().currentBytes).toBe(0); +}); diff --git a/tests/responses/compaction-progress.test.ts b/tests/responses/compaction-progress.test.ts new file mode 100644 index 0000000000..3e1a1556ea --- /dev/null +++ b/tests/responses/compaction-progress.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const encoder = new TextEncoder(); +const provider = { adapter: "openai-responses", baseUrl: "https://gateway.example/v1", authMode: "key" as const }; +const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n`; +const completed = { + type: "response.completed", + response: { + id: "resp_compaction", + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Final summary" }] }], + }, +}; + +function upstream() { + let controller!: ReadableStreamDefaultController; + let nextRead = Promise.withResolvers(); + let ended = false; + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + start(value) { controller = value; }, + pull() { pulls++; nextRead.resolve(); }, + cancel() { ended = true; cancelled = true; }, + }, { highWaterMark: 0 }); + return { + body, + get pulls() { return pulls; }, + get cancelled() { return cancelled; }, + waitingForRead: () => nextRead.promise, + send(text: string) { + nextRead = Promise.withResolvers(); + controller.enqueue(encoder.encode(text)); + }, + close() { if (!ended) { ended = true; controller.close(); } }, + }; +} + +function bridged() { + const source = upstream(); + const budget = createTestTranslatorBudget(); + let beat = () => {}; + let cleanupCalls = 0; + const stream = bridgeToResponsesSSE( + createResponsesPassthroughAdapter(provider).parseStream(new Response(source.body), budget), + "example-model", undefined, undefined, undefined, + () => { cleanupCalls++; source.close(); }, 500, + { + translatorBudget: budget, compaction: true, stallTimeoutSec: 1, + timers: { + setInterval(callback) { beat = callback; return 1; }, + clearInterval() { beat = () => {}; }, + }, + }, + ); + const text = new Response(stream).text(); + return { + source, text, + get cleanupCalls() { return cleanupCalls; }, + tick: () => beat(), + async send(text: string) { + await source.waitingForRead(); + source.send(text); + // The next upstream read occurs after the bridge consumes any adapter heartbeat. + await source.waitingForRead(); + }, + }; +} + +describe("buffered Responses compaction progress", () => { + // Codex oracle: openai/codex d2d5b702, codex-api/src/sse/responses.rs:367-408. + // Indices make these canonical reasoning fixtures; progress itself carries no content. + for (const delta of [ + { type: "response.output_text.delta", delta: "Buffered progress" }, + { type: "response.reasoning_summary_text.delta", delta: "Buffered progress", summary_index: 0 }, + { type: "response.reasoning_text.delta", delta: "Buffered progress", content_index: 0 }, + ]) { + test(`${delta.type} prevents stall before terminal without exposing partial content`, async () => { + const h = bridged(); + try { + for (let i = 0; i < 6; i++) { + await h.send(frame(delta)); + h.tick(); + expect(h.cleanupCalls).toBe(0); + } + await h.send(frame(completed)); + h.source.close(); + const wire = await h.text; + expect(wire.match(/event: response.completed\n/g)).toHaveLength(1); + expect(wire.match(/event: response.output_item.done\n/g)).toHaveLength(1); + expect(wire).toContain('"type":"compaction"'); + expect(wire).not.toContain("Buffered progress"); + expect(wire).not.toContain("event: response.output_text.delta"); + expect(wire).not.toContain("upstream_stall_timeout"); + // The bridge invokes its upstream cleanup callback on normal terminal events too. + expect(h.cleanupCalls).toBe(1); + } finally { h.source.close(); await h.text; } + }); + } + + test("comments, typed keepalives and empty or malformed deltas do not reset stall", async () => { + const h = bridged(); + try { + const noise = ": keep-alive\n\ndata: invalid-json\n\n" + + frame({ type: "response.heartbeat" }) + + frame({ type: "response.output_text.delta", delta: "" }) + + frame({ type: "response.reasoning_summary_text.delta", delta: null }) + + frame({ type: "response.reasoning_text.delta", delta: 42 }) + + frame({ type: "response.unknown.delta", delta: "not recognized progress" }); + await h.send(noise); + h.tick(); + await h.send(noise); + h.tick(); + const wire = await h.text; + expect(wire).toContain("upstream_stall_timeout"); + expect(wire).not.toContain("event: response.completed"); + expect(wire).not.toContain('"type":"compaction"'); + expect(h.cleanupCalls).toBe(1); + } finally { h.source.close(); await h.text; } + }); + + test("progress preserves snapshot precedence, usage and native ciphertext", async () => { + const budget = createTestTranslatorBudget(); + const ciphertext = "gAAAAABm-native-compaction-ciphertext"; + const usage = { input_tokens: 12, output_tokens: 4, total_tokens: 16, gateway_metadata: { cached: true } }; + const terminal = { + ...completed, + response: { ...completed.response, usage, output: [ + ...completed.response.output, { type: "compaction", encrypted_content: ciphertext }, + ] }, + }; + const input = frame({ type: "response.output_text.delta", delta: "Partial text" }) + + frame({ type: "response.output_text.done", text: "Done text" }) + + frame(terminal) + + frame({ type: "response.output_text.delta", delta: "Late text" }); + const events: AdapterEvent[] = []; + for await (const event of createResponsesPassthroughAdapter(provider).parseStream(new Response(input), budget)) { + events.push(event); + } + expect(events).toEqual([ + { type: "heartbeat" }, + { type: "text_delta", text: "Final summary" }, + { type: "done", usage: { inputTokens: 12, outputTokens: 4, totalTokens: 16, rawUsage: usage }, compactionEncryptedContent: ciphertext }, + ]); + const result = buildResponseJSON(events, "example-model", { compaction: true, translatorBudget: budget }); + expect(result.output).toEqual([expect.objectContaining({ type: "compaction", encrypted_content: ciphertext })]); + }); + + test("reasoning progress with ciphertext-only completion does not manufacture summary text", async () => { + const budget = createTestTranslatorBudget(); + const ciphertext = "gAAAAABm-ciphertext-only"; + const input = frame({ type: "response.reasoning_text.delta", content_index: 0, delta: "Hidden reasoning" }) + + frame({ ...completed, response: { + ...completed.response, output: [{ type: "compaction", encrypted_content: ciphertext }], + } }); + const events: AdapterEvent[] = []; + for await (const event of createResponsesPassthroughAdapter(provider).parseStream(new Response(input), budget)) { + events.push(event); + } + expect(events).toEqual([{ type: "heartbeat" }, { type: "done", compactionEncryptedContent: ciphertext }]); + expect(budget.snapshot().currentBytes).toBe(encoder.encode(ciphertext).byteLength); + const result = buildResponseJSON(events, "example-model", { compaction: true, translatorBudget: budget }); + expect(result.output).toEqual([expect.objectContaining({ type: "compaction", encrypted_content: ciphertext })]); + }); + + test("a suspended heartbeat does not read ahead and return cancels the reader", async () => { + const source = upstream(); + const budget = createTestTranslatorBudget(); + const iterator = createResponsesPassthroughAdapter(provider).parseStream(new Response(source.body), budget); + try { + source.send(frame({ type: "response.reasoning_text.delta", content_index: 0, delta: "Hidden reasoning" }).repeat(64)); + expect(await iterator.next()).toEqual({ done: false, value: { type: "heartbeat" } }); + expect(source.pulls).toBe(0); // Only the already-enqueued chunk was consumed (HWM 0). + for (let i = 1; i < 64; i++) { + expect(await iterator.next()).toEqual({ done: false, value: { type: "heartbeat" } }); + expect(source.pulls).toBe(0); + } + await iterator.return(undefined); + expect(source.cancelled).toBe(true); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { source.close(); await iterator.return(undefined); } + }); + + for (const type of ["response.failed", "response.incomplete"]) { + test(`${type} after progress never flushes a successful summary`, async () => { + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + const input = frame({ type: "response.output_text.delta", delta: "Unfinished summary" }) + + frame({ type, response: type === "response.failed" + ? { error: { message: "stopped" } } + : { incomplete_details: { reason: "stopped" } } }); + for await (const event of createResponsesPassthroughAdapter(provider).parseStream(new Response(input), budget)) { + events.push(event); + } + expect(events).toEqual([ + { type: "heartbeat" }, + type === "response.failed" ? { type: "error", message: "stopped" } : { type: "incomplete", reason: "stopped" }, + ]); + }); + } +}); diff --git a/tests/responses/continuation-dedup.test.ts b/tests/responses/continuation-dedup.test.ts index 5e9efd2a74..c5b55b10fd 100644 --- a/tests/responses/continuation-dedup.test.ts +++ b/tests/responses/continuation-dedup.test.ts @@ -310,9 +310,17 @@ describe("replay overlap: contracts held elsewhere", () => { }); test("the skip counter is not published on the memory surface", () => { - // /api/system/memory pins exactly 17 privacy-reviewed scalar fields. The five - // spill-health additions are enums, counters, or timestamps — never error text. - expect(Object.keys(responseStateMetrics())).toHaveLength(17); + // Pin the reviewed public fields, not just their count: no replay-skip + // counter or arbitrary diagnostic may replace a permitted field unnoticed. + expect(Object.keys(responseStateMetrics()).sort()).toEqual([ + "count", "residentCount", "spillStubCount", "tombstoneCount", + "totalBytes", "spillPayloadBytes", "largestBytes", "oldestAgeMs", + "spillWrites", "spillWriteFailures", "spillReadFailures", + "spillWriteStatus", "spillWriteConsecutiveFailures", + "spillLastWriteFailureCode", "spillLastWriteFailureOrigin", + "spillAclRetryReturnedTimeouts", "spillAclTimeoutMemoRefusals", + "spillLastWriteFailureAt", "spillLastWriteSuccessAt", "replayScopeMismatchDrops", + ].sort()); }); test("clearing state for tests resets the skip counter", () => { diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index 7724661246..629c3474a7 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { restoreRoutedCustomCalls, rewriteRoutedCustomToolsForUpstream } from "../../src/responses/custom-tool-compat"; import { createRoutedNamespaceCallRestoreRewrite, restoreRoutedNamespaceCalls, @@ -67,6 +68,7 @@ describe("Responses namespace tool compatibility", () => { ]); expect([...rewritten.aliases]).toEqual([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); }); @@ -124,6 +126,7 @@ describe("Responses namespace tool compatibility", () => { }); expect([...allowed.aliases]).toEqual([ ["collaboration__safe", { namespace: "collaboration", name: "safe", kind: "function" }], + ["collaboration.safe", { namespace: "collaboration", name: "safe", kind: "function" }], ]); expect(restoreRoutedNamespaceCalls({ type: "function_call", @@ -237,7 +240,7 @@ describe("Responses namespace tool compatibility", () => { ], }, }); - expect([...aliases.keys()]).toEqual([wireName]); + expect([...aliases.keys()]).toEqual([wireName, "collaboration.safe"]); }); }); @@ -253,7 +256,7 @@ describe("Responses namespace tool compatibility", () => { const { aliases } = rewriteRoutedNamespaceToolsForUpstream( choice === undefined ? { tools } : { tools, tool_choice: choice }, ); - expect(aliases.size).toBe(2); + expect(aliases.size).toBe(4); } // A top-level selector for another tool kind states a restriction that no // namespace call satisfies, so it authorizes nothing. @@ -482,6 +485,7 @@ describe("Responses namespace tool compatibility", () => { test("restores only aliases authorized by this request in JSON and SSE payloads", () => { const aliases = new Map([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); const payload = { type: "response.completed", @@ -517,3 +521,68 @@ describe("Responses namespace tool compatibility", () => { expect(restoreRoutedNamespaceCallsInJson("not-json", aliases)).toBe("not-json"); }); }); + +describe("dotted namespace restoration uses the declaration collision boundary", () => { + const ping = { type: "namespace", name: "mcp", tools: [{ type: "function", name: "ping", parameters: {} }] }; + test.each(["mcp.ping", "mcp__ping"])("preserves a custom call whose alias %s declares an ordinary function", name => { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping] }); + expect(aliases.get(name)?.kind).toBe("function"); + for (const namespace of [undefined, "mcp"]) { + const call = { type: "custom_tool_call", name, call_id: "call_ping", input: "raw custom input", + ...(namespace === undefined ? {} : { namespace }) }; + expect(restoreRoutedNamespaceCalls(call, aliases)).toEqual({ value: call, changed: false }); + expect(restoreRoutedNamespaceCalls(call, aliases).value).toBe(call); + const text = JSON.stringify({ type: "response.completed", response: { output: [call] } }, null, 2); + expect(restoreRoutedNamespaceCallsInJson(text, aliases)).toBe(text); + expect(createRoutedNamespaceCallRestoreRewrite(aliases)(text)).toBe(text); + } + }); + + test.each(["mcp.run", "mcp__run"])("restores the declared custom tool after upstream function downgrade via %s", name => { + const downgraded = rewriteRoutedCustomToolsForUpstream({ + tools: [{ type: "namespace", name: "mcp", tools: [{ type: "custom", name: "run", description: "Run raw input" }] }], + }, false); + const namespaced = rewriteRoutedNamespaceToolsForUpstream(downgraded.body, downgraded.names); + expect(namespaced.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); + expect(downgraded.names.has("mcp__run")).toBe(true); + expect(namespaced.aliases.get(name)?.kind).toBe("custom"); + const call = { type: "function_call", name, id: "fc_run", call_id: "call_run", arguments: '{"input":"echo ready"}' }; + const restored = restoreRoutedNamespaceCalls(call, namespaced.aliases); + expect(restored).toEqual({ changed: true, value: { ...call, name: "run", namespace: "mcp" } }); + expect(restoreRoutedCustomCalls({ output: [restored.value] }, downgraded.names).value).toEqual({ + output: [{ type: "custom_tool_call", name: "run", namespace: "mcp", id: "ctc_run", call_id: "call_run", input: "echo ready" }], + }); + const nativeCustom = { type: "custom_tool_call", name, input: "raw custom input" }; + expect(restoreRoutedNamespaceCalls(nativeCustom, namespaced.aliases).value) + .toEqual({ ...nativeCustom, name: "run", namespace: "mcp" }); + }); + + test("restores the dotted spelling after canonical tool-choice authorization", () => { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping], tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); + expect(restoreRoutedNamespaceCalls({ type: "function_call", name: "mcp.ping", arguments: "{}" }, aliases).value) + .toEqual({ type: "function_call", name: "ping", namespace: "mcp", arguments: "{}" }); + const conflicting = { type: "function_call", name: "mcp.ping", namespace: "other", arguments: "{}" }; + expect(restoreRoutedNamespaceCalls(conflicting, aliases).value).toEqual(conflicting); + }); + test.each([ + { type: "function", name: "mcp.ping", parameters: {} }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "mcp.ping", parameters: {} }] }, + ])("a bare canonical declaration prevents dotted shadowing in either order", collision => { + for (const tools of [[ping, collision], [collision, ping]]) { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools, tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); + expect(aliases.has("mcp.ping")).toBe(false); + expect(aliases.has("mcp__ping")).toBe(true); + } + }); + test("different dotted coordinates remain ambiguous and canonical forms remain distinct", () => { + for (const tools of [ + [{ type: "namespace", name: "a.b", tools: [{ type: "function", name: "c" }] }, { type: "namespace", name: "a", tools: [{ type: "function", name: "b.c" }] }], + [{ type: "namespace", name: "a", tools: [{ type: "function", name: "b.c" }] }, { type: "namespace", name: "a.b", tools: [{ type: "function", name: "c" }] }], + ]) { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools }); + expect(aliases.has("a.b.c")).toBe(false); + expect(aliases.has("a.b__c")).toBe(true); + expect(aliases.has("a__b.c")).toBe(true); + } + }); +}); diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 6979755f43..ce54e28c85 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -21,6 +21,8 @@ import { import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { restoreRoutedNamespaceCalls } from "../../src/responses/namespace-tool-compat"; +import { restoreRoutedCustomCalls } from "../../src/responses/custom-tool-compat"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); @@ -1028,6 +1030,7 @@ describe("routed compaction lowering order", () => { expect([...(built.convertedRoutedToolSearchNames ?? [])]).toEqual(["opencodex_tool_search"]); expect([...(built.convertedRoutedNamespaceToolAliases ?? new Map()).entries()]).toEqual([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); }); @@ -2611,6 +2614,24 @@ describe("OpenAI Responses passthrough sanitization", () => { }]); }); + test("external task parsing preserves the existing raw passthrough repair", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key" as const, apiKey: "xai-test", + }); + const raw = { + model: "grok-4.6", + input: [{ type: "function_call_output", id: "external-fixture", name: "handoff_input", namespace: "task_inbox", output: "external input" }], + }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + expect(parsed.context.messages).toMatchObject([{ role: "user", content: "external input" }]); + expect(raw).toEqual(original); + const body = JSON.parse(adapter.buildRequest(parsed, meta).body) as { input: unknown[] }; + expect(body.input).toEqual([{ type: "message", role: "user", content: [ + { type: "input_text", text: "[tool output for unknown call]\nexternal input" }, + ] }]); + }); + test("api-key mode keeps stateful tool outputs with call_id intact", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", @@ -3832,6 +3853,59 @@ describe("routed namespace and custom-tool identity", () => { const frame = (event: string, payload: Record): string => `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + test.each(["function_call", "custom_tool_call"])("adapter preserves original custom kind for upstream %s after actual lowering order", type => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const built = adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools: rawTools }, + }, { headers: new Headers() }); + const aliases = built.convertedRoutedNamespaceToolAliases; + const names = built.convertedRoutedCustomToolNames; + if (!aliases || !names) throw new Error("Missing adapter conversion provenance"); + expect([...names]).toEqual([`${customNamespace}__read`]); + expect(JSON.parse(built.body).tools).toMatchObject([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + for (const separator of ["__", "."]) { + const name = `${customNamespace}${separator}read`; + expect(aliases.get(name)?.kind).toBe("custom"); + expect(aliases.get(`${functionNamespace}${separator}read`)?.kind).toBe("function"); + const call = type === "function_call" ? { ...customUpstreamItem, name } : { + type, name, id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }; + const restored = restoreRoutedNamespaceCalls({ output: [call] }, aliases); + expect(restored.changed).toBe(true); + expect(restoreRoutedCustomCalls(restored.value, names).value).toEqual({ output: [{ + type: "custom_tool_call", name: "read", namespace: customNamespace, + id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }] }); + const mismatched = { type: "custom_tool_call", name: `${functionNamespace}${separator}read`, input: "opaque payload" }; + expect(restoreRoutedNamespaceCalls(mismatched, aliases)).toEqual({ value: mismatched, changed: false }); + } + }); + + test("adapter custom provenance does not add excluded or colliding namespace aliases", () => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const build = (tools: unknown[], tool_choice: unknown) => adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools, tool_choice }, + }, { headers: new Headers() }); + expect(build(rawTools, "none").convertedRoutedNamespaceToolAliases?.size).toBe(0); + const selected = build(rawTools, { type: "function", namespace: functionNamespace, name: "read" }); + expect([...selected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${functionNamespace}__read`, `${functionNamespace}.read`]); + const customSelected = build(rawTools, { type: "custom", namespace: customNamespace, name: "read" }); + expect([...customSelected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${customNamespace}__read`, `${customNamespace}.read`]); + expect(customSelected.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + const collision = build([...rawTools, { type: "function", name: `${customNamespace}.read`, parameters: {} }], "auto"); + expect(collision.convertedRoutedNamespaceToolAliases?.has(`${customNamespace}.read`)).toBe(false); + expect(collision.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + expect(() => build([...rawTools, { type: "function", name: `${customNamespace}__read`, parameters: {} }], "auto")) + .toThrow("namespace tool wire-name collision"); + }); + test("round-trips same-named namespaced custom and function calls through JSON and SSE", async () => { const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); const built = adapter.buildRequest({ diff --git a/tests/responses/passthrough-abort.test.ts b/tests/responses/passthrough-abort.test.ts index 6283ecb617..6985c6cd54 100644 --- a/tests/responses/passthrough-abort.test.ts +++ b/tests/responses/passthrough-abort.test.ts @@ -80,7 +80,9 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { expect(sseBranch).toContain("win32EagerRewrite"); expect(sseBranch).toContain("rewriteBlocks: clientBlockRewrite"); // Elsewhere the failed-tail relay converts mid-stream resets into a clean response.failed. - expect(sseBranch).toContain("relaySseWithFailedTail(rewrittenBody, upstream"); + expect(sseBranch).toMatch( + /relaySseWithFailedTail\(\s*rewrittenBody,\s*upstream,\s*reason\s*=>\s*clientGone\.abort\(reason\),\s*\{\s*upstreamError:\s*logCtx\.upstreamError\s*\},\s*\)/, + ); expect(sseBranch).toContain("new Response(clientBody"); expect(sseBranch).toContain("markNativePassthroughSseResponse"); // #314/phase 100 two-platform contract: the real core gate delegates to the diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 1bf646d93c..3c09693b10 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -4,11 +4,12 @@ * contract; every other gateway has to be driven as a plain summarizer, or Codex * fatals on a compaction turn that came back as an ordinary message. */ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, jest, spyOn, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { looksLikeBackendCiphertext } from "../../src/server/responses/encrypted-payload"; import * as adapterResolveModule from "../../src/server/adapter-resolve"; import * as visionModule from "../../src/vision"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -947,6 +948,57 @@ describe("compact alternate-account attempt (#913)", () => { }); } + test("native compact headers followed by a stalled body return 504 without retry and release account cleanup", async () => { + await withPoolEnv("ocx-compact-body-deadline-", async config => { + config.stallTimeoutSec = 2; + const readStarted = Promise.withResolvers(); + let sends = 0; + let cancelled = 0; + let acceptedBody = false; + const body = new ReadableStream({ + pull() { readStarted.resolve(); }, + cancel() { cancelled++; return new Promise(() => {}); }, + }, { highWaterMark: 0 }); + globalThis.fetch = (async () => { + sends++; + return new Response(body, { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const releaseSpy = spyOn(authContextModule, "releaseCodexAuthContextProbeLease"); + const client = new AbortController(); + // Same scoped, non-concurrent Bun timer control as responses/ws-upstream.test.ts. + jest.useFakeTimers(); + const pending = handleResponsesCompact( + compactionRequest({ model: "gpt-5.5", input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier turn" }] }, + ] }, client.signal), config, { model: "", provider: "" }, + undefined, undefined, { onRequestBodyRead: () => { acceptedBody = true; } }, + ); + try { + await Promise.race([ + readStarted.promise, + pending.then(response => { throw new Error(`compact returned ${response.status} before reading its body`); }), + ]); + expect(acceptedBody).toBe(true); + expect(sends).toBe(1); + jest.advanceTimersByTime(2_000); + const response = await pending; + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ error: { code: "upstream_stall_timeout" } }); + expect(sends).toBe(1); + expect(cancelled).toBe(1); + expect(body.locked).toBe(false); + expect(releaseSpy).toHaveBeenCalledWith(expect.objectContaining({ kind: "pool", accountId: "pool-a" })); + } finally { + client.abort(); + try { await pending; } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + releaseSpy.mockRestore(); + } + } + }); + }); + test("canonical trailing slashes are pinned before native compact sends pool credentials", async () => { await withPoolEnv("ocx-compact-canonical-url-", async config => { config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; @@ -1632,6 +1684,99 @@ describe("computer screenshot output translation boundary", () => { }); }); +describe("external task-input envelopes (#3735)", () => { + // Synthetic charset/length fixture: short plaintext in this slot is deliberately + // normalized to input_text before parsing, so it cannot exercise opaque rejection. + const opaqueOutput = `g${"A".repeat(127)}`; + const external = (output: unknown = "external task input") => ({ + type: "function_call_output", id: "external-fixture", name: "handoff_input", namespace: "task_inbox", output, + }); + const body = (item: Record) => ({ + model: "gw/model", stream: false, input: [item], + }); + + test("opaque negative fixtures survive the plaintext-slot classifier", () => { + expect(looksLikeBackendCiphertext(opaqueOutput)).toBe(true); + }); + + test("sends a complete envelope as user text without an orphan-tool marker", async () => { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ id: "chat_external", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(external(" preserve this input\n"))), + keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + await res.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.messages).toEqual([{ role: "user", content: " preserve this input\n" }]); + expect(JSON.stringify(captured)).not.toContain("[tool output for unknown call]"); + }); + + test("preserves ordered text and image content through translation", async () => { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ id: "chat_external_image", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(external([ + { type: "output_text", text: "inspect " }, + { type: "input_image", image_url: "https://example.com/task.png", detail: "original" }, + { type: "input_text", text: " then continue" }, + ]))), keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + await res.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.messages).toEqual([{ role: "user", content: [ + { type: "text", text: "inspect " }, + { type: "image_url", image_url: { url: "https://example.com/task.png", detail: "high" } }, + { type: "text", text: " then continue" }, + ] }]); + }); + + test("retains existing plaintext-slot normalization before task-input admission", async () => { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ id: "chat_plaintext_slot", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(external([ + { type: "encrypted_content", encrypted_content: "plaintext task" }, + ]))), keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + await res.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.messages).toEqual([{ role: "user", content: "plaintext task" }]); + }); + + const invalid: Array<[string, Record]> = [ + ["empty call id", { ...external(), call_id: "" }], + ["null call id", { ...external(), call_id: null }], + ["numeric call id", { ...external(), call_id: 42 }], + ["incomplete metadata", { ...external(), namespace: "" }], + ["custom output", { ...external(), type: "custom_tool_call_output" }], + ["blank output", external(" ")], + ["empty output array", external([])], + ["opaque output", external([{ type: "encrypted_content", encrypted_content: opaqueOutput }])], + ["mixed opaque output", external([{ type: "input_text", text: "retained input" }, { type: "encrypted_content", encrypted_content: opaqueOutput }])], + ["malformed image", external([{ type: "input_image", image_url: 42 }])], + ]; + for (const [name, item] of invalid) { + test(`rejects ${name} before upstream work`, async () => { + let fetches = 0; + globalThis.fetch = (async () => { fetches++; throw new Error("invalid envelope reached upstream"); }) as typeof fetch; + const res = await handleResponses(compactionRequest(body(item)), + keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(400); + const error = await res.json() as { error?: { message?: string } }; + expect(error.error?.message).toBe("tool result requires a non-empty string call_id"); + expect(fetches).toBe(0); + expect(JSON.stringify(error)).not.toContain("retained input"); + }); + } +}); + describe("unpaired tool result boundary (#3259)", () => { function unpairedBody(item: Record): Record { return { diff --git a/tests/responses/responses-compaction.test.ts b/tests/responses/responses-compaction.test.ts index 631ff48e2b..edf6fec1bb 100644 --- a/tests/responses/responses-compaction.test.ts +++ b/tests/responses/responses-compaction.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, jest, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; @@ -15,10 +15,185 @@ import { } from "../../src/responses/compaction"; import type { AdapterEvent } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { bufferCompactResponse, COMPACT_RESPONSE_MAX_BYTES } from "../../src/server/responses/compact"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); +// These non-concurrent tests scope Bun's fake timers like responses/ws-upstream.test.ts. +// The real bounded-body reader and idleDeadline run; upstream pull acknowledgements +// synchronize chunk consumption before advancing time, without sleeps or mocking either helper. +async function withCompactBodyClock(run: () => Promise): Promise { + jest.useFakeTimers(); + try { + await run(); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } +} + +function compactBodySource(onCancel?: () => void) { + let controller!: ReadableStreamDefaultController; + let nextRead = Promise.withResolvers(); + const cancellationReasons: unknown[] = []; + let ended = false; + const body = new ReadableStream({ + start(value) { controller = value; }, + pull() { nextRead.resolve(); }, + cancel(reason) { + ended = true; + cancellationReasons.push(reason); + onCancel?.(); + // A deadline must return even if the upstream's cancellation cleanup never finishes. + return new Promise(() => {}); + }, + }, { highWaterMark: 0 }); + return { + body, cancellationReasons, + waitingForRead: () => nextRead.promise, + async send(bytes: Uint8Array) { + await nextRead.promise; + nextRead = Promise.withResolvers(); + controller.enqueue(bytes); + await nextRead.promise; + }, + close() { if (!ended) { ended = true; controller.close(); } }, + }; +} + +describe("native compact response body deadline", () => { + test("headers followed by silence expire at the default 300 seconds without waiting for cancel", () => withCompactBodyClock(async () => { + const source = compactBodySource(); + const pending = bufferCompactResponse(new Response(source.body), new AbortController().signal); + try { + await source.waitingForRead(); + jest.advanceTimersByTime(299_999); + expect(jest.getTimerCount()).toBe(1); + expect(source.cancellationReasons).toHaveLength(0); + jest.advanceTimersByTime(1); + const response = await pending; + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ error: { type: "upstream_stall_timeout", code: "upstream_stall_timeout" } }); + expect(source.cancellationReasons).toHaveLength(1); + expect(source.cancellationReasons[0]).toBeInstanceOf(DOMException); + expect((source.cancellationReasons[0] as DOMException).name).toBe("TimeoutError"); + expect(source.body.locked).toBe(false); + } finally { source.close(); await pending; } + })); + + test("nonempty chunks rearm the deadline and success preserves exact bytes and header hints", () => withCompactBodyClock(async () => { + const source = compactBodySource(); + const expected = new Uint8Array([0, 255, 128, 195, 40]); + const pending = bufferCompactResponse(new Response(source.body, { + status: 201, statusText: "Compact ready", + headers: { + "content-type": "application/octet-stream", "content-length": "999", + "retry-after": "42", "x-codex-primary-reset-at": "1900000000", + "x-codex-secondary-reset-at": "1900000001", "x-codex-tertiary-reset-at": "1900000002", + location: "/compact-result", "set-cookie": "ignored=1", "transfer-encoding": "chunked", + }, + }), new AbortController().signal, 2); + try { + await source.waitingForRead(); + for (let i = 0; i < expected.length; i++) { + jest.advanceTimersByTime(1_500); + await source.send(expected.subarray(i, i + 1)); + } + source.close(); + const response = await pending; + expect(response.status).toBe(201); + expect(response.statusText).toBe("Compact ready"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(expected); + expect(Object.fromEntries(response.headers)).toEqual({ + "content-type": "application/octet-stream", "retry-after": "42", + "x-codex-primary-reset-at": "1900000000", "x-codex-secondary-reset-at": "1900000001", + "x-codex-tertiary-reset-at": "1900000002", location: "/compact-result", + }); + expect(source.cancellationReasons).toHaveLength(0); + expect(source.body.locked).toBe(false); + } finally { source.close(); await pending; } + })); + + test("empty chunks do not rearm the byte inactivity deadline", () => withCompactBodyClock(async () => { + const source = compactBodySource(); + const pending = bufferCompactResponse(new Response(source.body), new AbortController().signal, 2); + try { + await source.waitingForRead(); + jest.advanceTimersByTime(1_000); + await source.send(new Uint8Array(0)); + jest.advanceTimersByTime(999); + expect(source.cancellationReasons).toHaveLength(0); + jest.advanceTimersByTime(1); + expect((await pending).status).toBe(504); + expect(source.cancellationReasons).toHaveLength(1); + expect(source.body.locked).toBe(false); + } finally { source.close(); await pending; } + })); + + for (const idleAlsoFires of [false, true]) { + test(`client cancellation unblocks a pending read and wins over idle expiry (${idleAlsoFires})`, () => withCompactBodyClock(async () => { + const client = new AbortController(); + // Abort during the timeout's source-cleanup callback, before the wrapper + // classifies its result. Advancing fake time can already flush promises. + const source = compactBodySource(idleAlsoFires ? () => client.abort(new Error("client stopped")) : undefined); + const pending = bufferCompactResponse(new Response(source.body), client.signal, 2); + try { + await source.waitingForRead(); + if (idleAlsoFires) jest.advanceTimersByTime(2_000); + else client.abort(new Error("client stopped")); + const response = await pending; + expect(client.signal.aborted).toBe(true); + expect(response.status).toBe(499); + expect(await response.json()).toMatchObject({ error: { code: "client_cancelled" } }); + expect(source.cancellationReasons).toHaveLength(1); + expect(source.body.locked).toBe(false); + } finally { source.close(); await pending; } + })); + } + + test("cancellation after a completed timeout does not retroactively replace its 504", () => withCompactBodyClock(async () => { + const source = compactBodySource(); + const client = new AbortController(); + const pending = bufferCompactResponse(new Response(source.body), client.signal, 2); + try { + await source.waitingForRead(); + jest.advanceTimersByTime(2_000); + const response = await pending; + expect(response.status).toBe(504); + client.abort(new Error("late cancellation")); + expect(response.status).toBe(504); + expect(source.cancellationReasons).toHaveLength(1); + } finally { source.close(); await pending; } + })); + + test("declared and observed oversize bodies retain the 32 MiB limit without waiting for cancel", () => withCompactBodyClock(async () => { + for (const declared of [true, false]) { + let cancelled = 0; + const body = new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(COMPACT_RESPONSE_MAX_BYTES + 1)); }, + cancel() { cancelled++; return new Promise(() => {}); }, + }, { highWaterMark: 0 }); + const response = await bufferCompactResponse(new Response(body, { + headers: declared ? { "content-length": String(COMPACT_RESPONSE_MAX_BYTES + 1) } : {}, + }), new AbortController().signal, 2); + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ error: { code: "compact_response_too_large" } }); + expect(cancelled).toBe(1); + expect(body.locked).toBe(false); + } + const atLimit = new Uint8Array(COMPACT_RESPONSE_MAX_BYTES); + atLimit[atLimit.length - 1] = 255; + const response = await bufferCompactResponse(new Response(atLimit), new AbortController().signal, 2); + expect(response.status).toBe(200); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect(bytes.byteLength).toBe(COMPACT_RESPONSE_MAX_BYTES); + expect(bytes[0]).toBe(0); + expect(bytes[bytes.length - 1]).toBe(255); + })); +}); + async function* replay(events: AdapterEvent[]): AsyncGenerator { for (const event of events) yield event; } diff --git a/tests/responses/responses-custom-tool-repair.test.ts b/tests/responses/responses-custom-tool-repair.test.ts index ac5995d912..98336d0241 100644 --- a/tests/responses/responses-custom-tool-repair.test.ts +++ b/tests/responses/responses-custom-tool-repair.test.ts @@ -188,6 +188,215 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test.each([ + { label: "native raw exec", native: true, name: "exec", input: DECORATED_PATCH }, + { label: "native wrapped exec", native: true, name: "exec", input: WRAPPED_DECORATED_PATCH }, + { label: "native pretty wrapper", native: true, name: "exec", input: JSON.stringify({ input: DECORATED_PATCH }, null, 2) }, + { label: "native escaped-key wrapper", native: true, name: "exec", input: `{ "\\u0069nput": ${JSON.stringify(DECORATED_PATCH)} }` }, + { label: "function apply_patch wrapper alias", native: false, name: "apply_patch", input: WRAPPED_DECORATED_PATCH }, + ])("holds fragmented $label previews and completes with executable patch input", async ({ native, name, input }) => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), budget, new Set(), new Set(["exec"]), + ); + const id = native ? "ctc_patch_lifecycle" : "fc_patch_lifecycle"; + const item = { type: native ? "custom_tool_call" : "function_call", id, call_id: "call_patch_lifecycle", name }; + const payloadKey = native ? "input" : "arguments"; + const eventPrefix = native ? "response.custom_tool_call_input" : "response.function_call_arguments"; + // Independent oracle: do not compute expected source with the production compiler. + const expected = `const result = await tools.apply_patch(${JSON.stringify(CANONICAL_PATCH)});\ntext(result);`; + try { + const added = rewrite(frame("response.output_item.added", { + output_index: 0, item: { ...item, [payloadKey]: "", status: "in_progress" }, + })); + expect(added).toHaveLength(1); + expect(dataPayload(added[0]!).item).toMatchObject({ + type: "custom_tool_call", id: "ctc_patch_lifecycle", call_id: item.call_id, name: "exec", input: "", + }); + // Split both the JSON wrapper and patch markers, including escaped newlines. + for (const delta of input) { + expect(rewrite(frame(`${eventPrefix}.delta`, { output_index: 0, item_id: id, delta }))).toEqual([]); + } + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + const inputDone = rewrite(frame(`${eventPrefix}.done`, { + output_index: 0, item_id: id, [payloadKey]: input, + })); + expect(inputDone).toHaveLength(1); + expect(dataPayload(inputDone[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", item_id: "ctc_patch_lifecycle", input: expected, + }); + if (native) expect(budget.snapshot().currentBytes).toBe(0); + const completedItem = { ...item, [payloadKey]: input, status: "completed" }; + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, item: completedItem })); + expect(itemDone).toHaveLength(1); + expect(dataPayload(itemDone[0]!).item).toMatchObject({ + type: "custom_tool_call", id: "ctc_patch_lifecycle", call_id: item.call_id, name: "exec", input: expected, + }); + expect(dataPayload(itemDone[0]!).item).not.toHaveProperty("arguments"); + expect(budget.snapshot().currentBytes).toBe(0); + const terminal = rewrite(frame("response.completed", { + response: { id: "resp_patch_lifecycle", status: "completed", output: [completedItem] }, + })); + expect(terminal).toHaveLength(1); + const response = dataPayload(terminal[0]!).response as { output: Array> }; + expect(response.output).toHaveLength(1); + expect(response.output[0]).toMatchObject({ + type: "custom_tool_call", id: "ctc_patch_lifecycle", call_id: item.call_id, name: "exec", input: expected, + }); + expect(response.output[0]).not.toHaveProperty("arguments"); + // Execute the client-consumed terminal item once, not each redundant representation. + const calls: unknown[] = []; + const output: unknown[] = []; + const run = new Function("tools", "text", `return (async () => { ${response.output[0]!.input} })();`); + await run({ apply_patch: async (patch: unknown) => { calls.push(patch); return "patched"; } }, + (value: unknown) => output.push(value)); + expect(calls).toEqual([CANONICAL_PATCH]); + expect(output).toEqual(["patched"]); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + rewrite.dispose?.(); + } + }); + + test.each([ + { label: "raw item.done without input.done", input: DECORATED_PATCH, started: true, itemDone: true }, + { label: "wrapped item.done without input.done", input: WRAPPED_DECORATED_PATCH, started: true, itemDone: true }, + { label: "terminal after held deltas without either done event", input: WRAPPED_DECORATED_PATCH, started: true, itemDone: false }, + { label: "raw terminal-only", input: DECORATED_PATCH, started: false, itemDone: false }, + { label: "wrapped terminal-only", input: WRAPPED_DECORATED_PATCH, started: false, itemDone: false }, + ])("repairs native exec at $label completion", ({ input, started, itemDone }) => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), budget, new Set(), new Set(["exec"]), + ); + const item = { type: "custom_tool_call", id: "ctc_missing_done", call_id: "call_missing_done", name: "exec" }; + const expected = `const result = await tools.apply_patch(${JSON.stringify(CANONICAL_PATCH)});\ntext(result);`; + try { + if (started) { + rewrite(frame("response.output_item.added", { + output_index: 0, item: { ...item, input: "", status: "in_progress" }, + })); + // The authoritative item must win even when only a prefix was previewed upstream. + expect(rewrite(frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: item.id, delta: input.slice(0, 12), + }))).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + } + if (itemDone) { + const done = rewrite(frame("response.output_item.done", { + output_index: 0, item: { ...item, input, status: "completed" }, + })); + expect(done).toHaveLength(1); + expect(dataPayload(done[0]!).item).toEqual({ ...item, input: expected, status: "completed" }); + expect(budget.snapshot().currentBytes).toBe(0); + } + const terminal = rewrite(frame("response.completed", { + response: { id: "resp_missing_done", status: "completed", output: [{ ...item, input, status: "completed" }] }, + })); + expect(terminal).toHaveLength(1); + expect(dataPayload(terminal[0]!).response).toMatchObject({ + status: "completed", output: [{ ...item, input: expected, status: "completed" }], + }); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + rewrite.dispose?.(); + } + }); + + test.each([ + { label: "arbitrary JavaScript mentioning a patch", name: "exec", input: `const patch = ${JSON.stringify(DECORATED_PATCH)};\ntext(patch);` }, + { label: "JavaScript block with an ambiguous brace prefix", name: "exec", input: '{ const value = "literal"; text(value); }' }, + { label: "unrelated custom JSON input", name: "render_diagram", input: '{"input":"literal"}' }, + { label: "incomplete patch envelope", name: "exec", input: "*** Begin Patch ***\n*** Add File: note.txt\n+unfinished" }, + { label: "envelope without an operation", name: "exec", input: "*** Begin Patch ***\nnot an operation\n*** End Patch ***" }, + { label: "flat exec catalog", name: "exec", input: DECORATED_PATCH, flat: true }, + { label: "foreign exec namespace", name: "exec", input: DECORATED_PATCH, namespace: "mcp" }, + { label: "foreign helper namespace", name: "apply_patch", input: DECORATED_PATCH, namespace: "mcp" }, + ])("preserves native $label across completion boundaries", ({ name, input, ...options }) => { + const namespace = "namespace" in options ? options.namespace : undefined; + const flat = "flat" in options && options.flat; + const names = new Set(["exec", "render_diagram", "mcp__exec", "mcp__apply_patch"]); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + names, undefined, new Set(), new Set([...names, ...(flat ? ["exec_command"] : [])]), + ); + const item = { + type: "custom_tool_call", id: "ctc_preserved", call_id: "call_preserved", name, + ...(namespace ? { namespace } : {}), + }; + try { + rewrite(frame("response.output_item.added", { + output_index: 0, item: { ...item, input: "", status: "in_progress" }, + })); + let preview = ""; + for (const delta of input) { + for (const block of rewrite(frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: item.id, delta, + }))) { + const payload = dataPayload(block); + expect(payload.type).toBe("response.custom_tool_call_input.delta"); + expect(typeof payload.delta).toBe("string"); + preview += payload.delta; + expect(input.startsWith(preview)).toBe(true); + } + } + // Ordinary JS and unrelated tools retain progressive input; ambiguous exec may be held. + if (input.startsWith("const ") || name === "render_diagram") expect(preview).toBe(input); + const inputDone = rewrite(frame("response.custom_tool_call_input.done", { + output_index: 0, item_id: item.id, input, + })); + expect(inputDone).toHaveLength(1); + expect(dataPayload(inputDone[0]!)).toMatchObject({ type: "response.custom_tool_call_input.done", input }); + const completedItem = { ...item, input, status: "completed" }; + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, item: completedItem })); + expect(itemDone).toHaveLength(1); + expect(dataPayload(itemDone[0]!).item).toEqual(completedItem); + const terminal = rewrite(frame("response.completed", { + response: { id: "resp_preserved", status: "completed", output: [completedItem] }, + })); + expect(terminal).toHaveLength(1); + expect(dataPayload(terminal[0]!).response).toEqual({ + id: "resp_preserved", status: "completed", output: [completedItem], + }); + } finally { + rewrite.dispose?.(); + } + }); + + test.each(["failed", "incomplete", "dispose"])("releases held native exec input on %s without synthesizing success", outcome => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), budget, new Set(), new Set(["exec"]), + ); + try { + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "custom_tool_call", id: "ctc_cancelled", call_id: "call_cancelled", name: "exec", input: "", status: "in_progress" }, + })); + expect(rewrite(frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: "ctc_cancelled", delta: WRAPPED_DECORATED_PATCH, + }))).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + if (outcome === "dispose") { + expect(rewrite.dispose?.()).toBeUndefined(); + } else { + const terminal = frame(`response.${outcome}`, { + response: { id: "resp_cancelled", status: outcome, output: [] }, + }); + expect(rewrite(terminal)).toEqual([terminal]); + } + expect(budget.snapshot().currentBytes).toBe(0); + // Late provider bytes cannot reopen a cancelled collector or flush a successful item. + const lateDelta = frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: "ctc_cancelled", delta: "late", + }); + expect(rewrite(lateDelta)).toEqual([lateDelta]); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + rewrite.dispose?.(); + } + }); + test("restores streamed exec_command arguments through unified exec", () => { const rewrite = createRoutedCustomToolRestoreBlockRewrite( new Set(["exec"]), diff --git a/tests/responses/responses-forward-incomplete-quota.test.ts b/tests/responses/responses-forward-incomplete-quota.test.ts new file mode 100644 index 0000000000..0d93e3d5e4 --- /dev/null +++ b/tests/responses/responses-forward-incomplete-quota.test.ts @@ -0,0 +1,337 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig } from "../../src/config"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexAccountCooldownUntil } from "../../src/codex/routing"; +import type { CodexAuthContext } from "../../src/codex/auth-context"; +import { codexForwardTerminalOutcomeRecorder } from "../../src/server/responses/core"; +import { + httpStatusForRequestLogTerminal, + inspectResponseLogSsePayload, + type RequestLogContext, +} from "../../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountQuota, updateAccountQuota } from "../../src/codex/quota"; +import { + isModelHealthBlocked, + resetSubagentModelFallbackStateForTests, + setSubagentQuotaPrimeForTests, +} from "../../src/codex/subagent-model-fallback"; +import { handleResponses } from "../../src/server/responses"; +import type { HandleResponsesOptions } from "../../src/server/responses/core"; +import { isEagerRelaySseResponse } from "../../src/server/relay"; +import { sendResponseToWebSocket, type WsData } from "../../src/server/ws-bridge"; +import { installIsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget"; + +const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", +}; + +function auth(fixedAccount = false): CodexAuthContext { + return { + kind: "pool", accountId: "incomplete-quota-fixture", accessToken: "test-token", + chatgptAccountId: "test-account", generation: 1, + writerGeneration: captureConfigGeneration(), fixedAccount, + }; +} + +function inspect(response: Record): RequestLogContext { + const log: RequestLogContext = { model: "gpt-test", provider: "openai" }; + inspectResponseLogSsePayload(log, JSON.stringify({ type: "response.incomplete", response })); + return log; +} + +afterEach(() => clearCodexUpstreamHealth()); + +describe("incomplete quota terminal attribution", () => { + for (const response of [ + { incomplete_details: { reason: "usage_limit_reached" } }, + { incomplete_details: { reason: "rate_limit_exceeded" } }, + { incomplete_details: { reason: "insufficient_quota" } }, + { error: { code: "usage_limit_reached" } }, + { error: { type: "rate_limit_error" } }, + { incomplete_details: { message: "The usage limit has been reached" } }, + ]) { + test(`SSE inspection records quota health for ${JSON.stringify(response)}`, () => { + const log = inspect(response); + expect(log.terminalHttpStatus).toBe(429); + expect(httpStatusForRequestLogTerminal("incomplete", log)).toBe(429); + const record = codexForwardTerminalOutcomeRecorder(getDefaultConfig(), auth(), provider, "gpt-test", log); + expect(record).toBeDefined(); + record!("incomplete"); + expect(getCodexAccountCooldownUntil("incomplete-quota-fixture")).toBeGreaterThan(Date.now()); + }); + } + + for (const reason of ["max_output_tokens", "content_filter", "steered", "upstream_stall_timeout", "unknown"]) { + test(`ordinary ${reason} incomplete does not cool the account`, () => { + const log = inspect({ incomplete_details: { reason } }); + expect(log.terminalHttpStatus).toBeUndefined(); + codexForwardTerminalOutcomeRecorder(getDefaultConfig(), auth(), provider, "gpt-test", log)!("incomplete"); + expect(getCodexAccountCooldownUntil("incomplete-quota-fixture")).toBeNull(); + }); + } + + test("policy refusal takes precedence over conflicting quota details", () => { + const log = inspect({ + error: { code: "cyber_policy", message: "blocked" }, + incomplete_details: { reason: "usage_limit_reached" }, + }); + expect(log.terminalHttpStatus).toBe(400); + codexForwardTerminalOutcomeRecorder(getDefaultConfig(), auth(), provider, "gpt-test", log)!("incomplete"); + expect(getCodexAccountCooldownUntil("incomplete-quota-fixture")).toBeNull(); + }); + + test("a generic transport override does not erase captured quota evidence", () => { + const log = inspect({ incomplete_details: { reason: "usage_limit_reached" } }); + codexForwardTerminalOutcomeRecorder(getDefaultConfig(), auth(), provider, "gpt-test", log)!("incomplete", 502); + expect(getCodexAccountCooldownUntil("incomplete-quota-fixture")).toBeGreaterThan(Date.now()); + }); + + for (const status of [402, 429]) { + test(`parent terminal override ${status} reaches the child recorder`, () => { + // Combo/WS inspection owns the parent log, while this recorder closes over a child log. + const child: RequestLogContext = { model: "gpt-test", provider: "openai" }; + codexForwardTerminalOutcomeRecorder(getDefaultConfig(), auth(true), provider, "gpt-test", child)!("incomplete", status); + expect(getCodexAccountCooldownUntil("incomplete-quota-fixture")).toBeGreaterThan(Date.now()); + }); + } +}); + +type ReporterPath = "parent-recorder" | "guarded-ws" | "native-sse"; + +// Drive the endpoint and its real transport/inspection owners. Only the external +// Codex destination is redirected; the recorder and spawn health store stay real. +async function exerciseSpawnReporter(path: ReporterPath): Promise { + const realFetch = globalThis.fetch; + const RealWebSocket = globalThis.WebSocket; + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-incomplete-quota-")); + const codexHome = installIsolatedCodexHome("ocx-incomplete-quota-codex-"); + process.env.OPENCODEX_HOME = home; + const accountId = "incomplete-quota-endpoint"; + const model = "gpt-test"; + const config: OcxConfig = { + ...getDefaultConfig(), + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + streamMode: "legacy-tee", + providers: { openai: { ...provider, codexAccountMode: "pool" } }, + codexAccounts: [{ + id: accountId, email: "quota@example.test", isMain: false, + chatgptAccountId: "acct-quota-endpoint", + }], + activeCodexAccountId: accountId, + }; + let reason = "max_output_tokens"; + let httpDispatches = 0; + let wsDispatches = 0; + const terminal = () => ({ + type: "response.incomplete", + response: { + id: `resp-${path}-${reason}`, object: "response", status: "incomplete", + model, output: [], incomplete_details: { reason }, + }, + }); + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req, server) { + if (req.headers.get("upgrade") === "websocket" && server.upgrade(req)) return; + httpDispatches++; + return new Response(`event: response.incomplete\ndata: ${JSON.stringify(terminal())}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + }, + websocket: { + message(ws, message) { + const request = JSON.parse(String(message)); + expect(request.type).toBe("response.create"); + expect(request.model).toBe(model); + wsDispatches++; + ws.send(JSON.stringify(terminal())); + }, + }, + }); + let logCtx: RequestLogContext = { model: "", provider: "" }; + const resolved: { auth?: CodexAuthContext } = {}; + let parentTerminal: string | undefined; + let eager: boolean | undefined; + let registered: Parameters>[0]; + let reportTerminal: (status: string) => void = () => {}; + let rejectTerminal: (error: unknown) => void = () => {}; + const options = (): HandleResponsesOptions => ({ + // Use the existing runtime seam: HTTP fixtures must not accidentally select + // WS on a newer Bun, and the WS fixture must exercise the guarded relay. + codexWsRuntimeIdentity: path === "guarded-ws" ? "1.4.0" : "1.3.14", + recordTerminalOutcomes: path !== "parent-recorder", + onCodexAuthContextResolved: context => { resolved.auth = context; }, + setTerminalOutcomeRecorder: recorder => { registered = recorder; }, + onNativePassthroughTerminal: status => { + if (path === "parent-recorder") parentTerminal = status; + else reportTerminal(status); + }, + }); + const endpoint = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req, server) { + if (path === "parent-recorder" && server.upgrade(req, { data: { headers: req.headers } })) return; + const response = await handleResponses(req, config, logCtx, options()); + eager = isEagerRelaySseResponse(response); + return response; + }, + websocket: { + async message(ws, message) { + try { + const payload = JSON.parse(String(message)); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: ws.data.headers, + body: JSON.stringify({ ...payload, stream: true }), + }), config, logCtx, { ...options(), inboundTransport: "websocket" }); + expect(response.status).toBe(200); + expect(registered).toBeDefined(); + // Same ownership as server/index.ts: the bridge inspects first, then + // calls the recorder registered by core. Inject 502 only at this + // existing override seam to prove it cannot erase captured typed 429. + await sendResponseToWebSocket(ws, response, () => true, { + onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload), + onTerminal: status => registered!(status, 502), + }); + expect(parentTerminal).toBe("incomplete"); + reportTerminal(parentTerminal!); + } catch (error) { + rejectTerminal(error); + } + }, + }, + }); + let client: WebSocket | undefined; + try { + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountQuota(); + resetSubagentModelFallbackStateForTests(); + setSubagentQuotaPrimeForTests(async () => {}); + saveCodexAccountCredential(accountId, { + accessToken: "endpoint-token", refreshToken: "endpoint-refresh", + expiresAt: Date.now() + 60 * 60_000, chatgptAccountId: "acct-quota-endpoint", + }); + updateAccountQuota(accountId, 10); + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin === "https://chatgpt.com" && url.pathname === "/backend-api/codex/responses") { + return realFetch(new URL("/responses", upstream.url), init); + } + if (url.origin === endpoint.url.origin) return realFetch(input, init); + throw new Error(`Unexpected quota fixture fetch: ${url.origin}${url.pathname}`); + }) as typeof fetch; + globalThis.WebSocket = new Proxy(RealWebSocket, { + construct(target, args) { + const url = new URL(String(args[0])); + if (url.origin === "wss://chatgpt.com" && url.pathname === "/backend-api/codex/responses") { + return Reflect.construct(target, [upstream.url.toString().replace("http:", "ws:"), ...args.slice(1)]); + } + throw new Error(`Unexpected quota fixture WebSocket: ${url.origin}${url.pathname}`); + }, + }); + + // Ordinary incomplete comes first, so its negative assertion cannot be + // masked by clearing health produced by the quota terminal. + for (const quota of [false, true]) { + reason = quota ? "usage_limit_reached" : "max_output_tokens"; + logCtx = { model: "", provider: "" }; + resolved.auth = undefined; + parentTerminal = undefined; + registered = undefined; + expect(isModelHealthBlocked(model, config, accountId)).toBe(false); + let timer: ReturnType | undefined; + const reported = new Promise((resolve, reject) => { + reportTerminal = resolve; + rejectTerminal = reject; + timer = setTimeout(() => reject(new Error(`${path}: terminal reporter did not run`)), INTERNAL_DEADLINE_MS); + }); + const headers = { + "content-type": "application/json", authorization: "Bearer inbound-fixture", + "x-openai-subagent": "collab_spawn", + }; + const body = { model, input: "hello", stream: true }; + try { + const deliver = async () => { + if (path === "parent-recorder") { + // Real downstream WS; construction bypasses only our upstream redirect. + client = new RealWebSocket(endpoint.url.toString().replace("http:", "ws:") + "v1/responses", { + headers, + } as unknown as string[]); + client.addEventListener("open", () => client!.send(JSON.stringify({ type: "response.create", ...body }))); + client.addEventListener("error", () => rejectTerminal(new Error("endpoint WebSocket failed"))); + } else { + const response = await realFetch(new URL("/v1/responses", endpoint.url), { + method: "POST", headers, body: JSON.stringify(body), + signal: AbortSignal.timeout(INTERNAL_DEADLINE_MS), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"status":"incomplete"'); + expect(eager).toBe(path === "guarded-ws"); + } + }; + const [status] = await Promise.all([reported, deliver()]); + expect(status).toBe("incomplete"); + expect(resolved).toMatchObject({ auth: { kind: "pool", accountId } }); + expect(resolved).not.toMatchObject({ auth: { fixedAccount: true } }); + expect(logCtx.terminalHttpStatus).toBe(quota ? 429 : undefined); + // This is the actual store read by selectAvailableSubagentModel, separate + // from pool cooldown: removing any one reporter's spawn write fails here. + expect(isModelHealthBlocked(model, config, accountId)).toBe(quota); + expect(isModelHealthBlocked(model, config, "another-account")).toBe(false); + if (quota) expect(getCodexAccountCooldownUntil(accountId)).toBeGreaterThan(Date.now()); + else expect(getCodexAccountCooldownUntil(accountId)).toBeNull(); + } finally { + clearTimeout(timer); + client?.close(); + client = undefined; + } + } + expect(wsDispatches).toBe(path === "guarded-ws" ? 2 : 0); + expect(httpDispatches).toBe(path === "guarded-ws" ? 0 : 2); + } finally { + client?.close(); + await endpoint.stop(true); + await upstream.stop(true); + globalThis.fetch = realFetch; + globalThis.WebSocket = RealWebSocket; + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountQuota(); + resetSubagentModelFallbackStateForTests(); + codexHome.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } +} + +describe("incomplete quota endpoint reporter wiring", () => { + test("registered parent reporter preserves typed quota over 502 and updates spawn health", async () => { + await exerciseSpawnReporter("parent-recorder"); + }, { timeout: SERVER_BUDGET_MS }); + + test("guarded native WS reporter updates spawn health only for quota incomplete", async () => { + await exerciseSpawnReporter("guarded-ws"); + }, { timeout: SERVER_BUDGET_MS }); + + // core always applies a field-backfill rewrite; win32 therefore forces eager + // before the tee reporter regardless of streamMode (Bun#32111). Do not label + // that eager path as native-SSE reporter coverage on Windows. + test.skipIf(process.platform === "win32")("regular native SSE reporter updates spawn health only for quota incomplete", async () => { + await exerciseSpawnReporter("native-sse"); + }, { timeout: SERVER_BUDGET_MS }); +}); diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts new file mode 100644 index 0000000000..2f9343cd3d --- /dev/null +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -0,0 +1,410 @@ +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { describe, expect, test } from "bun:test"; +import { + collectFunctionCallRepairSchemas, + repairFunctionCalls, + repairFunctionCallsInJson, +} from "../../src/responses/function-call-compat"; +import { createResponsesFunctionToolRepairBlockRewrite } from "../../src/server/responses-function-tool-repair"; +import { createTranslatorBudget, TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; +import { sseDataPayload } from "../../src/server/sse-payload-rewrite"; +import { currentTurnWireToolCatalogBody } from "../../src/server/responses-undeclared-tool-guard"; + +const parameters = { type: "object", properties: { + cell_id: { type: "string" }, yield_time_ms: { type: "integer" }, + union: { type: ["number", "string"] }, +} }; +const wait = { type: "function", name: "wait", parameters }; +const schemas = collectFunctionCallRepairSchemas({ tools: [wait, { type: "function", name: "get_state" }] }); +const raw = '{"cell_id":4,"yield_time_ms":120000.0}'; +const canonical = '{"cell_id":"4","yield_time_ms":120000}'; + +function item(argumentsText: unknown = raw, overrides: Record = {}) { + return { type: "function_call", id: "fc_one", call_id: "call_one", name: "wait", arguments: argumentsText, status: "completed", ...overrides }; +} +function frame(type: string, fields: Record) { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...fields })}`; +} +function payload(block: string): Record { + return JSON.parse(sseDataPayload(block)!) as Record; +} +function repairedItem(argumentsText: unknown, overrides: Record = {}) { + return repairFunctionCalls(item(argumentsText, overrides), schemas).value; +} + +describe("original function declaration authority", () => { + const groups = [ + { type: "namespace", name: "left", tools: [wait] }, + { type: "namespace", name: "right", tools: [{ ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }] }, + { type: "custom", name: "exec", description: "JavaScript" }, + { type: "web_search" }, + ]; + + test("preserves schema references and distinct same-inner-name identities", () => { + const map = collectFunctionCallRepairSchemas({ tools: groups }); + expect([...map.keys()]).toEqual(["left__wait", "right__wait"]); + expect(map.get("left__wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item('{"cell_id":4}', { namespace: "left" }), map).value) + .toMatchObject({ arguments: '{"cell_id":"4"}' }); + expect(repairFunctionCalls(item('{"cell_id":4}', { namespace: "right" }), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw, { name: "left__wait", namespace: "right" }), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw, { name: "left__wait", namespace: "functions" }), map).changed).toBe(false); + }); + + test.each([ + [{ type: "function", namespace: "left", name: "wait" }, ["left__wait"]], + [{ type: "function", name: "left__wait" }, ["left__wait"]], + [{ type: "function", name: "left.wait" }, ["left__wait"]], + [{ type: "function", name: "wait" }, []], + [{ type: "custom", namespace: "left", name: "wait" }, []], + [{ type: "function", namespace: "right", name: "left__wait" }, []], + [{ type: "function", namespace: "", name: "left__wait" }, []], + [{ type: "function", namespace: null, name: "left__wait" }, []], + [{ type: "file_search", name: "left__wait" }, []], + ["none", []], + [null, []], + [{ type: "allowed_tools", tools: [{ type: "function", namespace: "right", name: "wait" }, { type: "custom", name: "left__wait" }] }, ["right__wait"]], + ])("honors exact selector %j", (tool_choice, keys) => { + expect([...collectFunctionCallRepairSchemas({ tools: groups, tool_choice }).keys()]).toEqual(keys); + }); + + test("reserved functions remain bare and support explicit namespace selectors", () => { + const map = collectFunctionCallRepairSchemas({ + tools: [{ type: "namespace", name: "functions", tools: [wait] }], + tool_choice: { type: "function", namespace: "functions", name: "wait" }, + }); + expect(map.get("wait")).toMatchObject({ name: "wait", parameters }); + expect(map.get("wait")).not.toHaveProperty("namespace"); + expect(repairFunctionCalls(item(raw, { namespace: "functions" }), map).value).toMatchObject({ arguments: canonical }); + }); + + test("reads supplied current-turn groups, not declarations nested in replay messages or metadata", () => { + const map = collectFunctionCallRepairSchemas({ + input: [{ type: "additional_tools", tools: [wait] }, { type: "message", tools: [{ type: "function", name: "old" }] }], + metadata: { tools: [{ type: "function", name: "shadow" }] }, + }); + expect([...map.keys()]).toEqual(["wait"]); + expect(collectFunctionCallRepairSchemas({ input: [{ type: "message", tools: [wait] }] }).size).toBe(0); + }); + + test("conflicting same-wire schemas cannot win by declaration order", () => { + const other = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + for (const tools of [[wait, other], [other, wait], [wait, { type: "custom", name: "wait" }]]) { + expect(collectFunctionCallRepairSchemas({ tools }).size).toBe(0); + } + }); + + test.each([false, true])("equivalent duplicate schemas ignore object key order (loaded=%s)", loaded => { + const first = { type: "object", properties: { + cell_id: { type: "string", description: "Cell identifier" }, + yield_time_ms: { type: "integer", minimum: 0 }, + }, required: ["cell_id", "yield_time_ms"], additionalProperties: false }; + const reordered = { additionalProperties: false, required: ["cell_id", "yield_time_ms"], properties: { + yield_time_ms: { minimum: 0, type: "integer" }, + cell_id: { description: "Cell identifier", type: "string" }, + }, type: "object" }; + for (const [original, duplicate] of [[first, reordered], [reordered, first]]) { + const explicit = { ...wait, parameters: original }; + const repeated = { ...wait, parameters: duplicate }; + const body = loaded + ? { tools: [explicit], input: [{ type: "tool_search_output", tools: [repeated] }] } + : { tools: [explicit, repeated] }; + const before = JSON.stringify(body); + const map = collectFunctionCallRepairSchemas(body); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(original); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(JSON.stringify(body)).toBe(before); + } + }); + + test("duplicate schema comparison keeps required and nested type arrays ordered", () => { + const original = { ...parameters, required: ["cell_id", "yield_time_ms"] }; + const differentArrays = [ + [original, { ...original, required: ["yield_time_ms", "cell_id"] }], + [ + { ...original, properties: { ...original.properties, union: { type: ["number", "string"] } } }, + { ...original, properties: { ...original.properties, union: { type: ["string", "number"] } } }, + ], + ]; + for (const [baseline, changed] of differentArrays) { + for (const pair of [[baseline, changed], [changed, baseline]]) { + const map = collectFunctionCallRepairSchemas({ tools: pair.map(parameters => ({ ...wait, parameters })) }); + expect(map.size).toBe(0); + const completed = item(); + expect(repairFunctionCalls(completed, map)).toEqual({ value: completed, changed: false }); + } + } + }); + + test("loaded tool_search_output functions retain their original schemas", () => { + const body = { input: [{ type: "tool_search_output", tools: [wait, { type: "custom", name: "exec" }] }] }; + const before = JSON.stringify(body); + const map = collectFunctionCallRepairSchemas(body); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(JSON.stringify(body)).toBe(before); + }); + + test.each([ + [{ type: "function", name: "left.wait" }, ["left__wait"]], + [{ type: "function", namespace: "right", name: "wait" }, ["right__wait"]], + [{ type: "function", name: "wait" }, []], + [{ type: "custom", name: "left.wait" }, []], + [{ type: "function", namespace: "right", name: "left__wait" }, []], + [{ type: "allowed_tools", tools: [{ type: "function", name: "right.wait" }] }, ["right__wait"]], + ["none", []], + ])("loaded namespace declarations honor selector %j", (tool_choice, keys) => { + const body = { tool_choice, input: [{ type: "tool_search_output", tools: groups }] }; + expect([...collectFunctionCallRepairSchemas(body).keys()]).toEqual(keys); + }); + + test("replay-trimmed loaded definitions cannot grant historical schema authority", () => { + const historical = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + const body = { input: [ + { type: "tool_search_output", tools: [historical, { type: "function", name: "old_only" }] }, + { type: "message", role: "user", content: [] }, + { type: "tool_search_output", tools: [wait] }, + ] }; + const map = collectFunctionCallRepairSchemas(currentTurnWireToolCatalogBody(body, 2)); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(collectFunctionCallRepairSchemas(currentTurnWireToolCatalogBody(body, 3)).size).toBe(0); + }); + + test("loaded and explicit conflicting declarations remain fail-closed in either order", () => { + const conflict = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + for (const [explicit, loaded] of [[wait, conflict], [conflict, wait]]) { + expect(collectFunctionCallRepairSchemas({ tools: [explicit], input: [{ type: "tool_search_output", tools: [loaded] }] }).size).toBe(0); + } + }); + + test("namespace wait does not inherit bare wait number-field exceptions", () => { + const numberWait = { ...wait, parameters: { type: "object", properties: { yield_time_ms: { type: "number" } } } }; + const map = collectFunctionCallRepairSchemas({ tools: [numberWait, { type: "namespace", name: "remote", tools: [numberWait] }] }); + expect(repairFunctionCalls(item('{"yield_time_ms":1000.0}'), map).value).toMatchObject({ arguments: '{"yield_time_ms":1000}' }); + expect(repairFunctionCalls(item('{"yield_time_ms":1000.0}', { namespace: "remote" }), map).changed).toBe(false); + }); +}); + +describe("pure function completion repair", () => { + test("repairs integer/string arguments and explicit completed empty arguments", () => { + expect(repairedItem(raw)).toEqual(item(canonical)); + expect(repairedItem("", { name: "get_state" })).toEqual(item("{}", { name: "get_state" })); + const missing = { type: "function_call", name: "get_state", status: "completed" }; + expect(repairFunctionCalls(missing, schemas).value).toBe(missing); + }); + + test.each([" ", "{", '{"cell_id":4.5}', '{"yield_time_ms":1.5}', '{"union":4.0}', + '{"cell_id":9007199254740993}', '{"cell_id":4,"unknown":9007199254740993}', '{"cell_id":4,"unknown":1e400}']) + ("preserves invalid/disagreeing/unsafe payload %s", argumentsText => { + const value = item(argumentsText); + expect(repairFunctionCalls(value, schemas)).toEqual({ value, changed: false }); + }); + + test("preserves custom/helper/unknown calls, previews and failed/incomplete snapshots", () => { + for (const overrides of [{ type: "custom_tool_call", input: raw }, { name: "exec_command" }, { name: "exec" }, + { status: "in_progress" }, { status: "incomplete" }, { namespace: "missing" }]) { + const value = item(raw, overrides); + expect(repairFunctionCalls(value, schemas).value).toBe(value); + } + for (const status of ["failed", "incomplete", "in_progress"]) { + const value = { status, output: [item()] }; + expect(repairFunctionCalls(value, schemas).value).toBe(value); + } + const added = { type: "response.output_item.added", item: item("") }; + expect(repairFunctionCalls(added, schemas).value).toBe(added); + }); + + test("repairs JSON/item/terminal completions without visiting metadata or adding status", () => { + const shadow = item(); + const value = { status: "completed", output: [item()], metadata: { shadow } }; + const json = repairFunctionCallsInJson(JSON.stringify(value), schemas); + expect(JSON.parse(json)).toEqual({ ...value, output: [item(canonical)] }); + expect(repairFunctionCallsInJson(json, schemas)).toBe(json); + expect(repairFunctionCallsInJson("not JSON", schemas)).toBe("not JSON"); + const noStatus = { ...item("", { name: "get_state" }), status: undefined }; + expect(repairFunctionCalls({ type: "response.output_item.done", item: noStatus }, schemas).value) + .toEqual({ type: "response.output_item.done", item: { ...noStatus, arguments: "{}" } }); + expect(repairFunctionCalls({ type: "response.completed", response: { output: [noStatus] } }, schemas).value) + .toEqual({ type: "response.completed", response: { output: [{ ...noStatus, arguments: "{}" }] } }); + expect(repairFunctionCalls(value, new Map()).value).toBe(value); + }); +}); + +describe("native function completion SSE", () => { + test("keeps previews exact and repairs every authoritative completion without synthetic deltas", () => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + const added = frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) }); + expect(rewrite(added)).toEqual([added]); + const delta = frame("response.function_call_arguments.delta", { item_id: "fc_one", delta: raw }); + expect(rewrite(delta)).toEqual([delta]); + const done = rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw })); + expect(done).toHaveLength(1); + expect(payload(done[0]!)).toMatchObject({ type: "response.function_call_arguments.done", arguments: canonical }); + expect(budget.snapshot().currentBytes).toBe(0); + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, item: item() })); + expect(payload(itemDone[0]!).item).toEqual(item(canonical)); + const terminal = rewrite(frame("response.completed", { response: { status: "completed", output: [item()] } })); + expect(payload(terminal[0]!).response).toEqual({ status: "completed", output: [item(canonical)] }); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test.each(["response.output_item.done", "response.completed"])("no-arg %s works without arguments.done", type => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + const call = item("", { name: "get_state" }); + const fields = type === "response.completed" ? { response: { status: "completed", output: [call] } } : { output_index: 0, item: call }; + const result = rewrite(frame(type, fields)); + expect(result).toHaveLength(1); + expect(JSON.stringify(payload(result[0]!))).toContain('"arguments":"{}"'); + } finally { rewrite.dispose?.(); } + }); + + test("correlates early id-less completions and interleaved calls without mixing schemas", () => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + expect(rewrite(frame("response.function_call_arguments.done", { output_index: 1, arguments: "" }))).toEqual([]); + rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + expect(payload(rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }))[0]!)) + .toMatchObject({ arguments: canonical }); + const output = rewrite(frame("response.output_item.added", { output_index: 1, item: item("", { name: "get_state", id: "fc_two", status: "in_progress" }) })); + expect(output.map(block => payload(block).type)).toEqual(["response.output_item.added", "response.function_call_arguments.done"]); + expect(payload(output[1]!)).toMatchObject({ arguments: "{}", output_index: 1, item_id: "fc_two" }); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test.each([raw, canonical])("index-only completion gets downstream identity even when arguments stay unchanged: %s", argumentsText => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + expect(rewrite(frame("response.function_call_arguments.done", { output_index: 0, arguments: argumentsText }))).toEqual([]); + const output = rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + const calls = new Map(); + for (const block of output) { + const event = payload(block); + if (event.type === "response.output_item.added") { + const call = event.item as { id: string; arguments: string }; + calls.set(call.id, call.arguments); + } else if (event.type === "response.function_call_arguments.done") { + expect(typeof event.item_id).toBe("string"); + expect(calls.has(event.item_id as string)).toBe(true); + calls.set(event.item_id as string, event.arguments as string); + } + } + expect([...calls]).toEqual([["fc_one", canonical]]); + } finally { rewrite.dispose?.(); } + }); + + test("terminal snapshots resolve early completions; authoritative arguments beat previews", () => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + const preview = frame("response.function_call_arguments.delta", { item_id: "fc_one", delta: '{"cell_id":999}' }); + expect(rewrite(preview)).toEqual([preview]); + expect(rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }))).toEqual([]); + const output = rewrite(frame("response.completed", { response: { output: [item()] } })); + expect(output).toHaveLength(2); + expect(payload(output[0]!)).toMatchObject({ arguments: canonical }); + expect(payload(output[1]!).response).toEqual({ output: [item(canonical)] }); + } finally { rewrite.dispose?.(); } + }); + + test.each(["response.failed", "response.incomplete", "response.cancelled"])("%s flushes unknown completions unchanged and frees retention", type => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + const early = frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }); + expect(rewrite(early)).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + const terminal = frame(type, { response: { output: [item()] } }); + expect(rewrite(terminal)).toEqual([early, terminal]); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test("charges identity metadata and early frames, releasing even on overflow or disposal", () => { + for (const early of [false, true]) { + const budget = createTranslatorBudget({ maxTurnBytes: 240 }); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + if (early) { + expect(() => rewrite(frame("response.function_call_arguments.done", { item_id: "unknown", arguments: "x".repeat(300) }))) + .toThrow(TranslatorBudgetExceededError); + } + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + } + }); + + test("empty forward map leaves every frame byte-identical", () => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(new Map()); + const block = frame("response.output_item.done", { output_index: 0, item: item() }); + expect(rewrite(block)).toEqual([block]); + }); +}); + +test("native Responses JSON/SSE and replay share the original function schema repair", async () => { + const originalFetch = globalThis.fetch; + const expected = '{"cell_id":"4","yield_time_ms":120000}'; + const output = { type: "function_call", id: "fc_wait", call_id: "call_wait", name: "wait", arguments: '{"cell_id":4,"yield_time_ms":120000.0}', status: "completed" }; + const tools = [{ type: "function", name: "wait", parameters: { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" } } } }]; + const config = { + port: 0, defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-responses", baseUrl: "https://function-parity.invalid/v1", authMode: "key", apiKey: "fixture-key" } }, + } as OcxConfig; + let activeId = ""; + let captured: { input?: Array> } | undefined; + const sse = (type: string, payload: object) => `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (!url.startsWith("https://function-parity.invalid/")) throw new Error("unexpected parity fixture destination"); + const body = JSON.parse(String(init?.body)); + captured = body; + const response = { id: activeId, status: "completed", output: [output] }; + return body.stream ? new Response([ + sse("response.output_item.added", { output_index: 0, item: { ...output, arguments: "", status: "in_progress" } }), + sse("response.function_call_arguments.delta", { output_index: 0, item_id: output.id, delta: output.arguments }), + sse("response.function_call_arguments.done", { output_index: 0, item_id: output.id, arguments: output.arguments }), + sse("response.output_item.done", { output_index: 0, item: output }), + sse("response.completed", { response }), "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }) : Response.json(response); + }) as typeof fetch; + try { + for (const stream of [false, true]) { + activeId = `resp_fn_${crypto.randomUUID()}`; + const request = (extra: object = {}) => new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/grok-probe", stream, input: [{ role: "user", content: "synthetic" }], tools, ...extra }), + }); + const response = await handleResponses(request(), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const raw = await response.text(); + if (stream) { + const events = raw.split("\n").filter(line => line.startsWith("data:") && !line.includes("[DONE]")).map(line => JSON.parse(line.slice(5))); + expect(events.find(event => event.type === "response.function_call_arguments.done")?.arguments).toBe(expected); + expect(events.find(event => event.type === "response.output_item.done")?.item.arguments).toBe(expected); + expect(events.find(event => event.type === "response.completed")?.response.output[0].arguments).toBe(expected); + } else expect(JSON.parse(raw).output[0].arguments).toBe(expected); + const previous = activeId; + activeId = `resp_fn_followup_${crypto.randomUUID()}`; + const followup = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: "call_wait", output: "done" }] }), config, { model: "", provider: "" }); + await followup.text(); + expect(captured?.input?.find(item => item.type === "function_call" && item.call_id === "call_wait")?.arguments).toBe(expected); + } + } finally { globalThis.fetch = originalFetch; } +}); diff --git a/tests/responses/responses-opaque-blob-recovery.test.ts b/tests/responses/responses-opaque-blob-recovery.test.ts index 967909c154..cf26431381 100644 --- a/tests/responses/responses-opaque-blob-recovery.test.ts +++ b/tests/responses/responses-opaque-blob-recovery.test.ts @@ -15,10 +15,14 @@ import { import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { markBodyNonPersistable, rememberResponseState, previousResponseProviderState } from "../../src/responses/state"; const originalFetch = globalThis.fetch; const originalOpenCodexHome = process.env.OPENCODEX_HOME; const BLOB = "provider-minted-opaque-state"; +// Synthetic Fernet-shaped data must survive the outbound ciphertext shape gate. +const FUNCTION_OUTPUT_BLOB = `g${"A".repeat(127)}`; +const FUNCTION_OUTPUT_DECRYPT_MESSAGE = "Encrypted function output content could not be decrypted or decoded."; const OPENAI_BLOB_ERROR = JSON.stringify({ error: { message: "The encrypted content could not be verified.", @@ -34,6 +38,13 @@ const CHATGPT_UNVERIFIABLE_BLOB_ERROR = JSON.stringify({ code: null, }, }); +const CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR = JSON.stringify({ + error: { + message: FUNCTION_OUTPUT_DECRYPT_MESSAGE, + type: "server_error", + code: null, + }, +}); const XAI_DECODE_ERROR = JSON.stringify({ code: "invalid-argument", error: "Could not decode the compaction blob: invalid payload", @@ -91,6 +102,58 @@ function serializedOutboundWithBlob(): string { return JSON.stringify({ model: "model-a", input: reasoningReplayInput() }); } +function functionOutputReplayInput(): Array> { + return [ + { + type: "function_call", + call_id: "call-encrypted-output", + name: "browser_capture", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call-encrypted-output", + output: [ + { type: "encrypted_content", encrypted_content: FUNCTION_OUTPUT_BLOB }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ], + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]; +} + +function serializedOutboundWithEncryptedFunctionOutput(): string { + return JSON.stringify({ model: "model-a", input: functionOutputReplayInput() }); +} + +function agentMessageReplayInput(): Array> { + return [ + { + type: "agent_message", + author: "/root/child_task", + recipient: "/root", + content: [ + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "encrypted_content", encrypted_content: FUNCTION_OUTPUT_BLOB }, + ], + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]; +} + +function serializedOutboundWithEncryptedAgentMessage(): string { + return JSON.stringify({ model: "model-a", input: agentMessageReplayInput() }); +} + function config(): OcxConfig { return { defaultProvider: "first", @@ -137,6 +200,105 @@ function requestWithIdentityHeaders( }); } +function functionOutputRequest(stream = false): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": "thread-encrypted-function-output", + }, + body: JSON.stringify({ + model: "first/model-a", + stream, + store: false, + input: functionOutputReplayInput(), + }), + }); +} + +function agentMessageRequest(stream = false): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": "thread-encrypted-agent-message", + }, + body: JSON.stringify({ + model: "first/model-a", + stream, + store: false, + input: agentMessageReplayInput(), + }), + }); +} + +function decryptStreamResponse(wire: string, contentType: string | null): Response { + // A string body would implicitly add text/plain even when headers are omitted. + const response = new Response(new TextEncoder().encode(wire), { + status: 200, + ...(contentType === null ? {} : { headers: { "content-type": contentType } }), + }); + expect(response.headers.get("content-type")).toBe(contentType); + return response; +} + +function streamedFunctionOutputDecryptFailure(contentType: string | null = "text/event-stream"): Response { + const failed = { + type: "response.failed", + response: { + id: "resp-function-output-failed", + status: "failed", + error: { + message: FUNCTION_OUTPUT_DECRYPT_MESSAGE, + type: "server_error", + code: "upstream_server_error", + }, + }, + }; + return decryptStreamResponse(`event: response.failed\ndata: ${JSON.stringify(failed)}\n\ndata: [DONE]\n\n`, contentType); +} + +// The observed ChatGPT production shape: response.created, then a bare error +// event carrying the decryption rejection, then EOF with no terminal event. +function streamedFunctionOutputDecryptErrorEvent( + flat = false, + contentType: string | null = "text/event-stream", +): Response { + const created = { + type: "response.created", + response: { id: "resp-function-output-error-event", status: "in_progress" }, + }; + const error = { + type: "server_error", + code: "upstream_server_error", + message: FUNCTION_OUTPUT_DECRYPT_MESSAGE, + }; + const errorEvent = flat ? { ...error, type: "error" } : { + type: "error", + error, + }; + return decryptStreamResponse( + `event: response.created\ndata: ${JSON.stringify(created)}\n\nevent: error\ndata: ${JSON.stringify(errorEvent)}\n\n`, + contentType, + ); +} + +function streamedSuccess(id: string): Response { + const completed = { + type: "response.completed", + response: { + id, + status: "completed", + model: "model-a", + output: [], + }, + }; + return new Response(`event: response.completed\ndata: ${JSON.stringify(completed)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + function rejection(body = OPENAI_BLOB_ERROR): Response { return new Response(body, { status: 400, @@ -202,9 +364,546 @@ describe("opaque blob recovery trigger", () => { }), })).toBe(false); }); + + test("accepts the exact ChatGPT 502 rejection only when function output carries encrypted content", () => { + const base = { + status: 502, + adapterName: "openai-responses", + outboundBody: serializedOutboundWithEncryptedFunctionOutput(), + errorBody: CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR, + alreadyAttempted: false, + }; + + expect(shouldAttemptOpaqueBlobRecovery(base)).toBe(true); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, status: 500 })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + errorBody: JSON.stringify({ error: { message: "Bad gateway" } }), + })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + outboundBody: JSON.stringify({ model: "model-a", input: [{ type: "message", role: "user" }] }), + })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, alreadyAttempted: true })).toBe(false); + }); + + test("accepts the exact ChatGPT 502 rejection when an agent_message content part carries encrypted content", () => { + const base = { + status: 502, + adapterName: "openai-responses", + outboundBody: serializedOutboundWithEncryptedAgentMessage(), + errorBody: CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR, + alreadyAttempted: false, + }; + + expect(shouldAttemptOpaqueBlobRecovery(base)).toBe(true); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + outboundBody: JSON.stringify({ + model: "model-a", + input: [{ type: "agent_message", content: [{ type: "input_text", text: "plain" }] }], + }), + })).toBe(false); + }); }); describe("opaque blob recovery through /v1/responses", () => { + test("recovers a zero-output streamed function-output decrypt failure before client relay", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? streamedFunctionOutputDecryptFailure() + : streamedSuccess("resp-stream-function-output-recovered"); + }) as typeof fetch; + + const response = await handleResponses(functionOutputRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.completed"); + expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(outbound).toHaveLength(2); + const retriedInput = outbound.at(1)?.input as Array> | undefined; + expect(retriedInput?.at(1)).toEqual({ + type: "function_call_output", + call_id: "call-encrypted-output", + output: [ + { type: "input_text", text: "[encrypted content omitted]" }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ], + }); + }); + + test("retries a ChatGPT function-output decrypt failure once with an omission marker", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length <= 3 + ? new Response(CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR, { + status: 502, + headers: { "content-type": "application/json" }, + }) + : success("resp-function-output-recovered"); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(functionOutputRequest(), config(), logCtx); + expect(response.status).toBe(200); + await response.text(); + + expect(outbound).toHaveLength(4); + const firstInput = outbound.at(0)?.input as Array> | undefined; + const retriedInput = outbound.at(3)?.input as Array> | undefined; + expect(firstInput?.at(1)).toEqual(functionOutputReplayInput().at(1)); + expect(retriedInput?.at(0)).toEqual(functionOutputReplayInput().at(0)); + expect(retriedInput?.at(1)).toEqual({ + type: "function_call_output", + call_id: "call-encrypted-output", + output: [ + { type: "input_text", text: "[encrypted content omitted]" }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ], + }); + expect(retriedInput?.at(2)).toEqual(functionOutputReplayInput().at(2)); + expect(logCtx.activeAttempt?.sendCount).toBe(4); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["transient-5xx", "opaque-blob-rejection"]); + }); + + test("surfaces a repeated function-output decrypt rejection after one sanitized rebuild", async () => { + const logCtx: RequestLogContext = { model: "", provider: "" }; + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return new Response(CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR, { + status: 502, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const response = await handleResponses(functionOutputRequest(), config(), logCtx); + expect(response.status).toBe(502); + const body = await response.json() as { error?: { message?: string } }; + expect(body.error?.message).toBe(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + + expect(outbound).toHaveLength(6); + const initialInput = outbound.at(0)?.input as Array> | undefined; + const finalInput = outbound.at(-1)?.input as Array> | undefined; + expect(initialInput?.at(1)).toEqual(functionOutputReplayInput().at(1)); + expect(finalInput?.at(1)).toEqual({ + type: "function_call_output", + call_id: "call-encrypted-output", + output: [ + { type: "input_text", text: "[encrypted content omitted]" }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ], + }); + }); + + test("keeps a repeated streamed function-output rejection visible after one sanitized rebuild", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return streamedFunctionOutputDecryptFailure(); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(functionOutputRequest(true), config(), logCtx); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.failed"); + expect(body).toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(logCtx.upstreamError).toBe(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(outbound).toHaveLength(2); + const finalInput = outbound.at(1)?.input as Array> | undefined; + expect(finalInput?.at(1)).toEqual({ + type: "function_call_output", + call_id: "call-encrypted-output", + output: [ + { type: "input_text", text: "[encrypted content omitted]" }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ], + }); + }); + + test("retries a ChatGPT agent-message decrypt failure once with an omission marker", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length <= 3 + ? new Response(CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR, { + status: 502, + headers: { "content-type": "application/json" }, + }) + : success("resp-agent-message-recovered"); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(agentMessageRequest(), config(), logCtx); + expect(response.status).toBe(200); + await response.text(); + + expect(outbound).toHaveLength(4); + const retriedInput = outbound.at(3)?.input as Array> | undefined; + expect(retriedInput?.at(0)).toEqual({ + type: "agent_message", + author: "/root/child_task", + recipient: "/root", + content: [ + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + }); + expect(retriedInput?.at(1)).toEqual(agentMessageReplayInput().at(1)); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["transient-5xx", "opaque-blob-rejection"]); + }); + + test("recovers a zero-output streamed agent-message decrypt failure before client relay", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? streamedFunctionOutputDecryptFailure() + : streamedSuccess("resp-stream-agent-message-recovered"); + }) as typeof fetch; + + const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.completed"); + expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(outbound).toHaveLength(2); + const retriedInput = outbound.at(1)?.input as Array> | undefined; + expect(retriedInput?.at(0)).toEqual({ + type: "agent_message", + author: "/root/child_task", + recipient: "/root", + content: [ + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + }); + }); + + test("recovers a zero-output error-event decrypt failure before client relay", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? streamedFunctionOutputDecryptErrorEvent() + : streamedSuccess("resp-stream-error-event-recovered"); + }) as typeof fetch; + + const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.completed"); + expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(outbound).toHaveLength(2); + const retriedInput = outbound.at(1)?.input as Array> | undefined; + expect(retriedInput?.at(0)).toEqual({ + type: "agent_message", + author: "/root/child_task", + recipient: "/root", + content: [ + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + }); + }); + + for (const streamMode of ["legacy-tee", "eager-relay"] as const) { + test(`preserves non-decrypt failed SSE with encrypted history (${streamMode})`, async () => { + const failed = { type: "response.failed", response: { + id: "resp-other-failure", status: "failed", output: [], + error: { type: "server_error", code: "unrelated_failure", message: "Other upstream failure" }, + } }; + const wire = `event: response.failed\ndata: ${JSON.stringify(failed)}\n\ndata: [DONE]\n\n`; + let sends = 0; + globalThis.fetch = Object.assign(async () => { + sends += 1; + return new Response(wire, { headers: { "content-type": "text/event-stream" } }); + }, { preconnect: originalFetch.preconnect }); + const response = await handleResponses(agentMessageRequest(true), { + ...config(), streamMode, + }, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(await response.text()).toBe(wire); + expect(sends).toBe(1); + }); + + for (const flat of [false, true]) { + test(`repeated bare decrypt errors terminate as failed (${streamMode}, flat=${flat})`, async () => { + let sends = 0; + globalThis.fetch = Object.assign(async () => { + sends += 1; + return streamedFunctionOutputDecryptErrorEvent(flat); + }, { preconnect: originalFetch.preconnect }); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const terminals: string[] = []; + let markTerminal!: () => void; + const terminal = new Promise(resolve => { markTerminal = resolve; }); + const response = await handleResponses(agentMessageRequest(true), { + ...config(), streamMode, + }, logCtx, { onNativePassthroughTerminal: status => { + terminals.push(status); + markTerminal(); + } }); + const body = await response.text(); + await terminal; + expect(terminals).toEqual(["failed"]); + expect(logCtx.activeAttempt).toBeDefined(); + expect(logCtx.activeAttempt?.streamAborted).not.toBe(true); + expect(sends).toBe(2); + expect(body).toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(body).not.toContain("adapter_eof"); + expect(body.match(/^event: response.failed$/gm)).toHaveLength(1); + expect(body.match(/^data: \[DONE\]$/gm)).toHaveLength(1); + }); + } + } + + test("recovers a flat error event once and preserves the marked raw body identity", async () => { + const definition = ADAPTER_REGISTRY["openai-responses"]; + const originalCreate = definition.create; + const rawBodies: unknown[] = []; + const createSpy = spyOn(definition, "create").mockImplementation((provider, context) => { + const adapter = originalCreate(provider, context); + const buildRequest = adapter.buildRequest.bind(adapter); + adapter.buildRequest = (parsed, incoming) => { + rawBodies.push(parsed._rawBody); + if (rawBodies.length === 1) markBodyNonPersistable(parsed._rawBody); + return buildRequest(parsed, incoming); + }; + return adapter; + }); + let sends = 0; + globalThis.fetch = Object.assign(async () => { + sends += 1; + return sends === 1 ? streamedFunctionOutputDecryptErrorEvent(true) : streamedSuccess("resp-identity"); + }, { preconnect: originalFetch.preconnect }); + try { + const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + expect(body).toContain("response.completed"); + expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(sends).toBe(2); + expect(rawBodies).toHaveLength(2); + expect(rawBodies[1]).toBe(rawBodies[0]); + rememberResponseState(rawBodies[1], { id: "resp-marked-identity", status: "completed", output: [] }, + { cursor: { conversationId: "must-not-persist" } }, { force: true }); + expect(previousResponseProviderState("resp-marked-identity")).toBeUndefined(); + } finally { + createSpy.mockRestore(); + } + }); + + test("recovers a missing-Content-Type streamed function-output decrypt failure before client relay", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? streamedFunctionOutputDecryptFailure(null) + : streamedSuccess("resp-missing-ct-function-output-recovered"); + }) as typeof fetch; + + const response = await handleResponses(functionOutputRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.completed"); + expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(outbound).toHaveLength(2); + const retriedInput = outbound.at(1)?.input as Array> | undefined; + expect(retriedInput?.at(1)).toEqual({ + type: "function_call_output", + call_id: "call-encrypted-output", + output: [ + { type: "input_text", text: "[encrypted content omitted]" }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ], + }); + }); + + test("recovers a missing-Content-Type error-event decrypt failure before client relay", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? streamedFunctionOutputDecryptErrorEvent(false, null) + : streamedSuccess("resp-missing-ct-error-event-recovered"); + }) as typeof fetch; + + const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.completed"); + expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(outbound).toHaveLength(2); + const retriedInput = outbound.at(1)?.input as Array> | undefined; + expect(retriedInput?.at(0)).toEqual({ + type: "agent_message", + author: "/root/child_task", + recipient: "/root", + content: [ + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + }); + }); + + test("absent Content-Type decrypt stream does not recover a non-stream request", async () => { + let sends = 0; + globalThis.fetch = Object.assign(async () => { + sends += 1; + return streamedFunctionOutputDecryptFailure(null); + }, { preconnect: originalFetch.preconnect }); + + const response = await handleResponses(functionOutputRequest(false), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(sends).toBe(1); + expect(body).toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(body).not.toContain("response.completed"); + }); + + for (const contentType of ["application/json", "text/plain"] as const) { + test(`refuses non-SSE ${contentType} streamed decrypt recovery`, async () => { + let sends = 0; + globalThis.fetch = Object.assign(async () => { + sends += 1; + return streamedFunctionOutputDecryptFailure(contentType); + }, { preconnect: originalFetch.preconnect }); + + const response = await handleResponses(functionOutputRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(sends).toBe(1); + expect(body).toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(body).not.toContain("response.completed"); + }); + } + + for (const streamMode of ["legacy-tee", "eager-relay"] as const) { + test(`created-then-reset streamed function-output does not sanitize or resend (${streamMode})`, async () => { + const created = { + type: "response.created", + response: { id: "resp-function-output-reset", status: "in_progress" }, + }; + const prefix = new TextEncoder().encode( + `event: response.created +data: ${JSON.stringify(created)} + +`, + ); + const readError = new Error("upstream stream reset"); + const outbound: Array> = []; + globalThis.fetch = Object.assign(async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + let sentPrefix = false; + return new Response(new ReadableStream({ + pull(controller) { + if (!sentPrefix) { + sentPrefix = true; + controller.enqueue(prefix); + return; + } + return Promise.reject(readError); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); + }, { preconnect: originalFetch.preconnect }) as typeof fetch; + + const logCtx: RequestLogContext = { model: "", provider: "" }; + const terminals: string[] = []; + let markTerminal!: () => void; + const terminal = new Promise(resolve => { markTerminal = resolve; }); + const response = await handleResponses(functionOutputRequest(true), { + ...config(), streamMode, + }, logCtx, { onNativePassthroughTerminal: status => { + terminals.push(status); + markTerminal(); + } }); + const body = await response.text(); + await terminal; + expect(terminals).toEqual(["failed"]); + expect(logCtx.activeAttempt?.streamAborted).toBe(true); + expect(response.status).toBe(200); + expect(body).toContain("response.failed"); + expect(body).toContain('"code":"upstream_reset"'); + expect(body).not.toContain('"reason":"adapter_eof"'); + expect(outbound).toHaveLength(1); + const sentInput = outbound.at(0)?.input as Array> | undefined; + expect(sentInput?.at(1)).toEqual(functionOutputReplayInput().at(1)); + expect(JSON.stringify(sentInput)).toContain("encrypted_content"); + }); + + test(`created-then-abort streamed function-output returns 499 without resend (${streamMode})`, async () => { + const created = { + type: "response.created", + response: { id: "resp-function-output-abort", status: "in_progress" }, + }; + const prefix = new TextEncoder().encode( + `event: response.created +data: ${JSON.stringify(created)} + +`, + ); + const abort = new AbortController(); + let fetchSignal: AbortSignal | undefined; + let sawCreated!: () => void; + const createdStarted = new Promise(resolve => { sawCreated = resolve; }); + const outbound: Array> = []; + globalThis.fetch = Object.assign(async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + fetchSignal = init?.signal ?? undefined; + let sentPrefix = false; + return new Response(new ReadableStream({ + pull(controller) { + if (!sentPrefix) { + sentPrefix = true; + controller.enqueue(prefix); + sawCreated(); + return new Promise((_resolve, reject) => { + const fail = () => reject(fetchSignal?.reason ?? new Error("aborted")); + if (fetchSignal?.aborted) { + fail(); + return; + } + fetchSignal?.addEventListener("abort", fail, { once: true }); + }); + } + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); + }, { preconnect: originalFetch.preconnect }) as typeof fetch; + + const logCtx: RequestLogContext = { model: "", provider: "" }; + const pending = handleResponses(functionOutputRequest(true), { + ...config(), streamMode, + }, logCtx, { abortSignal: abort.signal }); + await createdStarted; + expect(fetchSignal).toBeDefined(); + abort.abort(); + expect(fetchSignal?.aborted).toBe(true); + const response = await pending; + expect(response.status).toBe(499); + const body = await response.json() as { error?: { code?: string; type?: string } }; + expect(body.error?.code ?? body.error?.type).toBe("client_cancelled"); + expect(outbound).toHaveLength(1); + const sentInput = outbound.at(0)?.input as Array> | undefined; + expect(sentInput?.at(1)).toEqual(functionOutputReplayInput().at(1)); + }); + } + test("#2247 strips reasoning and compaction ciphertext before a pooled thread moves accounts", async () => { const outbound: Array<{ accountId: string | null; body: Record }> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 83e5687a0b..0debca2d0a 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { buildResponseJSON } from "../../src/bridge"; import { parseRequest } from "../../src/responses/parser"; +import { externalTaskInputContent } from "../../src/responses/task-input"; import { buildTools } from "../../src/responses/parser-tools"; import { parseTextFormat } from "../../src/responses/parser-text-format"; import { buildToolBridgeMaps } from "../../src/server/responses"; @@ -936,6 +937,221 @@ describe("unpaired tool result boundary (#3259)", () => { }); }); +describe("external task-input envelopes (#3735)", () => { + const parseFrozen = (input: unknown[], extra: Record = {}) => { + const body = Object.freeze({ + model: "test-model", + ...extra, + input: Object.freeze(input.map((item) => Object.freeze(item as object))), + }); + const before = JSON.stringify(body); + const parsed = parseRequest(body); + expect(parsed._rawBody).toBe(body); + expect(JSON.stringify(body)).toBe(before); + return parsed; + }; + + test.each([ + { + name: "arbitrary metadata names preserve output whitespace", + item: { + type: "function_call_output", + id: "rsrc.1", + name: "Launch Task", + namespace: "agent.workspace", + output: " keep ", + }, + content: " keep ", + }, + { + name: "ordered text and original image keep order and map detail to high", + item: { + type: "function_call_output", + id: "img_1", + name: "view", + namespace: "tools", + output: [ + { type: "input_text", text: "caption" }, + { type: "input_image", image_url: "https://example.com/a.png", detail: "original" }, + ], + }, + content: [ + { type: "text", text: "caption" }, + { type: "image", imageUrl: "https://example.com/a.png", detail: "high" }, + ], + }, + { + name: "output_text normalizes through input content parts", + item: { + type: "function_call_output", + id: "txt_1", + name: "note", + namespace: "ns", + output: [{ type: "output_text", text: "from output_text" }], + }, + content: "from output_text", + }, + ])("$name", ({ item, content }) => { + const parsed = parseFrozen([item]); + expect(parsed.context.messages).toMatchObject([{ role: "user", content }]); + expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(false); + }); + + test("complete metadata with a valid call_id stays a tool result", () => { + const parsed = parseFrozen([{ + type: "function_call_output", + call_id: "call_keep", + id: "task_1", + name: "Launch Task", + namespace: "agent.workspace", + output: "ok", + }]); + expect(parsed.context.messages).toMatchObject([{ + role: "toolResult", + toolCallId: "call_keep", + content: "ok", + }]); + }); + + test.each([ + { name: "missing id", item: { type: "function_call_output", name: "n", namespace: "ns", output: "ok" } }, + { name: "blank id", item: { type: "function_call_output", id: " ", name: "n", namespace: "ns", output: "ok" } }, + { name: "missing name", item: { type: "function_call_output", id: "i", namespace: "ns", output: "ok" } }, + { name: "blank name", item: { type: "function_call_output", id: "i", name: "", namespace: "ns", output: "ok" } }, + { name: "missing namespace", item: { type: "function_call_output", id: "i", name: "n", output: "ok" } }, + { name: "blank namespace", item: { type: "function_call_output", id: "i", name: "n", namespace: "\t", output: "ok" } }, + { name: "empty call_id", item: { type: "function_call_output", call_id: "", id: "i", name: "n", namespace: "ns", output: "ok" } }, + { name: "null call_id", item: { type: "function_call_output", call_id: null, id: "i", name: "n", namespace: "ns", output: "ok" } }, + { name: "number call_id", item: { type: "function_call_output", call_id: 1, id: "i", name: "n", namespace: "ns", output: "ok" } }, + { name: "custom_tool_call_output", item: { type: "custom_tool_call_output", id: "i", name: "n", namespace: "ns", output: "ok" } }, + { + name: "encrypted-only", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "encrypted_content", encrypted_content: "blob" }], + }, + }, + { + name: "mixed unsupported", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [ + { type: "input_text", text: "visible" }, + { type: "encrypted_content", encrypted_content: "blob" }, + ], + }, + }, + { + name: "malformed text", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_text", text: 1 }], + }, + }, + { + name: "malformed image", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_image", image_url: 1 }], + }, + }, + { + name: "invalid detail", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_image", image_url: "https://example.com/a.png", detail: "ultra" }], + }, + }, + { + name: "file_id-only image", + item: { + type: "function_call_output", + id: "i", + name: "n", + namespace: "ns", + output: [{ type: "input_image", file_id: "file-1" }], + }, + }, + { name: "blank output", item: { type: "function_call_output", id: "i", name: "n", namespace: "ns", output: " " } }, + { name: "empty output", item: { type: "function_call_output", id: "i", name: "n", namespace: "ns", output: "" } }, + { name: "empty array", item: { type: "function_call_output", id: "i", name: "n", namespace: "ns", output: [] } }, + ])("$name stays off the user path", ({ item }) => { + const parsed = parseFrozen([item]); + expect(parsed.context.messages.some((message) => message.role === "user")).toBe(false); + expect(parsed.context.messages.some((message) => message.role === "toolResult")).toBe(true); + }); + + test("own and inherited call_id properties are helper-ineligible", () => { + const base = { + type: "function_call_output", + id: "task_1", + name: "n", + namespace: "ns", + output: "ok", + }; + expect(externalTaskInputContent(base)).toBe("ok"); + expect(externalTaskInputContent({ ...base, call_id: undefined })).toBeUndefined(); + expect(externalTaskInputContent(Object.assign(Object.create({ call_id: "proto" }), base))).toBeUndefined(); + }); + + test("previous_response_id with only a valid envelope starts continuation at 0", () => { + const parsed = parseFrozen([{ + type: "function_call_output", + id: "task_1", + name: "Launch Task", + namespace: "agent.workspace", + output: "next task", + }], { previous_response_id: "resp_1" }); + expect(parsed._continuationConversationMessageIndex).toBe(0); + expect(parsed.context.messages).toMatchObject([{ role: "user", content: "next task" }]); + }); + + test("reasoning before a valid envelope does not leak into a later assistant", () => { + const parsed = parseFrozen([ + { + type: "reasoning", + id: "rs_stale", + summary: [{ type: "summary_text", text: "stale thinking" }], + }, + { + type: "function_call_output", + id: "task_1", + name: "Launch Task", + namespace: "agent.workspace", + output: "next task", + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "done" }], + }, + ]); + expect(parsed.context.messages).toMatchObject([ + { role: "user", content: "next task" }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, + ]); + const assistant = parsed.context.messages.find((message) => message.role === "assistant"); + expect(assistant && "content" in assistant ? assistant.content : []).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "thinking", thinking: "stale thinking" })]), + ); + }); +}); + test("parser leaf seams preserve tool and format contracts without importing the request parser", () => { const tools = buildTools([{ type: "function", name: "missing_parameters" }]); expect(tools?.[0]?.name).toBe("missing_parameters"); diff --git a/tests/responses/responses-pool-401-refresh.test.ts b/tests/responses/responses-pool-401-refresh.test.ts index 197191c92b..609b24d772 100644 --- a/tests/responses/responses-pool-401-refresh.test.ts +++ b/tests/responses/responses-pool-401-refresh.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; @@ -16,8 +16,22 @@ import { resetProviderRequestPacingForTest, setProviderRequestPacingLimitsForTest, } from "../../src/providers/request-pacing"; +import { + clearResponseStateForTests, + clearResponseStateMemoryForTests, + responseContinuationRetainedStoreSnapshot, + runPendingResponseStatePersistForTests, +} from "../../src/responses/state"; +import { resetAgentTaskRecoveryState } from "../../src/server/responses/agent-task-recovery"; +import { agentTaskRecoveryCacheSnapshotForTests } from "../../src/server/responses/agent-task-recovery-cache"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; +import { + FERNET_TASK, + codexHeaders, + encryptedInput, + recoverySse, +} from "../helpers/agent-task-recovery"; import { removeTreeWithRetry } from "../helpers/remove-tree"; /** @@ -64,17 +78,25 @@ const THREAD_ID = "thread-2887"; function request( path: "/v1/responses" | "/v1/responses/compact", - options: { affined?: boolean; model?: string; headers?: HeadersInit; stream?: boolean } = {}, + options: { + affined?: boolean; + model?: string; + headers?: HeadersInit; + stream?: boolean; + input?: unknown; + } = {}, ): Request { const headers = new Headers(options.headers); headers.set("content-type", "application/json"); if (options.affined) headers.set("x-codex-parent-thread-id", THREAD_ID); + const compact = path.endsWith("compact"); + const input = options.input ?? (compact ? [] : "hello"); return new Request(`http://localhost${path}`, { method: "POST", headers, - body: JSON.stringify(path.endsWith("compact") - ? { model: options.model ?? "gpt-5.5", input: [] } - : { model: options.model ?? "gpt-5.5", input: "hello", stream: options.stream ?? false }), + body: JSON.stringify(compact + ? { model: options.model ?? "gpt-5.5", input } + : { model: options.model ?? "gpt-5.5", input, stream: options.stream ?? false }), }); } @@ -117,7 +139,14 @@ function readStoredGeneration(): number { return raw[ACCOUNT_ID]!.generation; } -type Harness = { sends: string[]; refreshes: string[] }; +type Harness = { + sends: string[]; + refreshes: string[]; + recoveryAuths: string[]; + backupAuths: string[]; + backupBodies: string[]; + canonicalAliasSends: number; +}; /** * Upstream rejects the old bearer once, the token endpoint rotates, and the replay with the @@ -126,11 +155,18 @@ type Harness = { sends: string[]; refreshes: string[] }; function installHarness(options: { refresh?: () => Response; responseForSend?: (authorization: string, sendNumber: number, url: URL) => Response | undefined; + recovery?: (authorization: string, init?: RequestInit) => Response | Promise; } = {}): Harness { const sends: string[] = []; const refreshes: string[] = []; + const recoveryAuths: string[] = []; + const backupAuths: string[] = []; + const backupBodies: string[] = []; + let canonicalAliasSends = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); + const body = typeof init?.body === "string" ? init.body : ""; + const authorization = new Headers(init?.headers).get("authorization") ?? ""; if (url.hostname === "auth.openai.com") { refreshes.push(new URLSearchParams(String(init?.body)).get("refresh_token") ?? ""); if (options.refresh) return options.refresh(); @@ -140,10 +176,29 @@ function installHarness(options: { expires_in: 3600, }); } + if (body.includes("capture_assignment")) { + recoveryAuths.push(authorization); + if (options.recovery) return await options.recovery(authorization, init); + return new Response(recoverySse("RECOVERED-POOL-PLAINTEXT-SENTINEL"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } if (!url.pathname.endsWith("/responses") && !url.pathname.endsWith("/responses/compact")) { return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } }); } - const authorization = new Headers(init?.headers).get("authorization") ?? ""; + if (url.hostname === "backup.example" || url.hostname === "spare.example") { + backupAuths.push(authorization); + backupBodies.push(body); + } + if ( + url.hostname === "chatgpt.com" + && authorization !== "Bearer rejected-access" + && authorization !== "Bearer refreshed-access" + && authorization !== "Bearer other-access" + ) { + canonicalAliasSends += 1; + } sends.push(authorization); const customResponse = options.responseForSend?.(authorization, sends.length, url); if (customResponse) return customResponse; @@ -152,7 +207,7 @@ function installHarness(options: { } return Response.json({ id: "resp_replayed", object: "response", status: "completed", output: [] }); }) as typeof fetch; - return { sends, refreshes }; + return { sends, refreshes, recoveryAuths, backupAuths, backupBodies, get canonicalAliasSends() { return canonicalAliasSends; } }; } function recoveryComboConfig(): OcxConfig { @@ -175,6 +230,75 @@ function recoveryComboConfig(): OcxConfig { return cfg; } +function writeWorkAndOtherAccounts(): void { + writeStoredAccount({ + [OTHER_ACCOUNT_ID]: storedRecord({ + accessToken: "other-access", + refreshToken: "other-grant", + generation: 1, + chatgptAccountId: "acc-other", + }), + }); +} + +function encryptedRecoveryComboConfig(options: { + extraCanonical?: boolean; + extraSpare?: boolean; + includeBackup?: boolean; +} = {}): OcxConfig { + const cfg = recoveryComboConfig(); + cfg.agentTaskRecovery = { enabled: true }; + cfg.accountPoolStrategy = "fill-first"; + cfg.codexAccounts = [ + { id: ACCOUNT_ID, label: "work" }, + { id: OTHER_ACCOUNT_ID, label: "other" }, + ]; + if (options.extraCanonical) { + cfg.providers.chatgpt = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }; + } + if (options.extraSpare) { + cfg.providers.spare = { + adapter: "openai-responses", + baseUrl: "https://spare.example/v1", + authMode: "key", + apiKey: "spare-test-key", + }; + } + const targets: Array<{ provider: string; model: string }> = [ + { provider: "openai", model: "gpt-5.5" }, + ]; + if (options.extraCanonical) targets.push({ provider: "chatgpt", model: "gpt-5.5" }); + if (options.includeBackup !== false) targets.push({ provider: "backup", model: "m2" }); + if (options.extraSpare) targets.push({ provider: "spare", model: "m3" }); + cfg.combos = { + recovery: { + strategy: "failover", + targets, + }, + }; + return cfg; +} + +function storedReplay401(authorization: string, url: URL): Response | undefined { + if (url.hostname === "spare.example") { + return Response.json({ id: "must-not-run-spare", object: "response", status: "completed", output: [] }); + } + if (authorization === "Bearer rejected-access") { + return Response.json({ error: { message: "rejected bearer" } }, { status: 401 }); + } + if (authorization === "Bearer refreshed-access") { + return Response.json({ error: { message: "replay rejected" } }, { status: 401 }); + } + if (authorization === "Bearer other-access") { + return Response.json({ id: "must-not-run-other", object: "response", status: "completed", output: [] }); + } + return undefined; +} + beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-responses-pool-401-")); previousOcxHome = process.env.OPENCODEX_HOME; @@ -185,6 +309,8 @@ beforeEach(() => { clearAccountNeedsReauth(OTHER_ACCOUNT_ID); clearCodexUpstreamHealth(); clearThreadAccountMap(); + clearResponseStateMemoryForTests(); + resetAgentTaskRecoveryState(); writeStoredAccount(); }); @@ -195,6 +321,8 @@ afterEach(() => { clearAccountNeedsReauth(OTHER_ACCOUNT_ID); clearCodexUpstreamHealth(); clearThreadAccountMap(); + resetAgentTaskRecoveryState(); + clearResponseStateForTests(); if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOcxHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -892,3 +1020,210 @@ describe("ordinary pool 401 refresh and replay (#2887)", () => { expect(harness.refreshes).toEqual(["refresh-grant"]); }); }); + +describe("stored pool 401 replay then encrypted combo recovery", () => { + const assignment = "RECOVERED-POOL-PLAINTEXT-SENTINEL"; + + async function postEncryptedCombo( + cfg: OcxConfig, + headers: Headers, + abortSignal?: AbortSignal, + logCtx: RequestLogContext = { model: "", provider: "" } as RequestLogContext, + ): Promise { + return handleResponses( + request("/v1/responses", { + model: "combo/recovery", + headers, + input: encryptedInput(), + }), + cfg, + logCtx, + abortSignal ? { abortSignal } : {}, + ); + } + + test("refreshes once, recovers once with the caller bearer, and backups plaintext without storing it", async () => { + writeWorkAndOtherAccounts(); + const headers = codexHeaders(); + const cfg = encryptedRecoveryComboConfig({ extraCanonical: true }); + const harness = installHarness({ + responseForSend: (authorization, _sendNumber, url) => { + if (url.hostname === "backup.example") { + return Response.json({ id: "resp_backup", object: "response", status: "completed", output: [] }); + } + return storedReplay401(authorization, url); + }, + }); + + const response = await postEncryptedCombo(cfg, headers); + await runPendingResponseStatePersistForTests(); + const payload = await response.clone().json() as { id?: string }; + + expect(response.status).toBe(200); + expect(typeof payload.id).toBe("string"); + expect(harness.refreshes).toEqual(["refresh-grant"]); + expect(harness.sends.filter(send => send === "Bearer rejected-access" || send === "Bearer refreshed-access")) + .toEqual(["Bearer rejected-access", "Bearer refreshed-access"]); + expect(harness.sends).not.toContain("Bearer other-access"); + expect(harness.recoveryAuths).toEqual([headers.get("authorization")]); + expect(harness.backupAuths).toEqual(["Bearer backup-test-key"]); + expect(harness.backupBodies).toHaveLength(1); + expect(harness.backupBodies[0]).toContain(assignment); + expect(harness.backupBodies[0]).not.toContain(FERNET_TASK); + expect(harness.canonicalAliasSends).toBe(0); + expect(responseContinuationRetainedStoreSnapshot().count).toBe(0); + const snapshotPath = join(home, "responses-state.json"); + const snapshot = existsSync(snapshotPath) ? readFileSync(snapshotPath, "utf8") : ""; + expect(snapshot).not.toContain(assignment); + expect(snapshot).not.toContain(payload.id!); + }); + + test("skips another canonical alias before the independently routed backup", async () => { + writeWorkAndOtherAccounts(); + const headers = codexHeaders(); + const cfg = encryptedRecoveryComboConfig({ extraCanonical: true }); + const harness = installHarness({ + responseForSend: (authorization, _sendNumber, url) => { + if (url.hostname === "backup.example") { + return Response.json({ id: "resp_backup", object: "response", status: "completed", output: [] }); + } + return storedReplay401(authorization, url); + }, + }); + + const response = await postEncryptedCombo(cfg, headers); + expect(response.status).toBe(200); + expect(harness.canonicalAliasSends).toBe(0); + expect(harness.sends).not.toContain("Bearer other-access"); + expect(harness.backupBodies).toHaveLength(1); + expect(harness.backupBodies[0]).toContain(assignment); + }); + + test("abort during recovery returns 499 without backup, other-account spend, or cache", async () => { + writeWorkAndOtherAccounts(); + const headers = codexHeaders(); + const cfg = encryptedRecoveryComboConfig({ extraCanonical: true }); + const controller = new AbortController(); + let markRecoveryStarted: (() => void) | undefined; + const recoveryStarted = new Promise((resolve) => { + markRecoveryStarted = resolve; + }); + const harness = installHarness({ + responseForSend: (authorization, _sendNumber, url) => storedReplay401(authorization, url), + recovery: (_authorization, init) => { + markRecoveryStarted?.(); + return new Promise((_resolve, reject) => { + const signal = init?.signal; + const rejectAbort = () => reject(signal?.reason ?? new DOMException("aborted", "AbortError")); + if (signal?.aborted) rejectAbort(); + else signal?.addEventListener("abort", rejectAbort, { once: true }); + }); + }, + }); + + const pending = postEncryptedCombo(cfg, headers, controller.signal); + await recoveryStarted; + controller.abort(new DOMException("client disconnected", "AbortError")); + const response = await pending; + await runPendingResponseStatePersistForTests(); + const payload = await response.json() as { error?: { code?: string } }; + + expect(response.status).toBe(499); + expect(payload).toMatchObject({ error: { code: "client_cancelled" } }); + expect(harness.backupAuths).toEqual([]); + expect(harness.sends).not.toContain("Bearer other-access"); + expect(harness.canonicalAliasSends).toBe(0); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(responseContinuationRetainedStoreSnapshot().count).toBe(0); + }); + + test("recovery failure keeps the replay 401 and does not send backup", async () => { + writeWorkAndOtherAccounts(); + const headers = codexHeaders(); + const cfg = encryptedRecoveryComboConfig({ extraCanonical: true }); + const harness = installHarness({ + responseForSend: (authorization, _sendNumber, url) => storedReplay401(authorization, url), + recovery: () => new Response("not-sse", { status: 500 }), + }); + + const response = await postEncryptedCombo(cfg, headers); + expect(response.status).toBe(401); + expect(harness.recoveryAuths).toHaveLength(1); + expect(harness.backupAuths).toEqual([]); + expect(harness.sends).toEqual(["Bearer rejected-access", "Bearer refreshed-access"]); + expect(harness.canonicalAliasSends).toBe(0); + }); + + test("no independently routed target retains the replay 401 without recovery", async () => { + writeWorkAndOtherAccounts(); + const headers = codexHeaders(); + const cfg = encryptedRecoveryComboConfig({ extraCanonical: true, includeBackup: false }); + const harness = installHarness({ + responseForSend: (authorization, _sendNumber, url) => storedReplay401(authorization, url), + }); + + const response = await postEncryptedCombo(cfg, headers); + expect(response.status).toBe(401); + expect(harness.recoveryAuths).toEqual([]); + expect(harness.backupAuths).toEqual([]); + expect(harness.sends).toEqual(["Bearer rejected-access", "Bearer refreshed-access"]); + expect(harness.canonicalAliasSends).toBe(0); + }); + + test("a hop-class backup failure cannot reopen later combo or native hops", async () => { + writeWorkAndOtherAccounts(); + const headers = codexHeaders(); + const cfg = encryptedRecoveryComboConfig({ extraCanonical: true, extraSpare: true }); + const logCtx = { model: "", provider: "" } as RequestLogContext; + const harness = installHarness({ + responseForSend: (authorization, _sendNumber, url) => { + if (url.hostname === "backup.example") { + return Response.json({ error: { message: "backup overloaded" } }, { status: 503 }); + } + return storedReplay401(authorization, url); + }, + }); + + const response = await postEncryptedCombo(cfg, headers, undefined, logCtx); + expect(response.status).toBe(503); + expect(harness.recoveryAuths).toHaveLength(1); + expect(harness.sends.filter(send => send === "Bearer rejected-access" || send === "Bearer refreshed-access")) + .toEqual(["Bearer rejected-access", "Bearer refreshed-access"]); + expect(harness.sends).not.toContain("Bearer other-access"); + expect(harness.canonicalAliasSends).toBe(0); + expect(harness.backupAuths).toContain("Bearer backup-test-key"); + expect(harness.backupAuths).not.toContain("Bearer spare-test-key"); + expect((logCtx.attempts ?? []).filter(attempt => attempt.provider === "backup")).toHaveLength(1); + expect((logCtx.attempts ?? []).some(attempt => attempt.provider === "spare")).toBe(false); + expect((logCtx.attempts ?? []).filter(attempt => attempt.provider === "chatgpt")).toHaveLength(0); + }); + + test.each(["cyber_policy", "invalid_request_error"])("encrypted replay %s remains a terminal 400 without recovery or backup", async (code) => { + writeWorkAndOtherAccounts(); + const headers = codexHeaders(); + const cfg = encryptedRecoveryComboConfig({ extraCanonical: true }); + const harness = installHarness({ + responseForSend: (authorization, _sendNumber, url) => { + if (url.hostname === "backup.example") { + return Response.json({ id: "must-not-run", object: "response", status: "completed", output: [] }); + } + if (authorization === "Bearer rejected-access") { + return Response.json({ error: { message: "rejected bearer" } }, { status: 401 }); + } + if (authorization === "Bearer refreshed-access") { + return Response.json({ + error: { type: code, code, message: "blocked" }, + }, { status: 400 }); + } + return storedReplay401(authorization, url); + }, + }); + + const response = await postEncryptedCombo(cfg, headers); + expect(response.status).toBe(400); + expect(harness.recoveryAuths).toEqual([]); + expect(harness.backupAuths).toEqual([]); + expect(harness.sends).toEqual(["Bearer rejected-access", "Bearer refreshed-access"]); + expect(harness.canonicalAliasSends).toBe(0); + }); +}); diff --git a/tests/responses/responses-snapshot-repair-server.test.ts b/tests/responses/responses-snapshot-repair-server.test.ts index 57916e8f4e..214806b141 100644 --- a/tests/responses/responses-snapshot-repair-server.test.ts +++ b/tests/responses/responses-snapshot-repair-server.test.ts @@ -23,11 +23,46 @@ const SPARSE_EVENTS = [ { type: "response.completed", response: { id: "resp_sparse" } }, ]; -function sparseSseBody(): ReadableStream { +const EXPLICIT_EMPTY_TERMINAL_EVENTS = [ + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_sparse", + role: "assistant", + status: "completed", + phase: "final_answer", + content: [{ type: "output_text", text: "hello", annotations: [] }], + }, + }, + { + type: "response.completed", + response: { id: "resp_sparse", status: "completed", output: [] }, + }, +]; + +const CODEX_SPARSE_TERMINAL_EVENTS = [ + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello" }], + }, + }, + { + type: "response.completed", + response: { id: "resp_sparse", status: "completed" }, + }, +]; + +function sparseSseBody(events: readonly Record[] = SPARSE_EVENTS): ReadableStream { return new ReadableStream({ start(controller) { const encoder = new TextEncoder(); - for (const event of SPARSE_EVENTS) { + for (const event of events) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); } controller.enqueue(encoder.encode("data: [DONE]\n\n")); @@ -36,7 +71,10 @@ function sparseSseBody(): ReadableStream { }); } -function stubSparseGateway(origin: string): void { +function stubSparseGateway( + origin: string, + events: readonly Record[] = SPARSE_EVENTS, +): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const url = new URL(requestUrl); @@ -44,7 +82,7 @@ function stubSparseGateway(origin: string): void { return Response.json({ data: [] }); } if (url.origin === origin && url.pathname.endsWith("/responses")) { - return new Response(sparseSseBody(), { + return new Response(sparseSseBody(events), { status: 200, headers: { "content-type": "text/event-stream" }, }); @@ -189,4 +227,134 @@ describe("responsesSnapshotRepair through /v1/responses", () => { await server.stop(true); } }); + + test("the Grok client marker alone repairs an explicit empty completed snapshot", async () => { + const gateway = "https://grok-sparse-terminal.example.test"; + stubSparseGateway(gateway, EXPLICIT_EMPTY_TERMINAL_EVENTS); + saveConfig({ + port: 0, + defaultProvider: "sparse", + providers: { + sparse: { + adapter: "openai-responses", + baseUrl: `${gateway}/v1`, + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig); + + const server = startServer(0); + try { + const request = (grokMarker: boolean) => originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + ...(grokMarker ? { "x-opencodex-grok": "1" } : {}), + }, + body: JSON.stringify({ model: "sparse-model", input: "hi", stream: true }), + }); + + const grokResponse = await request(true); + expect(grokResponse.status).toBe(200); + const grokText = await grokResponse.text(); + const grokCompletedLine = grokText.split("\n") + .find(line => line.includes('"response.completed"')); + expect(grokCompletedLine).toBeDefined(); + const grokCompleted = JSON.parse(grokCompletedLine!.replace(/^data: /, "")) as { + response: { output: { id: string }[] }; + }; + expect(grokCompleted.response.output[0]?.id).toBe("msg_sparse"); + + const ordinaryResponse = await request(false); + expect(ordinaryResponse.status).toBe(200); + const ordinaryText = await ordinaryResponse.text(); + const ordinaryCompletedLine = ordinaryText.split("\n") + .find(line => line.includes('"response.completed"')); + expect(ordinaryCompletedLine).toBeDefined(); + const ordinaryCompleted = JSON.parse(ordinaryCompletedLine!.replace(/^data: /, "")) as { + response: { output: unknown[] }; + }; + expect(ordinaryCompleted.response.output).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("the Grok marker repairs Codex-style done items plus a sparse completed response", async () => { + const gateway = "https://grok-codex-sparse.example.test"; + stubSparseGateway(gateway, CODEX_SPARSE_TERMINAL_EVENTS); + saveConfig({ + port: 0, + defaultProvider: "sparse", + providers: { + sparse: { + adapter: "openai-responses", + baseUrl: `${gateway}/v1`, + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig); + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencodex-grok": "1", + }, + body: JSON.stringify({ model: "sparse-model", input: "hi", stream: true }), + }); + expect(response.status).toBe(200); + const text = await response.text(); + const completedLine = text.split("\n").find(line => line.includes('"response.completed"')); + expect(completedLine).toBeDefined(); + const completed = JSON.parse(completedLine!.replace(/^data: /, "")) as { + response: { output: Array> }; + }; + expect(completed.response.output).toHaveLength(1); + expect(completed.response.output[0]).toMatchObject({ + id: "msg_ocx_0", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hello", annotations: [] }], + }); + } finally { + await server.stop(true); + } + }); +}); + +test("sparse JSON completion inference precedes function repair in client output and replay", async () => { + const expected = '{"cell_id":"4","yield_time_ms":120000}'; + const item = { type: "function_call", id: "fc_sparse_wait", call_id: "call_sparse_wait", name: "wait", arguments: '{"cell_id":4,"yield_time_ms":120000.0}' }; + let responseId = `resp_sparse_${crypto.randomUUID()}`; + let capturedInput: Array> = []; + globalThis.fetch = (async (_input, init) => { + capturedInput = JSON.parse(String(init?.body)).input; + return Response.json({ id: responseId, output: [item] }); + }) as typeof fetch; + const config = { + port: 0, defaultProvider: "sparse", + providers: { sparse: { adapter: "openai-responses", baseUrl: "https://sparse-function.invalid/v1", authMode: "key", apiKey: "fixture", responsesSnapshotRepair: true } }, + } as OcxConfig; + const request = (extra: object = {}) => new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "sparse/probe", stream: false, input: "synthetic", + tools: [{ type: "function", name: "wait", parameters: { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" } } } }], + ...extra, + }), + }); + const first = await handleResponses(request(), config, { model: "", provider: "" }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ status: "completed", output: [{ status: "completed", arguments: expected }] }); + const previous = responseId; + responseId = `resp_sparse_followup_${crypto.randomUUID()}`; + const second = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: item.call_id, output: "done" }] }), config, { model: "", provider: "" }); + await second.text(); + expect(capturedInput.find(value => value.type === "function_call" && value.call_id === item.call_id)?.arguments).toBe(expected); }); diff --git a/tests/responses/responses-snapshot-repair.test.ts b/tests/responses/responses-snapshot-repair.test.ts index 0231fdb77f..7596134b5a 100644 --- a/tests/responses/responses-snapshot-repair.test.ts +++ b/tests/responses/responses-snapshot-repair.test.ts @@ -4,11 +4,16 @@ import { hasResponsesSnapshotRepair, repairResponsesSnapshotJson, } from "../../src/server/responses-snapshot-repair"; +import { createGrokResponsesSparseTerminalBlockRewrite } from "../../src/server/grok-responses-snapshot-repair"; import { composeSseBlockRewrites, payloadRewriteAsBlockRewrite, relaySseWithBlockRewrite, } from "../../src/server/sse-payload-rewrite"; +import { + MAX_COMPLETED_OUTPUT_ITEMS, + MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES, +} from "../../src/server/relay"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -31,6 +36,437 @@ const ISSUE_FIXTURE = { completed: { type: "response.completed", response: { id: "resp_1" } }, }; +describe("createGrokResponsesSparseTerminalBlockRewrite", () => { + test("Grok compatibility backfills explicit empty output from completed items", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const item = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + phase: "final_answer", + content: [{ type: "output_text", text: "hello", annotations: [] }], + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [] }, + })); + const terminal = eventsOf(out).find(event => event.type === "response.completed")!; + expect((terminal.response as Record).output).toEqual([item]); + }); + + test("Grok compatibility preserves explicit empty output when completed items are gapped", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + rewrite(dataBlock({ + type: "response.output_item.done", + output_index: 1, + item: { + type: "message", + id: "msg_2", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "partial", annotations: [] }], + }, + })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [] }, + })); + const terminal = eventsOf(out).find(event => event.type === "response.completed")!; + expect((terminal.response as Record).output).toEqual([]); + }); + + test("Grok compatibility also backfills a missing terminal output", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const item = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hello", annotations: [] }], + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item })); + const out = rewrite(dataBlock({ type: "response.completed", response: { id: "resp_1" } })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([item]); + }); + + test("Grok compatibility trusts missing ids/status only when semantic content is already valid", () => { + // The always-on field backfill that follows this rewrite supplies the id, + // message status, and annotations. The official Codex SSE parser also + // accepts done items that omit id/status, so absence alone is not a + // contradiction; malformed values still are. + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const item = { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello" }], + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([item]); + }); + + test("Grok compatibility never promotes repaired or contradictory done items", () => { + const invalidItems = [ + { + type: "message", + id: "msg_user", + role: "user", + status: "completed", + content: [{ type: "output_text", text: "bad", annotations: [] }], + }, + { + type: "message", + id: "msg_failed", + role: "assistant", + status: "failed", + content: [{ type: "output_text", text: "bad", annotations: [] }], + }, + { + type: "message", + id: "msg_content", + role: "assistant", + status: "completed", + content: "bad", + }, + { + type: "message", + id: "", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "bad", annotations: [] }], + }, + { + type: "message", + id: 42, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "bad", annotations: [] }], + }, + ]; + for (const item of invalidItems) { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([]); + } + }); + + test("Grok compatibility treats every duplicate done index as contradictory", () => { + for (const conflicting of [false, true]) { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const first = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "first", annotations: [] }], + }; + const second = conflicting + ? { ...first, id: "msg_2", content: [{ type: "output_text", text: "second", annotations: [] }] } + : first; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: first })); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: second })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([]); + } + }); + + test("Grok compatibility requires a real done item, not deltas or an open item", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + rewrite(dataBlock({ + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, + })); + rewrite(dataBlock({ + type: "response.output_text.delta", + output_index: 0, + item_id: "msg_1", + delta: "visible but not durable", + })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([]); + }); + + test("Grok compatibility reconstructs a contiguous reasoning-plus-message snapshot", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const reasoning = { + type: "reasoning", + id: "rs_1", + status: "completed", + summary: [{ type: "summary_text", text: "summary" }], + content: [{ type: "reasoning_text", text: "reasoning" }], + encrypted_content: null, + }; + const message = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + phase: "final_answer", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: reasoning })); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 1, item: message })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([reasoning, message]); + }); + + test("Grok compatibility reconstructs a valid function call", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const call = { + type: "function_call", + id: "fc_1", + status: "completed", + call_id: "call_1", + name: "search", + arguments: "{}", + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: call })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([call]); + }); + + test("Grok compatibility rejects missing, empty, or whitespace function_call call_id", () => { + const callIds = [undefined, "", " "] as const; + for (const callId of callIds) { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const call = { + type: "function_call", + id: "fc_1", + status: "completed", + name: "search", + arguments: "{}", + ...(callId === undefined ? {} : { call_id: callId }), + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: call })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([]); + } + }); + + test("Grok compatibility rejects missing, empty, or whitespace custom_tool_call call_id even with a visible message", () => { + const message = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }; + const callIds = [undefined, "", " "] as const; + for (const callId of callIds) { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const custom = { + type: "custom_tool_call", + id: "ctc_1", + status: "completed", + name: "browser", + input: "{}", + ...(callId === undefined ? {} : { call_id: callId }), + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: custom })); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 1, item: message })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([]); + } + }); + + test("Grok compatibility reconstructs a valid custom_tool_call with a visible message", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + const custom = { + type: "custom_tool_call", + id: "ctc_1", + status: "completed", + call_id: "call_1", + name: "browser", + input: "{}", + }; + const message = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: custom })); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 1, item: message })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + })); + const terminal = eventsOf(out)[0]!.response as Record; + expect(terminal.output).toEqual([custom, message]); + }); + + test("Grok compatibility preserves a non-empty terminal snapshot as authoritative", () => { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + rewrite(dataBlock({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", id: "msg_done", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "done", annotations: [] }], + }, + })); + const canonical = [{ + type: "message", id: "msg_canonical", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "canonical", annotations: [] }], + }]; + const terminalBlock = dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: canonical }, + }); + const out = rewrite(terminalBlock); + expect(out).toEqual([terminalBlock]); + }); + + test("Grok compatibility leaves explicit malformed terminal output fail-closed", () => { + for (const malformed of [null, "bad", 42, { bad: true }]) { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + rewrite(dataBlock({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", id: "msg_1", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }, + })); + const terminalBlock = dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: malformed }, + }); + expect(rewrite(terminalBlock)).toEqual([terminalBlock]); + } + }); + + test("Grok compatibility bounds and releases open-item identity state", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(budget); + for (let outputIndex = 0; outputIndex <= MAX_COMPLETED_OUTPUT_ITEMS; outputIndex++) { + rewrite(dataBlock({ + type: "response.output_item.added", + output_index: outputIndex, + item: { type: "message", id: `msg_${outputIndex}` }, + })); + } + // The first item beyond the count bound taints and immediately refunds all + // retained identities; an empty terminal remains authoritative. + expect(budget.snapshot().currentBytes).toBe(0); + const terminalBlock = dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + }); + expect(rewrite(terminalBlock)).toEqual([terminalBlock]); + }); + + test("Grok compatibility rejects an oversized retained open identity", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(budget); + rewrite(dataBlock({ + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "x".repeat(MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES + 1) }, + })); + expect(budget.snapshot().currentBytes).toBe(0); + const terminalBlock = dataBlock({ + type: "response.completed", + response: { id: "resp_1", output: [] }, + }); + expect(rewrite(terminalBlock)).toEqual([terminalBlock]); + }); + + test("Grok compatibility dispose releases an unfinished open identity", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(budget); + rewrite(dataBlock({ + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_1" }, + })); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("Grok compatibility never rewrites failed, incomplete, or contradictory completed terminals", () => { + for (const terminal of [ + { type: "response.failed", response: { id: "resp_1", output: [] } }, + { type: "response.incomplete", response: { id: "resp_1", output: [] } }, + { type: "response.completed", response: { id: "resp_1", status: "failed", output: [] } }, + ]) { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()); + rewrite(dataBlock({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", id: "msg_1", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }, + })); + const terminalBlock = dataBlock(terminal); + expect(rewrite(terminalBlock)).toEqual([terminalBlock]); + } + }); + + test("Grok sparse repair still fills an empty terminal when composed ahead of provider snapshot repair", () => { + const done = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + phase: "final_answer", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }; + const chain = composeSseBlockRewrites( + createGrokResponsesSparseTerminalBlockRewrite(createTestTranslatorBudget()), + createResponsesSnapshotBlockRewrite(undefined, createTestTranslatorBudget()), + ); + chain(dataBlock({ type: "response.output_item.done", output_index: 0, item: done })); + const out = chain(dataBlock({ + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [] }, + })); + const completed = eventsOf(out).find(event => event.type === "response.completed"); + expect(completed).toBeDefined(); + expect((completed!.response as Record).output).toEqual([done]); + }); +}); + describe("createResponsesSnapshotBlockRewrite", () => { test("the exact #893 issue fixture yields the full canonical lifecycle and a committed message", () => { const rewrite = createResponsesSnapshotBlockRewrite(undefined, createTestTranslatorBudget()); diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index fe3542ca3a..464642cda7 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -1,9 +1,8 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { BULK_DURABLE_IO_BUDGET_MS, STORE_BUDGET_MS } from "../helpers/test-budget"; +import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"; +import { BULK_DURABLE_IO_BUDGET_MS } from "../helpers/test-budget"; import { findDeadPid } from "../helpers/dead-pid"; import { closeSync, - copyFileSync, existsSync, linkSync, mkdirSync, @@ -19,7 +18,6 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; import { buildResponseJSON } from "../../src/bridge"; import { createCursorRequest } from "../../src/adapters/cursor/request-builder"; import { createCursorContextUsageTracker } from "../../src/adapters/cursor/protobuf-events"; @@ -29,10 +27,10 @@ import { createSseInspector } from "../../src/server/relay"; import { clearResponseStateForTests, clearResponseStateMemoryForTests, - awaitResponseSpillPublicationTailForTests, evictOldestResponseContinuationForBudget, expandPreviousResponseInput, flushResponseState, + awaitResponseSpillPublicationTailForTests, markBodyNonPersistable, previousResponseConversationId, previousResponseProviderState, @@ -61,17 +59,14 @@ import { } from "../../src/responses/state"; import { RESPONSE_SPILL_DIR_NAME, - createResponseSpillPublicationControl, readResponseSpill, deleteResponseSpill, recoverOrphanedResponseSpills, responseSpillDirectory, - setAfterSpillOwnershipTransferForTests, setResponseSpillNowForTests, setResponseSpillPayloadCapForTests, setSpillIoForTest, writeResponseSpillDurably, - writeResponseSpillDurablyAsync, } from "../../src/responses/spill-store"; import { adapterNeedsForcedContinuation, injectDeveloperMessage } from "../../src/server/responses"; import { watchdogMs } from "../helpers/ci-watchdog"; @@ -133,8 +128,6 @@ function forceWindowsAclLane(): void { setPlatformForTests("win32"); setWindowsPrincipalRunnerForTests(() => SYNTHETIC_SID); setAsyncWindowsPrincipalRunnerForTests(async () => SYNTHETIC_SID); - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); } function feedInspector( @@ -198,7 +191,6 @@ interface NeverSettlingAclChildResult { settled: boolean; pending: { count: number; bytes: number }; metrics: { tombstoneCount: number }; - seamCalls: { principal: number; icacls: number }; } async function runShutdownBudgetChild( @@ -327,36 +319,27 @@ describe("Responses previous_response_id state", () => { expect(previousResponseConversationId("resp_progress_31")).toBe("cursor_progress_chain"); }); - afterEach(async () => { - let tailError: unknown; - try { - await awaitResponseSpillPublicationTailForTests(); - } catch (error) { - tailError = error; - } finally { - setAfterSpillOwnershipTransferForTests(null); - setSpillIoForTest(null); - setResponseSpillNowForTests(null); - setAsyncIcaclsRunnerForTests(null); - setIcaclsRunnerForTests(null); - setNowForTests(null); - setPlatformForTests(null); - setWindowsPrincipalRunnerForTests(null); - setAsyncWindowsPrincipalRunnerForTests(null); - resetWindowsPrincipalForTests(); - setStatForTests(null); - resetHardenedStateForTests(); - delete process.env.OPENCODEX_ACL_TIMEOUT_MS; - setResponseSpillShutdownBudgetForTests(null); - setResponseSpillAsyncAclAttemptBudgetForTests(null); - setResponseStateByteCapForTests(null); - setSpilledResponseByteCapForTests(null); - clearResponseStateForTests(); - removeTreeWithRetry(home); - if (priorHome === undefined) delete process.env["OPENCODEX_HOME"]; - else process.env["OPENCODEX_HOME"] = priorHome; - } - if (tailError) throw tailError; + afterEach(() => { + setSpillIoForTest(null); + setResponseSpillNowForTests(null); + setAsyncIcaclsRunnerForTests(null); + setIcaclsRunnerForTests(null); + setNowForTests(null); + setPlatformForTests(null); + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + setStatForTests(null); + resetHardenedStateForTests(); + delete process.env.OPENCODEX_ACL_TIMEOUT_MS; + setResponseSpillShutdownBudgetForTests(null); + setResponseSpillAsyncAclAttemptBudgetForTests(null); + setResponseStateByteCapForTests(null); + setSpilledResponseByteCapForTests(null); + clearResponseStateForTests(); + removeTreeWithRetry(home); + if (priorHome === undefined) delete process.env["OPENCODEX_HOME"]; + else process.env["OPENCODEX_HOME"] = priorHome; }); test("expands later input with stored prior input and output", () => { @@ -946,135 +929,34 @@ describe("Responses previous_response_id state", () => { expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1, spillWrites: 1, spillWriteFailures: 0 }); }); - test("lock contention never cleans a colliding destination this attempt did not publish", async () => { - const payload = { createdAt: Date.now(), items: [{ role: "user", content: "collision" }] }; - const first = writeResponseSpillDurably("resp_lock_collision", payload); - const match = /\.(\d+)\.(\d+)\.spill\.json$/.exec(first.fileName)!; - const collisionName = first.fileName.replace( - /\.(\d+)\.(\d+)\.spill\.json$/, - `.${Number(match[1]) + 1}.${match[2]}.spill.json`, - ); - const dir = responseSpillDirectory(home); - const collisionPath = join(dir, collisionName); - copyFileSync(join(dir, first.fileName), collisionPath); - - const readyPath = join(home, "spill-lock-ready"); - const releasePath = join(home, "spill-lock-release"); - const configUrl = pathToFileURL(join(repoRoot(), "src/config.ts")).href; - const child = Bun.spawn([process.execPath, "-e", ` - import { existsSync, writeFileSync } from "node:fs"; - import { withConfigMutationLockSync } from ${JSON.stringify(configUrl)}; - withConfigMutationLockSync(() => { - writeFileSync(${JSON.stringify(readyPath)}, "ready"); - while (!existsSync(${JSON.stringify(releasePath)})) Bun.sleepSync(10); - }); - `], { - cwd: repoRoot(), - env: { ...process.env, OPENCODEX_HOME: home }, - stdout: "ignore", - stderr: "ignore", - }); - - try { - const deadline = Date.now() + watchdogMs(3_000); - while (!existsSync(readyPath) && Date.now() < deadline) await Bun.sleep(10); - expect(existsSync(readyPath)).toBe(true); - const control = createResponseSpillPublicationControl(); - await expect(writeResponseSpillDurablyAsync("resp_lock_collision", payload, { - aclBudgetMs: 1_000, - publicationControl: control, - })).rejects.toThrow(); - expect(existsSync(collisionPath)).toBe(true); - expect(readFileSync(collisionPath)).toEqual(readFileSync(join(dir, first.fileName))); - } finally { - writeFileSync(releasePath, "release"); - expect(await child.exited).toBe(0); - } - }); - - test("copy fallback retries cleanup for a destination created before post-copy failure", async () => { - forceWindowsAclLane(); - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - let destinationUnlinks = 0; - let fileFsyncs = 0; - setSpillIoForTest({ - link: () => { throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); }, - fsync: () => { - fileFsyncs += 1; - if (fileFsyncs === 2) throw Object.assign(new Error("fsync failed"), { code: "EIO" }); - }, - unlink(path) { - if (path.endsWith(".spill.json") && ++destinationUnlinks === 1) { - throw Object.assign(new Error("temporarily locked"), { code: "EACCES" }); - } - unlinkSync(path); - }, - }); - const control = createResponseSpillPublicationControl(); - - await expect(writeResponseSpillDurablyAsync("resp_copy_cleanup", { - createdAt: Date.now(), - items: [{ role: "user", content: "cleanup" }], - }, { - aclBudgetMs: 1_000, - publicationControl: control, - })).rejects.toThrow(); - - expect(destinationUnlinks).toBe(2); - expect(spillFileNames(home)).toEqual([]); - expect(spillTempNames(home)).toEqual([]); - expect(control).toMatchObject({ destinationPath: null, destinationOwned: false, tempPath: null }); - }); - - test("a committed async spill survives redundant temp cleanup failure", async () => { + test("holds the disk cap while a copy-fallback publication has temp and destination on disk", async () => { + // The cap is a promise about the volume, and a file being created by + // writeResponseSpillDurablyAsync is on the volume. Accounting that walks only + // installed spills reports a satisfied budget while the directory grows — the + // incident behind this cap put 6.8 GiB on disk in 44 minutes. + // + // The peak is TWO envelopes, not one: when hard-linking fails, publication copies + // with COPYFILE_EXCL and then hardens the destination, so the temp and the copy exist + // together. This drives that exact path and measures real bytes on disk. forceWindowsAclLane(); - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - setSpillIoForTest({ - unlink(path) { - if (path.endsWith(".tmp")) throw Object.assign(new Error("locked temp"), { code: "EACCES" }); - unlinkSync(path); - }, - }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_temp_cleanup", "t".repeat(8_000)); - await flushPendingResponseSpillsForTests(); - - expect(responseStateMetrics()).toMatchObject({ spillStubCount: 1, tombstoneCount: 0, spillWriteFailures: 0 }); - expect(spillFileNames(home)).toHaveLength(1); - expect(spillTempNames(home)).toHaveLength(1); - expect(getAccountedResponseSpillBytesForTests()).toBeGreaterThan(getSpilledResponseBytesForTests()); - expect(JSON.stringify(expandPreviousResponseInput({ previous_response_id: "resp_temp_cleanup", input: "next" }))) - .toContain("tttttttt"); - }); - - test("a committed async spill survives shared-lock commit failure", async () => { - forceWindowsAclLane(); - setIcaclsRunnerForTests(() => ICACLS_OK); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - setAfterSpillOwnershipTransferForTests(() => { - throw new Error("injected lock commit failure"); + let gateDestinationHarden = false; + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let announced = false; + setAsyncIcaclsRunnerForTests(async args => { + if (gateDestinationHarden && args.some(arg => arg.endsWith(".spill.json"))) { + if (!announced) { + announced = true; + entered(); + } + await gate; + } + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; }); - setResponseStateByteCapForTests(1_024); - - rememberLarge("resp_lock_commit", "c".repeat(8_000)); - await flushPendingResponseSpillsForTests(); - - expect(responseStateMetrics()).toMatchObject({ spillStubCount: 1, tombstoneCount: 0, spillWriteFailures: 0 }); - expect(spillFileNames(home)).toHaveLength(1); - expect(spillTempNames(home)).toHaveLength(0); - expect(getAccountedResponseSpillBytesForTests()).toBe(getSpilledResponseBytesForTests()); - expect(JSON.stringify(expandPreviousResponseInput({ previous_response_id: "resp_lock_commit", input: "next" }))) - .toContain("cccccccc"); - }); - - test("copy-fallback publication commits the destination and stub before releasing its lock", async () => { - forceWindowsAclLane(); - setResponseStateByteCapForTests(1_024); - setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); - setIcaclsRunnerForTests(() => ICACLS_OK); // One resident spill already on disk, so the cap has real prior occupancy. rememberLarge("resp_cap_existing", "e".repeat(8_000)); @@ -1083,21 +965,52 @@ describe("Responses previous_response_id state", () => { expect(existingBytes).toBeGreaterThan(0); expect(spillFileNames(home)).toHaveLength(1); + // Room for the two-envelope publication and nothing more. The seeded spill does not + // fit alongside it, so correct admission must reclaim it before publishing; without + // the check, seeded + temp + destination sit on disk together and blow the cap. + // Generous enough that this publication is admitted: the point of THIS test is that + // the accounting sees the in-flight bytes. The cap-refusal behaviour is proven + // separately below, where admission is the only thing standing between the request + // and an over-budget directory. const spillCap = existingBytes * 4; setSpilledResponseByteCapForTests(spillCap); - const events: string[] = []; + + // Force the exclusive-copy fallback, then gate the destination hardening that follows + // it, so the observation below happens with BOTH files present. setSpillIoForTest({ link: () => { throw Object.assign(new Error("EXDEV"), { code: "EXDEV" }); }, - record: event => events.push(event), }); + gateDestinationHarden = true; rememberLarge("resp_cap_inflight", "x".repeat(8_000)); + await started; + try { + // Real disk: the temp and the copied destination coexist during hardening. + const onDisk = spillFileNames(home).length + spillTempNames(home).length; + expect(onDisk).toBeGreaterThanOrEqual(3); + // The walk over installed spills still reports only the settled file, so accounting + // built on it alone would price a three-envelope directory as one. + expect(getSpilledResponseBytesForTests()).toBe(existingBytes); + // Reservation prices the in-flight publication at its peak, so the accounted total + // covers what is actually on the volume. + expect(getAccountedResponseSpillBytesForTests()) + .toBeGreaterThanOrEqual(existingBytes * 3); + // And the bytes ACTUALLY on disk stay inside the configured cap. This is the + // assertion the admission check has to earn: without it, the seeded spill plus the + // temp plus the destination copy exceed a cap sized for two envelopes. + expect(bytesOnDisk(home)).toBeLessThanOrEqual(spillCap); + } finally { + release(); + setSpillIoForTest(null); + } await flushPendingResponseSpillsForTests(); - setSpillIoForTest(null); - expect(events.indexOf("publish")).toBeLessThan(events.indexOf("stub-swap")); + // Settled: the reservation is released exactly once and accounting collapses to the + // real files. A leaked reservation would be monotonic — it would ratchet the usable + // cap toward zero until nothing could spill at all. expect(getAccountedResponseSpillBytesForTests()).toBe(getSpilledResponseBytesForTests()); expect(spillTempNames(home)).toHaveLength(0); - expect(bytesOnDisk(home)).toBeLessThanOrEqual(spillCap); + // And the newest continuation is still replayable: the cap must not have turned the + // fail-closed path into the ordinary one. expect(JSON.stringify(expandPreviousResponseInput({ previous_response_id: "resp_cap_inflight", input: "next" }))) .toContain("xxxxxxxx"); }); @@ -1166,6 +1079,9 @@ describe("Responses previous_response_id state", () => { spillWriteFailures: 0, spillWriteStatus: "healthy", spillWriteConsecutiveFailures: 0, + spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, + spillAclTimeoutMemoRefusals: 0, }); }); @@ -1199,6 +1115,9 @@ describe("Responses previous_response_id state", () => { spillWriteConsecutiveFailures: 1, spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", spillLastWriteSuccessAt: null, + spillLastWriteFailureOrigin: "retry_returned_timeout", + spillAclRetryReturnedTimeouts: 1, + spillAclTimeoutMemoRefusals: 0, }); expect(metrics.spillLastWriteFailureAt).toBeGreaterThanOrEqual(0); @@ -1214,14 +1133,67 @@ describe("Responses previous_response_id state", () => { spillWriteStatus: "healthy", spillWriteConsecutiveFailures: 0, spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", + spillLastWriteFailureOrigin: "retry_returned_timeout", + spillAclRetryReturnedTimeouts: 1, + spillAclTimeoutMemoRefusals: 0, }); expect(typeof recovered.spillLastWriteSuccessAt === "number" && recovered.spillLastWriteSuccessAt >= (recovered.spillLastWriteFailureAt ?? 0)).toBe(true); }); + test("Windows stable-directory memo refusals stay distinct after the runner becomes healthy", async () => { + forceWindowsAclLane(); + const previousVerify = process.env.OPENCODEX_ACL_VERIFY_EXISTING; + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + let clock = 0; + let grantCalls = 0; + setNowForTests(() => clock); + setResponseSpillNowForTests(() => clock); + setResponseSpillAsyncAclAttemptBudgetForTests(100); + setResponseStateByteCapForTests(1_024); + const spillDir = responseSpillDirectory(); + let healthy = false; + setAsyncIcaclsRunnerForTests(async args => { + if (args[0] !== spillDir) return ICACLS_OK; + if (args.includes("/grant:r")) grantCalls += 1; + if (healthy) return ICACLS_OK; + clock += 100; + return { success: false, exitCode: null, timedOut: true, stdout: "private-acl-output" }; + }); + try { + rememberLarge("resp_stable_timeout", "x".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + expect(responseStateMetrics()).toMatchObject({ + spillWrites: 0, spillWriteFailures: 1, + spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", + spillLastWriteFailureOrigin: "retry_returned_timeout", + spillAclRetryReturnedTimeouts: 1, spillAclTimeoutMemoRefusals: 0, + }); + expect(grantCalls).toBe(2); + healthy = true; // Same stable directory and process; no memo reset between jobs. + for (let refusal = 1; refusal <= 2; refusal += 1) { + rememberLarge(`resp_stable_refusal_${refusal}`, "y".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + expect(responseStateMetrics()).toMatchObject({ + spillWrites: 0, spillWriteFailures: 1 + refusal, + spillWriteStatus: "degraded", spillWriteConsecutiveFailures: 1 + refusal, + spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", + spillLastWriteFailureOrigin: "timeout_memo_refusal", + spillAclRetryReturnedTimeouts: 1, spillAclTimeoutMemoRefusals: refusal, + spillLastWriteSuccessAt: null, spillStubCount: 0, + }); + expect(grantCalls).toBe(2); + expect(spillFileNames(home)).toHaveLength(0); + expect(spillTempNames(home)).toHaveLength(0); + } + } finally { + if (previousVerify === undefined) delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + else process.env.OPENCODEX_ACL_VERIFY_EXISTING = previousVerify; + } + }); + test("Windows async spill attempts share one bounded ACL budget across every harden", async () => { forceWindowsAclLane(); - setIcaclsRunnerForTests(() => ICACLS_OK); let clock = 0; let firstGrant = true; const deadlines: number[] = []; @@ -1250,11 +1222,12 @@ describe("Responses previous_response_id state", () => { rememberLarge("resp_async_acl_attempt_budget", "q".repeat(8_000)); await flushPendingResponseSpillsForTests(); - expect(deadlines.length).toBeGreaterThanOrEqual(7); + expect(deadlines.length).toBeGreaterThanOrEqual(10); expect(Math.max(...deadlines)).toBeLessThanOrEqual(15_000); - const retryAttemptGrantDeadlines = grantDeadlines.slice(-2); - expect(retryAttemptGrantDeadlines).toHaveLength(2); + const retryAttemptGrantDeadlines = grantDeadlines.slice(-3); + expect(retryAttemptGrantDeadlines).toHaveLength(3); expect(retryAttemptGrantDeadlines[1]!).toBeLessThan(retryAttemptGrantDeadlines[0]!); + expect(retryAttemptGrantDeadlines[2]!).toBeLessThan(retryAttemptGrantDeadlines[1]!); expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1, @@ -1272,9 +1245,6 @@ describe("Responses previous_response_id state", () => { pending: { count: 0, bytes: 0 }, metrics: { tombstoneCount: 2 }, }); - expect(result.seamCalls.principal).toBeGreaterThan(0); - if (mode === "principal") expect(result.seamCalls.icacls).toBe(0); - else expect(result.seamCalls.icacls).toBeGreaterThan(0); } }, { timeout: (2 * watchdogMs(1_500)) + 2_000 }); @@ -1387,6 +1357,8 @@ describe("Responses previous_response_id state", () => { test("shutdown drain reaches a stable tail after a publication is appended mid-drain", async () => { forceWindowsAclLane(); setResponseSpillShutdownBudgetForTests({ totalMs: 1_000, fallbackReserveMs: 500 }); + const nativeSetImmediate = setImmediate; + const epoch = Date.now(); let releaseFirst!: () => void; let releaseSecond!: () => void; let firstEntered!: () => void; @@ -1395,47 +1367,110 @@ describe("Responses previous_response_id state", () => { const secondGate = new Promise(resolve => { releaseSecond = resolve; }); const firstStarted = new Promise(resolve => { firstEntered = resolve; }); const secondStarted = new Promise(resolve => { secondEntered = resolve; }); - let aclCalls = 0; + const gatedTemps = new Set(); setAsyncIcaclsRunnerForTests(async args => { - if (!isSpillAclTarget(args)) return ICACLS_OK; - aclCalls += 1; - if (aclCalls === 1) { - firstEntered(); - await firstGate; - } else if (aclCalls === 7) { - secondEntered(); - await secondGate; + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; } - return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { + firstEntered(); + await firstGate; + } else if (gatedTemps.size === 2) { + secondEntered(); + await secondGate; + } + } + return ICACLS_OK; + }); + let syncSpillCalls = 0; + setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls += 1; + return ICACLS_OK; + }); + let stubSwaps = 0; + setSpillIoForTest({ + record: event => { if (event === "stub-swap") stubSwaps += 1; }, }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_fixed_point_first", "a".repeat(8_000)); - await firstStarted; - let flushed = false; - const flushing = flushResponseState().then(() => { flushed = true; }); - rememberLarge("resp_fixed_point_second", "b".repeat(8_000)); - releaseFirst(); - await secondStarted; + let drained = false; + let draining: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; + let flushing: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; + let restoreClock: (() => void) | undefined; try { - await new Promise(resolve => setTimeout(resolve, 25)); + // This case proves publication ordering; real disk latency must not fire the drain timer. + jest.useFakeTimers(); + const nowSpy = spyOn(Date, "now").mockReturnValue(epoch); + restoreClock = () => { nowSpy.mockRestore(); }; + setNowForTests(() => epoch); + setResponseSpillNowForTests(() => epoch); + rememberLarge("resp_fixed_point_first", "a".repeat(8_000)); + await firstStarted; + + // Handle rejection immediately, including when a gate/assertion fails before this await. + draining = flushPendingResponseSpillsForTests().then( + () => { drained = true; return { ok: true } as const; }, + (error: unknown) => { drained = true; return { ok: false, error } as const; }, + ); + flushing = flushResponseState().then( + () => { flushed = true; return { ok: true } as const; }, + (error: unknown) => { flushed = true; return { ok: false, error } as const; }, + ); + rememberLarge("resp_fixed_point_second", "b".repeat(8_000)); + releaseFirst(); + await secondStarted; + await new Promise(resolve => nativeSetImmediate(resolve)); + jest.advanceTimersByTime(25); + await new Promise(resolve => nativeSetImmediate(resolve)); + // Snapshot I/O after draining must not mask a premature drain return. + expect(drained).toBe(false); expect(flushed).toBe(false); + expect(gatedTemps.size).toBe(2); + expect(stubSwaps).toBe(1); + expect(syncSpillCalls).toBe(0); + + releaseSecond(); + const drainOutcome = await draining; + if (!drainOutcome.ok) throw drainOutcome.error; + const outcome = await flushing; + if (!outcome.ok) throw outcome.error; + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(stubSwaps).toBe(2); + expect(syncSpillCalls).toBe(0); + for (const [id, payload] of [["resp_fixed_point_first", "a"], ["resp_fixed_point_second", "b"]] as const) { + expect(JSON.stringify(expandPreviousResponseInput({ + previous_response_id: id, + input: "next", + }))).toContain(payload.repeat(8_000)); + } } finally { + releaseFirst(); releaseSecond(); + try { + await draining; + await flushing; + await awaitResponseSpillPublicationTailForTests(); + await flushPendingResponseSpillsForTests(); + } finally { + restoreClock?.(); + jest.useRealTimers(); + } } - await flushing; - await awaitResponseSpillPublicationTailForTests(); - expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); }); test("shutdown drain cap expiry enters the synchronous spill fallback", async () => { forceWindowsAclLane(); - // Freeze the ACL/spill clocks, then give the real wall-clock fallback enough time for - // the 2 MiB durable write. The gate, not fallback-budget exhaustion, proves drain expiry. + // Freeze the ACL/spill clocks: the sync fallback harden now really runs on every host + // (harden() follows the platform seam), and its budget must not race a loaded CI + // shard's wall clock inside the 80 ms reserve — run 33603770447 shard 3 lost that race. let aclClock = 0; setNowForTests(() => aclClock); setResponseSpillNowForTests(() => aclClock); - setResponseSpillShutdownBudgetForTests({ totalMs: STORE_BUDGET_MS + 1, fallbackReserveMs: STORE_BUDGET_MS }); + setResponseSpillShutdownBudgetForTests({ totalMs: 120, fallbackReserveMs: 80 }); let release!: () => void; let entered!: () => void; const gate = new Promise(resolve => { release = resolve; }); @@ -1452,18 +1487,28 @@ describe("Responses previous_response_id state", () => { return { success: true, exitCode: 0, timedOut: false, stdout: "" }; }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_shutdown_fallback", "f".repeat(2 * 1024 * 1024 + 4_096)); - await started; - + let restoreClock: (() => void) | undefined; try { + rememberLarge("resp_shutdown_fallback", "f".repeat(2 * 1024 * 1024 + 4_096)); + await started; + // ACL/spill clocks alone do not control the shutdown reserve: state.ts + // uses Date.now(). Keep its 80 ms budget independent of real disk latency. + // The real 40 ms drain timer still fires while publication stays gated. + const shutdownNow = Date.now(); + const nowSpy = spyOn(Date, "now").mockReturnValue(shutdownNow); + restoreClock = () => { nowSpy.mockRestore(); }; await flushResponseState(); expect(synchronousCalls).toBeGreaterThan(0); expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1 }); } finally { release(); + try { + await awaitResponseSpillPublicationTailForTests(); + } finally { + restoreClock?.(); + } } - await awaitResponseSpillPublicationTailForTests(); }); test("shutdown fallback prices the job-owned superseded generation before publishing", async () => { @@ -1473,12 +1518,13 @@ describe("Responses previous_response_id state", () => { // (debt + footprint) and (old + debt + footprint) admits a publication that puts the // directory over budget. forceWindowsAclLane(); - // Freeze the ACL/spill clocks, but keep the real wall-clock fallback reserve independent - // of runner load. The gate is the semantic reason the async drain cannot complete. + // Freeze the ACL/spill clocks: the sync fallback harden now really runs on every host + // (harden() follows the platform seam), and its budget must not race a loaded CI + // shard's wall clock inside the 80 ms reserve — run 33603770447 shard 3 lost that race. let aclClock = 0; setNowForTests(() => aclClock); setResponseSpillNowForTests(() => aclClock); - setResponseSpillShutdownBudgetForTests({ totalMs: STORE_BUDGET_MS + 1, fallbackReserveMs: STORE_BUDGET_MS }); + setResponseSpillShutdownBudgetForTests({ totalMs: 120, fallbackReserveMs: 80 }); let release!: () => void; let entered!: () => void; const gate = new Promise(resolve => { release = resolve; }); @@ -1524,17 +1570,12 @@ describe("Responses previous_response_id state", () => { } finally { release(); } - await awaitResponseSpillPublicationTailForTests(); }); test("shutdown fallback spends only its reserved ACL budget", async () => { forceWindowsAclLane(); - // The 2 MiB fallback write performs real fsync work even though the ACL clock below - // is synthetic. Keep the real wall-clock reserve out of the assertion's critical - // path: run 33998058832 exhausted the old 300 ms reserve under Linux shard load. - const drainMs = 200; - const fallbackReserveMs = STORE_BUDGET_MS; - const totalMs = fallbackReserveMs + drainMs; + const totalMs = 500; + const fallbackReserveMs = 300; setResponseSpillShutdownBudgetForTests({ totalMs, fallbackReserveMs }); let release!: () => void; let entered!: () => void; @@ -1542,47 +1583,71 @@ describe("Responses previous_response_id state", () => { const started = new Promise(resolve => { entered = resolve; }); let aclClock = 0; setNowForTests(() => aclClock); + setResponseSpillNowForTests(() => aclClock); setAsyncIcaclsRunnerForTests(async args => { if (!isSpillAclTarget(args)) return ICACLS_OK; entered(); await gate; return ICACLS_OK; }); - const deadlines: number[] = []; + let released = false; + const deadlines: Array<{ target: string; timeoutMs: number; spentBefore: number; gateReleased: boolean }> = []; setIcaclsRunnerForTests((args, timeoutMs) => { if (!isSpillAclTarget(args)) return ICACLS_OK; - deadlines.push(timeoutMs); - // Spend a meaningful share of the logical reserve per call so the total-budget - // assertion stays sharp without coupling it to hosted-runner filesystem latency. - aclClock += Math.floor(fallbackReserveMs / 8); - return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + deadlines.push({ target: args[0]!, timeoutMs, spentBefore: aclClock, gateReleased: released }); + aclClock += 20; + return ICACLS_OK; }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_shutdown_budget", "b".repeat(2 * 1024 * 1024 + 4_096)); - await started; - + let restoreClock: (() => void) | undefined; try { + rememberLarge("resp_shutdown_budget", "b".repeat(2 * 1024 * 1024 + 4_096)); + await started; + // Keep the native 200 ms drain timer, but charge only logical ACL work to the reserve. + const epoch = Date.now(); + const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); + restoreClock = () => { nowSpy.mockRestore(); }; await flushResponseState(); + const logicalElapsedMs = totalMs - fallbackReserveMs + aclClock; + expect(deadlines.length).toBeGreaterThanOrEqual(6); + expect(Math.max(...deadlines.map(call => call.timeoutMs))).toBeLessThanOrEqual(Math.floor(fallbackReserveMs / 2)); + expect(logicalElapsedMs).toBeLessThanOrEqual(totalMs); + const previousDeadlineByTarget = new Map(); + for (const { target, timeoutMs, spentBefore, gateReleased } of deadlines) { + expect(gateReleased).toBe(false); + expect(timeoutMs).toBeGreaterThan(0); + expect(timeoutMs).toBeLessThanOrEqual(300 - spentBefore); + const previous = previousDeadlineByTarget.get(target); + if (previous !== undefined) expect(timeoutMs).toBe(previous - 20); + previousDeadlineByTarget.set(target, timeoutMs); + } + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1 }); + expect(JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_shutdown_budget", + input: "next", + }))).toContain("b".repeat(2 * 1024 * 1024 + 4_096)); } finally { + released = true; release(); + try { + // Shutdown can clear pending ownership before the superseded async runner settles. + await awaitResponseSpillPublicationTailForTests(); + await flushPendingResponseSpillsForTests(); + } finally { + restoreClock?.(); + } } - await awaitResponseSpillPublicationTailForTests(); - const logicalElapsedMs = drainMs + aclClock; - expect(deadlines.length).toBeGreaterThanOrEqual(6); - expect(Math.max(...deadlines)).toBeLessThanOrEqual(Math.floor(fallbackReserveMs / 2)); - expect(logicalElapsedMs).toBeLessThanOrEqual(totalMs); }); test("late async spill completion cannot overwrite the shutdown fallback", async () => { forceWindowsAclLane(); setStatForTests(() => ({ dev: 1n, ino: 10n, ctimeNs: 100n })); - // Frozen ACL/spill clocks do not freeze state.ts's wall-clock shutdown deadline. + // Frozen clocks for the same reason as the drain-cap case above. let aclClock = 0; setNowForTests(() => aclClock); setResponseSpillNowForTests(() => aclClock); - // Preserve the 120 ms async drain window while giving the required synchronous - // 2 MiB fallback write the shared filesystem budget under hosted-runner load. - setResponseSpillShutdownBudgetForTests({ totalMs: STORE_BUDGET_MS + 120, fallbackReserveMs: STORE_BUDGET_MS }); + setResponseSpillShutdownBudgetForTests({ totalMs: 120, fallbackReserveMs: 80 }); let release!: () => void; let entered!: () => void; let tempHardenFinished!: () => void; @@ -1942,72 +2007,6 @@ describe("Responses previous_response_id state", () => { expect(metrics.totalBytes).toBeLessThanOrEqual(1_024); }); - test("repeated synchronous temp cleanup failure remains charged against the disk cap", () => { - let tempUnlinks = 0; - setSpillIoForTest({ - unlink(path) { - if (path.endsWith(".tmp")) { - tempUnlinks += 1; - throw Object.assign(new Error("locked temp"), { code: "EACCES" }); - } - unlinkSync(path); - }, - }); - setResponseStateByteCapForTests(1_024); - - rememberLarge("resp_sync_temp_debt", "t".repeat(8_000)); - - expect(tempUnlinks).toBe(2); - expect(spillTempNames(home)).toHaveLength(1); - expect(spillFileNames(home)).toHaveLength(0); - expect(responseStateMetrics()).toMatchObject({ tombstoneCount: 1, spillWriteFailures: 1 }); - expect(getSpilledResponseBytesForTests()).toBe(0); - const cleanupDebt = getAccountedResponseSpillBytesForTests(); - expect(cleanupDebt).toBeGreaterThan(0); - - setSpillIoForTest(null); - setSpilledResponseByteCapForTests(cleanupDebt); - rememberLarge("resp_sync_temp_cap", "c".repeat(8_000)); - expect(spillFileNames(home)).toHaveLength(0); - expect(getAccountedResponseSpillBytesForTests()).toBe(cleanupDebt); - }); - - test("repeated synchronous copy-destination cleanup failure remains charged against the disk cap", () => { - let fileFsyncs = 0; - let destinationUnlinks = 0; - setSpillIoForTest({ - link: () => { throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); }, - fsync: () => { - fileFsyncs += 1; - if (fileFsyncs === 2) throw Object.assign(new Error("fsync failed"), { code: "EIO" }); - }, - unlink(path) { - if (path.endsWith(".spill.json")) { - destinationUnlinks += 1; - throw Object.assign(new Error("locked destination"), { code: "EACCES" }); - } - unlinkSync(path); - }, - }); - setResponseStateByteCapForTests(1_024); - - rememberLarge("resp_sync_destination_debt", "d".repeat(8_000)); - - expect(destinationUnlinks).toBe(2); - expect(spillTempNames(home)).toHaveLength(0); - expect(spillFileNames(home)).toHaveLength(1); - expect(responseStateMetrics()).toMatchObject({ tombstoneCount: 1, spillWriteFailures: 1 }); - expect(getSpilledResponseBytesForTests()).toBe(0); - const cleanupDebt = getAccountedResponseSpillBytesForTests(); - expect(cleanupDebt).toBeGreaterThan(0); - - setSpillIoForTest(null); - setSpilledResponseByteCapForTests(cleanupDebt); - rememberLarge("resp_sync_destination_cap", "c".repeat(8_000)); - expect(spillFileNames(home)).toHaveLength(1); - expect(getAccountedResponseSpillBytesForTests()).toBe(cleanupDebt); - }); - test("disk permission failure increments spillWriteFailures without retaining payload", () => { const denied = Object.assign(new Error("denied"), { code: "EACCES" }); setSpillIoForTest({ write: () => { throw denied; } }); @@ -2194,32 +2193,6 @@ describe("Responses previous_response_id state", () => { } }); - test("spill eviction keeps failed unlink bytes charged until the file disappears", () => { - setResponseStateByteCapForTests(1_024); - rememberLarge("resp_eviction_unlink_debt", "e".repeat(8_000)); - const spillPath = join(responseSpillDirectory(home), spillFileNames(home)[0]!); - const payloadBytes = getSpilledResponseBytesForTests(); - expect(payloadBytes).toBeGreaterThan(0); - - setSpillIoForTest({ - unlink(path) { - if (path === spillPath) throw Object.assign(new Error("locked spill"), { code: "EACCES" }); - unlinkSync(path); - }, - }); - setSpilledResponseByteCapForTests(0); - sweepExpiredResponseStates(); - - expect(existsSync(spillPath)).toBe(true); - expect(getSpilledResponseBytesForTests()).toBe(0); - expect(getAccountedResponseSpillBytesForTests()).toBe(payloadBytes); - - setSpillIoForTest(null); - sweepExpiredResponseStates(); - expect(existsSync(spillPath)).toBe(false); - expect(getAccountedResponseSpillBytesForTests()).toBe(0); - }); - test("startup orphan cleanup removes only old unreferenced regular spill files", async () => { setResponseStateByteCapForTests(1_024); rememberLarge("resp_live_orphan_gc", "l".repeat(8_000)); @@ -2268,7 +2241,7 @@ describe("Responses previous_response_id state", () => { rememberLarge("resp_legacy_small", "small"); await flushResponseState(); const snapshot = JSON.parse(readFileSync(join(home, "responses-state.json"), "utf8")) as { version: number; states: [string, Record][] }; - expect(snapshot.version).toBe(3); + expect(snapshot.version).toBe(2); const row = snapshot.states.find(([id]) => id === "resp_legacy_small")?.[1]; expect(row).toMatchObject({ items: expect.any(Array) }); expect(row?.kind).toBeUndefined(); @@ -2399,7 +2372,7 @@ describe("Responses previous_response_id state", () => { ); const items = [{ role: "user", content: "한글🙂" }, ...output]; const expected = Buffer.byteLength(JSON.stringify({ - responseId: "resp_다국어", kind: "resident", createdAt: at, items, providerOutputStart: 1, providers, + responseId: "resp_다국어", createdAt: at, items, providerOutputStart: 1, providers, }), "utf8"); expect(getStoredResponseBytesForTests()).toBe(expected); } finally { @@ -2678,6 +2651,7 @@ describe("Responses previous_response_id state", () => { const { spillWriteStatus, spillLastWriteFailureCode, + spillLastWriteFailureOrigin, spillLastWriteFailureAt, spillLastWriteSuccessAt, ...numericMetrics @@ -2686,6 +2660,7 @@ describe("Responses previous_response_id state", () => { .every(value => typeof value === "number" && Number.isFinite(value))).toBe(true); expect(spillWriteStatus).toBe("healthy"); expect(spillLastWriteFailureCode).toBeNull(); + expect(spillLastWriteFailureOrigin).toBeNull(); expect(spillLastWriteFailureAt).toBeNull(); expect(typeof spillLastWriteSuccessAt === "number" && Number.isFinite(spillLastWriteSuccessAt)).toBe(true); const serialized = JSON.stringify(metrics); @@ -3181,7 +3156,7 @@ describe("Responses previous_response_id state", () => { expect(closed).toBe(true); }); - test("v1 Cursor snapshot is retired by the version 3 confidentiality migration", () => { + test("v1 Cursor snapshot migrates to versioned provider state", () => { mkdirSync(home, { recursive: true }); writeFileSync(join(home, "responses-state.json"), JSON.stringify({ version: 1, @@ -3193,9 +3168,10 @@ describe("Responses previous_response_id state", () => { }]], })); - expect(previousResponseProviderState("resp_v1")).toBeUndefined(); - expect(previousResponseConversationId("resp_v1")).toBeUndefined(); - expect(JSON.parse(readFileSync(join(home, "responses-state.json"), "utf8"))).toEqual({ version: 3, states: [] }); + expect(previousResponseProviderState("resp_v1")).toEqual({ + cursor: { conversationId: "cursor_v1", checkpointUsable: false }, + }); + expect(previousResponseConversationId("resp_v1")).toBe("cursor_v1"); }); test("persists provider-keyed Cursor and Kiro continuation state across restart", async () => { @@ -3235,7 +3211,7 @@ describe("Responses previous_response_id state", () => { kiro: { conversationId: "kiro_conv_2" }, }); const snapshot = JSON.parse(readFileSync(join(home, "responses-state.json"), "utf8")) as { version: number }; - expect(snapshot.version).toBe(3); + expect(snapshot.version).toBe(2); }); test("stale snapshot entries are pruned on load", async () => { @@ -3445,6 +3421,9 @@ describe("Responses previous_response_id state", () => { spillWriteStatus: "initial", spillWriteConsecutiveFailures: 0, spillLastWriteFailureCode: null, + spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, + spillAclTimeoutMemoRefusals: 0, spillLastWriteFailureAt: null, spillLastWriteSuccessAt: null, spillReadFailures: 0, @@ -3452,6 +3431,53 @@ describe("Responses previous_response_id state", () => { }); }); + test("spill failure origin decoding stays bounded, closed and paired with the effective code", () => { + setResponseStateByteCapForTests(1_024); + const memoError = Object.assign(new Error("private-path-and-payload"), { + code: "ETIMEDOUT", aclFailureOrigin: "timeout_memo_refusal", + }); + const cycle: { code: string; cause?: unknown; aclFailureOrigin: string } = { + code: "ETIMEDOUT", aclFailureOrigin: "private-origin", + }; + cycle.cause = cycle; + const cases = [ + { error: new Error("wrapper", { cause: memoError }), code: "ETIMEDOUT", origin: "timeout_memo_refusal" }, + { error: Object.assign(new Error("denied", { cause: memoError }), { code: "EACCES" }), code: "EACCES", origin: null }, + { error: { code: "EACLRETRYEXHAUSTED" }, code: "EACLRETRYEXHAUSTED", origin: null }, + { error: { code: "ETIMEDOUT", aclFailureOrigin: "private-origin" }, code: "ETIMEDOUT", origin: null }, + { error: { code: "ETIMEDOUT", aclFailureOrigin: ["timeout_memo_refusal"] }, code: "ETIMEDOUT", origin: null }, + { error: cycle, code: "ETIMEDOUT", origin: null }, + // Including the writer's wrapper, the marker is beyond the four-object scan. + { error: { code: "ETIMEDOUT", cause: { cause: { cause: memoError } } }, code: "ETIMEDOUT", origin: null }, + ]; + cases.forEach(({ error, code, origin }, index) => { + setSpillIoForTest({ write: () => { throw error; } }); + rememberLarge(`resp_private_origin_${index}`, "private-content".repeat(1_000)); + const metrics = responseStateMetrics(); + expect(metrics).toMatchObject({ + spillWriteFailures: index + 1, + spillLastWriteFailureCode: code, + spillLastWriteFailureOrigin: origin, + spillAclRetryReturnedTimeouts: 0, spillAclTimeoutMemoRefusals: 1, + }); + const serialized = JSON.stringify(metrics); + for (const privateValue of ["private-path-and-payload", "private-origin", "private-content", "resp_private_origin", home]) { + expect(serialized).not.toContain(privateValue); + } + }); + setSpillIoForTest(null); + rememberLarge("resp_after_origin_failures", "healthy".repeat(1_500)); + expect(responseStateMetrics()).toMatchObject({ + spillWriteStatus: "healthy", spillWriteConsecutiveFailures: 0, + spillLastWriteFailureCode: "ETIMEDOUT", spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, spillAclTimeoutMemoRefusals: 1, + }); + clearResponseStateMemoryForTests(); + expect(responseStateMetrics()).toMatchObject({ + spillLastWriteFailureOrigin: null, spillAclRetryReturnedTimeouts: 0, spillAclTimeoutMemoRefusals: 0, + }); + }); + test("a successful spill clears a repeated failure streak without erasing the last failure", () => { const realNow = Date.now; let clock = 1_000; @@ -3557,6 +3583,9 @@ describe("Responses previous_response_id state", () => { spillWriteStatus: "initial", spillWriteConsecutiveFailures: 0, spillLastWriteFailureCode: null, + spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, + spillAclTimeoutMemoRefusals: 0, spillLastWriteFailureAt: null, spillLastWriteSuccessAt: null, spillReadFailures: 0, @@ -3689,14 +3718,12 @@ describe("Responses state admission boundary (oversized direct-spill)", () => { expect(previousResponseReplayFailure(body)?.reason).toBe("spill_failed"); }); - test("externally oversized legacy snapshot is retired without parsing", () => { + test("externally oversized snapshot file is refused before parse", () => { const refusalsBefore = responseAdmissionCountersForTests().snapshotOversizedRefusals; - const path = join(home, "responses-state.json"); - writeFileSync(path, `{"version":2,"states":[${" ".repeat(33 * 1024 * 1024)}]}`); + writeFileSync(join(home, "responses-state.json"), `{"version":2,"states":[${" ".repeat(33 * 1024 * 1024)}]}`); // First store access triggers the lazy load. rememberResponseState({ model: "m", input: "x" }, completedResponse("resp_after", "ok")); expect(responseAdmissionCountersForTests().snapshotOversizedRefusals).toBe(refusalsBefore + 1); - expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ version: 3, states: [] }); // The store still works: the new entry is present and replays. expect((expandChained("resp_after") as { input: unknown[] }).input.length).toBeGreaterThan(1); }); @@ -3812,6 +3839,7 @@ describe("Responses state admission boundary (oversized direct-spill)", () => { const files = readdirSync(dir); expect(files.length).toBe(1); const envelope = statSync(join(dir, files[0])).size; + const firstGeneration = files[0]; clearResponseStateMemoryForTests(); setResponseSpillPayloadCapForTests(envelope - 1); const dropsBefore = responseAdmissionCountersForTests().oversizedDrops; @@ -3828,9 +3856,9 @@ describe("Responses state admission boundary (oversized direct-spill)", () => { // replay reports the tombstone (spill_failed); the sync lane's spill_too_large is a // read-time classification of a file that was never written here. expect(previousResponseReplayFailure(body)?.reason).toBe("spill_failed"); - // The over-ceiling publication was deleted, and the absent-snapshot startup - // retirement pass removed the first generation orphaned by the memory clear. - expect(readdirSync(dir)).toHaveLength(1); + // The over-ceiling publication was deleted; only the first generation's file (orphaned by + // the memory clear, owned by the orphan GC) remains. + expect(readdirSync(dir)).toEqual([firstGeneration]); }); test("win32: async direct-spill write failure installs a tombstone and keeps unrelated residents", async () => { diff --git a/tests/responses/sse-failed-tail.test.ts b/tests/responses/sse-failed-tail.test.ts index 914818e27b..f21301ffb7 100644 --- a/tests/responses/sse-failed-tail.test.ts +++ b/tests/responses/sse-failed-tail.test.ts @@ -238,6 +238,32 @@ describe("relaySseWithFailedTail", () => { expect(eager.split("data: [DONE]").length - 1).toBe(1); }); + test("clean EOF after a recorded upstream error reports that error instead of adapter_eof", async () => { + const upstream = new AbortController(); + const src = sourceStream(['data: {"type":"response.in_progress"}\n\n']); + const out = await drain(relaySseWithFailedTail(src, upstream, undefined, { + upstreamError: "The usage limit has been reached", + })); + + expect(out).toContain("event: response.failed"); + expect(out).toContain("The usage limit has been reached"); + expect(out).not.toContain('"reason":"adapter_eof"'); + expect(out.endsWith("data: [DONE]\n\n")).toBe(true); + }); + + test("clean EOF after an upstream error keeps the same failed payload through eager relay", async () => { + const upstream = new AbortController(); + const src = sourceStream(['data: {"type":"response.in_progress"}\n\n']); + const out = await drain(relaySseEagerBounded(src, upstream, parityHooks, { + upstreamError: "The usage limit has been reached", + })); + + expect(out).toContain("event: response.failed"); + expect(out).toContain("The usage limit has been reached"); + expect(out).not.toContain('"reason":"adapter_eof"'); + expect(out.endsWith("data: [DONE]\n\n")).toBe(true); + }); + test.each([ [ "clean EOF without a terminal", @@ -307,4 +333,92 @@ describe("relaySseWithFailedTail", () => { } } }); + + const CREDENTIAL_CANARY = "sk-testCANARY9live"; + const OVERLONG_SUFFIX = "x".repeat(600); + const BARE_ERROR_MESSAGE = "upstream failed " + CREDENTIAL_CANARY + " " + OVERLONG_SUFFIX; + const EXPECTED_SYNTHETIC_MESSAGE = ("upstream failed [REDACTED] " + OVERLONG_SUFFIX) + .slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS); + + const sseDataFrame = (payload: unknown): string => "data: " + JSON.stringify(payload) + "\n\n"; + + const synthesizedTail = (out: string, original: string): string => { + expect(out.startsWith(original)).toBe(true); + return out.slice(original.length); + }; + + const synthesizedFailedPayload = (tail: string): { + type: string; + response: { status: string; error: { type: string; code: string; message: string } }; + } => { + const dataLine = tail.split("event: response.failed\ndata: ")[1]?.split("\n")[0]; + if (!dataLine) throw new Error("missing synthesized response.failed payload"); + return JSON.parse(dataLine) as { + type: string; + response: { status: string; error: { type: string; code: string; message: string } }; + }; + }; + + test.each([ + [ + "tee", + "flat", + (src: ReadableStream) => relaySseWithFailedTail(src, new AbortController()), + { type: "error", message: BARE_ERROR_MESSAGE }, + ], + [ + "tee", + "nested", + (src: ReadableStream) => relaySseWithFailedTail(src, new AbortController()), + { type: "error", error: { message: BARE_ERROR_MESSAGE } }, + ], + [ + "eager", + "flat", + (src: ReadableStream) => relaySseEagerBounded(src, new AbortController(), parityHooks), + { type: "error", message: BARE_ERROR_MESSAGE }, + ], + [ + "eager", + "nested", + (src: ReadableStream) => relaySseEagerBounded(src, new AbortController(), parityHooks), + { type: "error", error: { message: BARE_ERROR_MESSAGE } }, + ], + ] as const)("%s %s bare error synthesizes a redacted capped failed tail", async (_mode, _shape, relay, payload) => { + const original = sseDataFrame({ type: "response.in_progress" }) + sseDataFrame(payload); + const out = await drain(relay(sourceStream([original]))); + const tail = synthesizedTail(out, original); + const parsed = synthesizedFailedPayload(tail); + + expect(original).toContain(CREDENTIAL_CANARY); + expect(out.slice(0, original.length)).toBe(original); + expect(tail).toContain("event: response.failed"); + expect(tail).not.toContain(CREDENTIAL_CANARY); + expect(parsed.type).toBe("response.failed"); + expect(parsed.response.status).toBe("failed"); + expect(parsed.response.error.code).toBe("upstream_server_error"); + expect(parsed.response.error.message).toBe(EXPECTED_SYNTHETIC_MESSAGE); + expect(parsed.response.error.message).toHaveLength(MAX_TAIL_ERROR_MESSAGE_CHARS); + expect(parsed.response.error.message).not.toContain(CREDENTIAL_CANARY); + expect(terminalEvents(tail)).toEqual(["response.failed"]); + expect(doneEvents(out)).toHaveLength(1); + expect(doneEvents(tail)).toHaveLength(1); + expect(tail.endsWith("data: [DONE]\n\n")).toBe(true); + expect(out).not.toContain('"reason":"adapter_eof"'); + }); + + test.each(["tee", "eager"] as const)("%s existing terminal wins over a preceding bare error and does not duplicate DONE", async (mode) => { + const original = sseDataFrame({ type: "error", message: BARE_ERROR_MESSAGE }) + + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n' + + "data: [DONE]\n\n"; + const relay = mode === "tee" + ? (src: ReadableStream) => relaySseWithFailedTail(src, new AbortController()) + : (src: ReadableStream) => relaySseEagerBounded(src, new AbortController(), parityHooks); + const out = await drain(relay(sourceStream([original]))); + + expect(out).toBe(original); + expect(terminalEvents(out)).toEqual(["response.completed"]); + expect(doneEvents(out)).toHaveLength(1); + expect(out).not.toContain("event: response.failed"); + }); }); diff --git a/tests/responses/sse-payload-rewrite.test.ts b/tests/responses/sse-payload-rewrite.test.ts index 34dae59e07..773665a054 100644 --- a/tests/responses/sse-payload-rewrite.test.ts +++ b/tests/responses/sse-payload-rewrite.test.ts @@ -153,4 +153,82 @@ describe("SSE payload rewrite composition", () => { expect(budget.snapshot().currentBytes).toBe(0); budget.dispose(); }); + + test.each(["resolve", "reject"] as const)( + "surfaces a rewrite failure before tee cancellation can %s", + async cancellationOutcome => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); + const upstream = new AbortController(); + const cancellation = Promise.withResolvers(); + const cancellationError = new Error("upstream cancellation failed"); + let cancelCalls = 0; + let disposeCalls = 0; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: partial")); + controller.enqueue(new TextEncoder().encode("x".repeat(80))); + // Keep the source open after exhausting the rewrite budget. + }, + cancel() { + cancelCalls += 1; + return cancellation.promise; + }, + }); + const [native, inspection] = source.tee(); + const inspectionReader = inspection.getReader(); + await inspectionReader.read(); + await inspectionReader.read(); + let inspectionSettled = false; + const pendingInspection = inspectionReader.read().then(() => { inspectionSettled = true; }); + const rewrite = Object.assign((block: string) => [block], { + dispose() { disposeCalls += 1; }, + }); + const rewritten = relaySseWithBlockRewrite(native, rewrite, budget); + const client = relaySseWithFailedTail(rewritten, upstream); + const completion = readAll(client); + let deadline: ReturnType | undefined; + + try { + const out = await Promise.race([ + completion, + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("rewrite failure waited for the inspection tee")), 1_000); + }), + ]); + expect(out.match(/event: response.failed/g)).toHaveLength(1); + expect(out).toContain('"code":"translation_buffer_limit"'); + expect(out).toEndWith("data: [DONE]\n\n"); + expect(upstream.signal.aborted).toBe(true); + expect(inspectionSettled).toBe(false); + expect(cancelCalls).toBe(0); + expect(disposeCalls).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(1); + + // Releasing inspection settles both tee cancellation promises. A late + // rejection must be handled by the rewriter as well as this reader. + const siblingCancellation = inspectionReader.cancel("inspection cleanup"); + expect(cancelCalls).toBe(1); + if (cancellationOutcome === "reject") { + cancellation.reject(cancellationError); + await expect(siblingCancellation).rejects.toBe(cancellationError); + } else { + cancellation.resolve(); + await siblingCancellation; + } + await pendingInspection; + await Bun.sleep(0); // Let the runner observe any unhandled cancellation rejection. + expect(disposeCalls).toBe(1); + } finally { + clearTimeout(deadline); + const cleanup = inspectionReader.cancel().catch(() => {}); + cancellation.resolve(); + await cleanup; + await pendingInspection; + await completion.catch(() => {}); + inspectionReader.releaseLock(); + budget.dispose(); + } + }, + ); }); diff --git a/tests/responses/ws-upstream-reuse.test.ts b/tests/responses/ws-upstream-reuse.test.ts index 545af19d19..3859079808 100644 --- a/tests/responses/ws-upstream-reuse.test.ts +++ b/tests/responses/ws-upstream-reuse.test.ts @@ -6,6 +6,8 @@ import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-reque const URL = "https://chatgpt.com/backend-api/codex/responses"; const realWebSocket = globalThis.WebSocket; +const proxyEnvKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; +let savedProxyEnv: Record; let sequence = 0; class Socket extends EventTarget { @@ -13,7 +15,7 @@ class Socket extends EventTarget { static onSend: (socket: Socket, frame: Record) => void = (socket) => socket.complete(); readyState = 0; frames: Record[] = []; - constructor(readonly url: string) { + constructor(readonly url: string, readonly options?: { proxy?: string }) { super(); Socket.all.push(this); queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.dispatchEvent(new Event("open")); } }); @@ -58,7 +60,11 @@ function bodyWith(fields: Record) { options.body = JSON.stringify({ ...JSON.parse(options.body as string), ...fields }); return options; } -beforeEach(() => { globalThis.WebSocket = Socket as unknown as typeof WebSocket; }); +beforeEach(() => { + globalThis.WebSocket = Socket as unknown as typeof WebSocket; + savedProxyEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])); + for (const key of proxyEnvKeys) delete process.env[key]; +}); afterEach(() => { runOptionalShutdownHooks(); @@ -67,6 +73,25 @@ afterEach(() => { Socket.onSend = socket => socket.complete(); sequence = 0; globalThis.WebSocket = realWebSocket; + for (const key of proxyEnvKeys) delete process.env[key]; + for (const key of proxyEnvKeys) { + if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; + } +}); + +test("proxy changes and NO_PROXY retire the old route while unchanged routes reuse", async () => { + for (const proxy of ["http://proxy-a.example:8080", "http://proxy-b.example:8080"]) { + process.env.HTTPS_PROXY = proxy; + await drain(); + await drain(); + } + process.env.NO_PROXY = "chatgpt.com:443"; + await drain(); + await drain(); + expect(Socket.all.map(socket => socket.options?.proxy)) + .toEqual(["http://proxy-a.example:8080", "http://proxy-b.example:8080", undefined]); + expect(Socket.all.map(socket => socket.frames.length)).toEqual([2, 2, 2]); + expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 3, 1]); }); test("same account/thread/turn reuses one socket without trimming either HTTP input", async () => { diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 6af3f6299f..3ae551e63d 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -1,25 +1,24 @@ -import { afterEach, describe, expect, jest, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; import { providerFetch } from "../../src/server/responses/fetch-helpers"; import { handleResponses } from "../../src/server/responses"; import { isEagerRelaySseResponse } from "../../src/server/relay"; import { isWin32EagerRewrite } from "../../src/lib/bun-stream-caps"; +import { fetchWithTransientRetry } from "../../src/lib/upstream-retry"; +import { codexWsExchange } from "../../src/server/responses/codex-ws-exchange"; +import { CodexWsSession } from "../../src/server/responses/codex-ws-session"; +import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; import { CodexWsMetadata, CODEX_WS_METADATA_MAX_BYTES, CODEX_WS_METADATA_MAX_VALUE_BYTES } from "../../src/server/responses/codex-ws-metadata"; import { bunSupportsBoundedCodexWsRelay, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit, - type CodexWsUpstreamOptions, codexWsUpstreamFetch as rawCodexWsUpstreamFetch, currentBunRuntimeIdentity, - isCodexWsUpstreamDisabled, isCodexWsUpstreamResponse, + isCodexWsQuotaObservedResponse, MAX_CODEX_WS_CREATE_FRAME_BYTES, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, - resolveCodexWsMaxFrameBytes, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, shouldUseCodexWsUpstream as rawShouldUseCodexWsUpstream, } from "../../src/server/responses/ws-upstream"; @@ -40,17 +39,16 @@ const BOUNDED_WS_RUNTIME = "1.4.0"; // constant that only held before the backfill landed. const EAGER_RELAY_FORCED_BY_PLATFORM = isWin32EagerRewrite(process.platform, true); -function shouldUseCodexWsUpstream(url: string, init?: RequestInit, options?: CodexWsUpstreamOptions): boolean { - return rawShouldUseCodexWsUpstream(url, init, BOUNDED_WS_RUNTIME, options); +function shouldUseCodexWsUpstream(url: string, init?: RequestInit, upstreamWebsocket = false): boolean { + return rawShouldUseCodexWsUpstream(url, init, BOUNDED_WS_RUNTIME, upstreamWebsocket); } function codexWsUpstreamFetch( url: string, init: RequestInit, fallback: typeof fetch, - options: CodexWsUpstreamOptions = { wsUpstream: true }, ): Promise { - return rawCodexWsUpstreamFetch(url, init, fallback, BOUNDED_WS_RUNTIME, options); + return rawCodexWsUpstreamFetch(url, init, fallback, BOUNDED_WS_RUNTIME); } function streamingInit(body: Record = {}): RequestInit { @@ -114,7 +112,7 @@ describe("shouldUseCodexWsUpstream", () => { }); test("matches only streaming POSTs to the Codex backend", () => { - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), { wsUpstream: true })).toBe(true); + expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit())).toBe(true); // Non-streaming turns keep HTTP: the WS path only speaks the event protocol. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", @@ -135,8 +133,8 @@ describe("shouldUseCodexWsUpstream", () => { // Whitespace-formatted JSON still routes. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", - body: "{\n \"model\": \"gpt-5.6-luna\",\n \"stream\" : true\n}", - }, { wsUpstream: true })).toBe(true); + body: "{\n \"model\": \"gpt-5.5\",\n \"stream\" : true\n}", + })).toBe(true); // Non-boolean stream values stay on HTTP. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", @@ -146,65 +144,47 @@ describe("shouldUseCodexWsUpstream", () => { expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", body: "{\"stream\":true" })).toBe(false); }); - test("bypasses WS when wsUpstream option is false", () => { - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), { wsUpstream: false })).toBe(false); - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), { wsUpstream: true })).toBe(true); - }); - - test("requires an explicit provider or environment opt-in", () => { - delete process.env.OCX_CODEX_WS_UPSTREAM; - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit())).toBe(false); - - for (const envVal of ["false", "0", "invalid"]) { - process.env.OCX_CODEX_WS_UPSTREAM = envVal; - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit())).toBe(false); - } - for (const envVal of ["true", "1"]) { - process.env.OCX_CODEX_WS_UPSTREAM = envVal; - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit())).toBe(true); - } - - process.env.OCX_CODEX_WS_UPSTREAM = "true"; - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), { wsUpstream: false })).toBe(false); - process.env.OCX_CODEX_WS_UPSTREAM = "false"; - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), { wsUpstream: true })).toBe(true); - }); - test("opt-in upstream WebSocket only for configured OpenAI-compatible Responses endpoints", () => { - // The canonical backend keeps its independent, default-off wsUpstream contract. - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), { upstreamWebsocket: true })).toBe(false); + // The canonical backend ignores the flag. + expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), false)).toBe(true); // Configured providers join the WS lane on their own /v1/responses path. - expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", streamingInit(), { upstreamWebsocket: true })).toBe(true); + expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", streamingInit(), true)).toBe(true); // Plain HTTP stays on SSE; never send credentials or request data through ws://. - expect(shouldUseCodexWsUpstream("http://10.0.0.5:8080/v1/responses", streamingInit(), { upstreamWebsocket: true })).toBe(false); - expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", streamingInit())).toBe(false); + expect(shouldUseCodexWsUpstream("http://10.0.0.5:8080/v1/responses", streamingInit(), true)).toBe(false); + expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", streamingInit(), false)).toBe(false); // Non-Responses paths on a configured provider stay on HTTP. - expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/chat/completions", streamingInit(), { upstreamWebsocket: true })).toBe(false); - expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/images", streamingInit(), { upstreamWebsocket: true })).toBe(false); - expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/alpha/search", streamingInit(), { upstreamWebsocket: true })).toBe(false); + expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/chat/completions", streamingInit(), true)).toBe(false); + expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/images", streamingInit(), true)).toBe(false); + expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/alpha/search", streamingInit(), true)).toBe(false); // The usual streaming/body rules still apply to configured providers. - expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", { method: "GET" }, { upstreamWebsocket: true })).toBe(false); + expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", { method: "GET" }, true)).toBe(false); expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", { method: "POST", body: JSON.stringify({ model: "m" }), - }, { upstreamWebsocket: true })).toBe(false); - expect(shouldUseCodexWsUpstream("not a url", streamingInit(), { upstreamWebsocket: true })).toBe(false); + }, true)).toBe(false); + expect(shouldUseCodexWsUpstream("not a url", streamingInit(), true)).toBe(false); }); }); type Listener = (event: unknown) => void; +type FakeWebSocketOptions = { + headers?: Record; + proxy?: string; +}; /** Minimal scriptable stand-in for Bun's WebSocket. */ class FakeWebSocket { static instances: FakeWebSocket[] = []; static script: (ws: FakeWebSocket) => void = () => {}; url: string; + options?: FakeWebSocketOptions; sent: string[] = []; closed = false; listeners = new Map(); - constructor(url: string) { + constructor(url: string, options?: FakeWebSocketOptions) { this.url = url; + this.options = options; FakeWebSocket.instances.push(this); queueMicrotask(() => FakeWebSocket.script(this)); } @@ -236,14 +216,23 @@ class FakeWebSocket { const RealWebSocket = globalThis.WebSocket; const RealFetch = globalThis.fetch; +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] as const; +let savedProxyEnv: Record; + +beforeEach(() => { + savedProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.map(key => [key, process.env[key]])); + for (const key of PROXY_ENV_KEYS) delete process.env[key]; +}); afterEach(() => { - delete process.env.OCX_CODEX_WS_UPSTREAM; - delete process.env.OCX_CODEX_WS_MAX_FRAME_BYTES; globalThis.WebSocket = RealWebSocket; globalThis.fetch = RealFetch; FakeWebSocket.instances = []; FakeWebSocket.script = () => {}; + for (const key of PROXY_ENV_KEYS) delete process.env[key]; + for (const key of PROXY_ENV_KEYS) { + if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; + } }); function installFake(script: (ws: FakeWebSocket) => void) { @@ -252,21 +241,6 @@ function installFake(script: (ws: FakeWebSocket) => void) { } describe("providerFetch routing", () => { - test("defaults omitted wsUpstream to HTTP SSE", async () => { - installFake(ws => { - ws.emit("open", {}); - ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); - }); - const sentinel = new Response("base"); - const provider = { - fetch: (async () => sentinel) as typeof fetch, - } as OcxProviderConfig; - const wrapped = providerFetch(provider, BOUNDED_WS_RUNTIME); - - expect(await wrapped(CODEX_URL, streamingInit())).toBe(sentinel); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - test("a canary runtime identity cannot open the WS transport", async () => { const sentinel = new Response("base"); let baseCalls = 0; @@ -294,7 +268,6 @@ describe("providerFetch routing", () => { const baseCalls: string[] = []; const sentinel = new Response("base"); const provider = { - wsUpstream: true, fetch: (async (input: unknown) => { baseCalls.push(String(input)); return sentinel.clone(); @@ -318,44 +291,6 @@ describe("providerFetch routing", () => { expect(FakeWebSocket.instances).toHaveLength(1); }); - test("routes to base fetch over HTTP SSE when provider.wsUpstream is false", async () => { - const sentinel = new Response("base"); - let baseCalls = 0; - const provider = { - wsUpstream: false, - fetch: (async () => { - baseCalls += 1; - return sentinel; - }) as typeof fetch, - } as OcxProviderConfig; - const wrapped = providerFetch(provider, BOUNDED_WS_RUNTIME); - - const res = await wrapped(CODEX_URL, streamingInit()); - expect(res).toBe(sentinel); - expect(baseCalls).toBe(1); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - - test("routes to base fetch over HTTP SSE when OCX_CODEX_WS_UPSTREAM is false or 0", async () => { - for (const envVal of ["false", "0"]) { - process.env.OCX_CODEX_WS_UPSTREAM = envVal; - const sentinel = new Response("base"); - let baseCalls = 0; - const provider = { - fetch: (async () => { - baseCalls += 1; - return sentinel; - }) as typeof fetch, - } as OcxProviderConfig; - const wrapped = providerFetch(provider, BOUNDED_WS_RUNTIME); - - const res = await wrapped(CODEX_URL, streamingInit()); - expect(res).toBe(sentinel); - expect(baseCalls).toBe(1); - expect(FakeWebSocket.instances).toHaveLength(0); - } - }); - test("routes an opt-in provider's Responses streams over its upstream WS", async () => { installFake(ws => { ws.emit("open", {}); @@ -397,7 +332,6 @@ describe("handleResponses Codex WS relay selection", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", - wsUpstream: true, }, }, } as OcxConfig; @@ -431,51 +365,6 @@ describe("handleResponses Codex WS relay selection", () => { expect(text).toContain("data: [DONE]"); }); - test("a canonical upstream WebSocket carries the bridge catalog and normalized collaboration frame", async () => { - const previousHome = process.env.CODEX_HOME; - const home = mkdtempSync(join(tmpdir(), "ocx-v2-bridge-ws-")); - writeFileSync(join(home, "config.toml"), "[features.multi_agent_v2]\nenabled = true\n"); - process.env.CODEX_HOME = home; - installFake(ws => { - ws.emit("open", {}); - ws.emit("message", { data: JSON.stringify({ type: "response.output_item.added", item: { - type: "function_call", id: "fc_bridge_ws", call_id: "call_bridge_ws", namespace: "ocx_agents", name: "spawn_agent", arguments: "", - } }) }); - ws.emit("message", { data: JSON.stringify({ type: "response.function_call_arguments.done", item_id: "fc_bridge_ws", arguments: "{}" }) }); - ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { - id: "resp_bridge_ws", status: "completed", output: [{ - type: "function_call", id: "fc_bridge_ws", call_id: "call_bridge_ws", namespace: "ocx_agents", name: "spawn_agent", arguments: "{}", - }], - } }) }); - }); - try { - const cfg = forwardConfig(); - cfg.multiAgentMode = "v2"; - cfg.v2RoutedDelegationBridge = true; - const response = await handleResponses(new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json", authorization: "Bearer test" }, - body: JSON.stringify({ model: "gpt-5.5", stream: true, input: "delegate", tools: [{ - type: "namespace", name: "collaboration", tools: [ - { type: "function", name: "spawn_agent", parameters: { type: "object" } }, - { type: "function", name: "send_message", parameters: { type: "object" } }, - ], - }] }), - }), cfg, { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); - - const frame = JSON.parse(FakeWebSocket.instances[0]!.sent[0]!) as Record; - const text = await response.text(); - expect(JSON.stringify(frame.tools)).toContain('"ocx_agents"'); - expect(text).toContain('"namespace":"collaboration"'); - expect(text).toContain('"encrypted_function_args":[]'); - expect(text).not.toContain('"namespace":"ocx_agents"'); - } finally { - if (previousHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousHome; - rmSync(home, { recursive: true, force: true }); - } - }); - test("an HTTP fallback remains on the configured legacy tee path", async () => { installFake(ws => ws.close()); globalThis.fetch = (async () => new Response( @@ -620,7 +509,7 @@ describe("codexWsUpstreamFetch", () => { headers: { authorization: "Bearer fixture", "content-type": "application/json", "x-openai-internal-codex-responses-lite": "true" }, body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true, service_tier: "priority" }), }), { - defaultProvider: "openai", providers: { openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex", wsUpstream: true } }, + defaultProvider: "openai", providers: { openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" } }, } as OcxConfig, { model: "", provider: "" }, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME }); await response.text(); expect(frames).toHaveLength(1); @@ -658,6 +547,41 @@ describe("codexWsUpstreamFetch", () => { expect(text).not.toContain("must-not-leak"); }); + test("passes the selected proxy without changing handshake headers", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); + }); + + await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("fallback must not run"); + }) as unknown as typeof fetch); + + const options = FakeWebSocket.instances[0]!.options; + expect(options?.proxy).toBe("http://proxy.example:8080"); + expect(options?.headers?.authorization).toBe("Bearer test"); + expect(options?.headers?.["openai-beta"]).toContain("responses_websockets"); + expect(options?.headers?.["content-type"]).toBeUndefined(); + }); + + test.each([ + ["unsupported protocol", "socks5://proxy.example:1080"], + ["invalid URL", "not a proxy URL"], + ])("falls back once without dialing for an %s", async (_label, proxy) => { + process.env.HTTPS_PROXY = proxy; + const sentinel = new Response("sse-fallback"); + let fallbackCalls = 0; + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { + fallbackCalls += 1; + return sentinel; + }) as typeof fetch); + + expect(response).toBe(sentinel); + expect(fallbackCalls).toBe(1); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + test("relays event frames as an SSE response and sends one response.create frame", async () => { installFake(ws => { ws.emit("open", {}); @@ -718,9 +642,283 @@ describe("codexWsUpstreamFetch", () => { expect(FakeWebSocket.instances[0].closed).toBe(true); }); + describe("wrapped create refusals", () => { + const refusal = { type: "error", status_code: 429, error: { + type: "usage_limit_reached", message: "The usage limit has been reached", plan_type: "plus", resets_at: 1_800_000_000, + } }; + const emit = (ws: FakeWebSocket, payload: Record) => + ws.emit("message", { data: JSON.stringify(payload, null, 2) }); + + async function receive(payload: Record, prelude: Record[] = [], + url = CODEX_URL, onQuota?: (headers: Headers) => void) { + installFake(ws => { + ws.emit("open", {}); + for (const event of prelude) emit(ws, event); + emit(ws, payload); + ws.emit("close", { code: 1000, reason: "normal" }); + }); + let attempts = 0; + let fallbacks = 0; + const response = await fetchWithTransientRetry(() => { + attempts++; + return rawCodexWsUpstreamFetch(url, streamingInit(), (async () => { + fallbacks++; + throw new Error("a sent create must not be resent over HTTP"); + }) as typeof fetch, BOUNDED_WS_RUNTIME, onQuota); + }, {}); + const ws = FakeWebSocket.instances.at(-1)!; + expect(attempts).toBe(1); + expect(fallbacks).toBe(0); + expect(ws.sent).toHaveLength(1); + expect(ws.closed).toBe(true); + expect([...ws.listeners.values()].every(listeners => listeners.length === 0)).toBe(true); + return response; + } + + // Independent oracle: openai/codex d2d5b702, responses_websocket.rs:1016-1064 + // explicitly accepts numeric window-minutes as the HTTP header string "15". + test.each(["status", "status_code"])("returns %s 429 as bounded HTTP JSON with scalar quota headers", async field => { + const { status_code, ...frame } = refusal; + const response = await receive({ ...frame, [field]: status_code, headers: { + "X-Codex-Primary-Used-Percent": "100.0", "X-Codex-Primary-Window-Minutes": 15, + "X-Codex-Primary-Reset-At": 1_800_000_000, "X-Codex-Credits-Has-Credits": true, + "Retry-After": 60, "X-Request-Id": "fixture-request", + "x-codex-extra-secondary-used-percent": "25", "x-ratelimit-remaining-requests": 0, + } }); + expect(response.status).toBe(429); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("x-codex-primary-used-percent")).toBe("100.0"); + expect(response.headers.get("x-codex-primary-window-minutes")).toBe("15"); + expect(response.headers.get("x-codex-primary-reset-at")).toBe("1800000000"); + expect(response.headers.get("x-codex-credits-has-credits")).toBe("true"); + expect(response.headers.get("retry-after")).toBe("60"); + expect(response.headers.get("x-request-id")).toBe("fixture-request"); + expect(response.headers.get("x-codex-extra-secondary-used-percent")).toBe("25"); + expect(response.headers.get("x-ratelimit-remaining-requests")).toBe("0"); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(isCodexWsQuotaObservedResponse(response)).toBe(false); + expect(await response.json()).toEqual({ error: refusal.error }); + }); + + test.each([400, 401, 402, 403, 404, 408, 499])("preserves a precommit HTTP %i refusal", async status_code => { + const response = await receive({ ...refusal, status_code }); + expect(response.status).toBe(status_code); + expect(await response.json()).toEqual({ error: refusal.error }); + }); + + test.each([ + { status_code: undefined }, { status_code: null }, { status_code: "429" }, { status_code: true }, + { status_code: 429.5 }, { status_code: 399 }, { status_code: 500 }, { status_code: 502 }, + { status_code: 503 }, { status_code: 599 }, { status_code: 429, status: 429 }, + { status_code: 502, status: 429 }, { status_code: null, status: 401 }, + { status_code: "bad", status: 401 }, { error: [] }, { error: "refused" }, + { error: { code: 42 } }, { error: { message: false } }, { headers: [] }, { headers: "bad" }, + { stream_id: "another-stream" }, + ])("keeps an ineligible wrapper on SSE without outer retry: %j", async fields => { + const response = await receive({ ...refusal, ...fields }); + expect(response.status).toBe(200); + expect(isCodexWsUpstreamResponse(response)).toBe(true); + expect(await response.text()).toContain("event: error\ndata: "); + }); + + test.each([undefined, null, {}])("handles an optional error object: %j", async error => { + const response = await receive({ ...refusal, error, headers: null }); + expect(response.status).toBe(429); + expect(await response.json()).toEqual({ error: error ?? { + type: "upstream_error", message: "Upstream rejected the request", + } }); + }); + + test("drops injection, credentials, framing and connection-nominated metadata", async () => { + const forbidden = ["Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie", "Content-Length", + "Content-Encoding", "Transfer-Encoding", "Keep-Alive", "Proxy-Connection", "TE", "Trailer", "Upgrade", + "Content-Range", "Content-Location", "ETag", "Last-Modified", "Digest", "Content-MD5", + "Access-Control-Allow-Origin", "Location", "WWW-Authenticate", "x-codex-private-token"]; + const error = { message: "refusal\r\nX-Injected: body text only" }; + const response = await receive({ ...refusal, error, headers: { + ...Object.fromEntries(forbidden.map(name => [name, "must-not-leak"])), + "Content-Type": "text/html", "Cache-Control": "public, max-age=3600", + Connection: "Retry-After, X-Codex-Primary-Used-Percent, content-type, cache-control", + connection: "X-Request-Id", "Retry-After": "60", "X-Request-Id": "must-not-leak", + "x-codex-primary-used-percent": "100", "x-codex-secondary-used-percent": "99", + "x-ratelimit-bad name": "invalid", "x-ratelimit-crlf": "ok\r\nSet-Cookie: injected", + "x-ratelimit-nul": "bad\0value", "x-ratelimit-nonbyte": "漢字", + "x-ratelimit-array": [1], "x-ratelimit-object": { value: 1 }, "x-ratelimit-null": null, + "X-RateLimit-Remaining": "2", "x-ratelimit-remaining": "3", + } }, [{ type: "codex.response.metadata", headers: { + "retry-after": "10", "x-request-id": "prelude-request", "x-codex-primary-used-percent": "30", + } }]); + expect(response.status).toBe(429); + expect(Object.fromEntries(response.headers)).toEqual({ + "cache-control": "no-store", "content-type": "application/json", + "x-codex-secondary-used-percent": "99", "x-ratelimit-remaining": "3", + }); + expect(await response.json()).toEqual({ error }); + }); + + test("merges prelude quota with refusal updates without replaying the observer", async () => { + const observations: string[] = []; + const response = await receive({ ...refusal, headers: { "x-codex-primary-used-percent": 100 } }, [ + { type: "codex.rate_limits", rate_limits: { + primary: { used_percent: 30, window_minutes: 15, reset_at: 1_800_000_000 }, + secondary: { used_percent: 40, window_minutes: 10080, reset_at: 1_900_000_000 }, + } }, + { type: "codex.response.metadata", headers: { "x-models-etag": "prelude-catalog" } }, + ], CODEX_URL, headers => observations.push(headers.get("x-codex-primary-used-percent")!)); + expect(response.status).toBe(429); + expect(response.headers.get("x-codex-primary-used-percent")).toBe("100"); + expect(response.headers.has("x-codex-primary-window-minutes")).toBe(false); + expect(response.headers.has("x-codex-primary-reset-at")).toBe(false); + expect(response.headers.get("x-codex-secondary-used-percent")).toBe("40"); + expect(response.headers.get("x-codex-secondary-reset-at")).toBe("1900000000"); + expect(response.headers.get("x-models-etag")).toBe("prelude-catalog"); + expect(observations).toEqual(["30"]); + expect(isCodexWsQuotaObservedResponse(response)).toBe(false); + expect(await response.json()).toEqual({ error: refusal.error }); + }); + + const boundedHeaders = (count: number, value = "1") => + Object.fromEntries(Array.from({ length: count }, (_, i) => [`x-ratelimit-fixture-${i}`, value])); + const quotaFamilies = (count: number) => Object.fromEntries( + Array.from({ length: count }, (_, i) => [`x-codex-family-${i}-primary-used-percent`, "1"])); + test.each([ + ["value", { "x-models-etag": "x".repeat(4096) }, true], + ["value overflow", { "x-models-etag": "x".repeat(4097) }, false], + ["UTF-8 value", { "x-models-etag": "é".repeat(2048) }, true], + ["UTF-8 overflow", { "x-models-etag": "é".repeat(2049) }, false], + ["header count", boundedHeaders(128), true], ["header count overflow", boundedHeaders(129), false], + ["families", quotaFamilies(16), true], ["family overflow", quotaFamilies(17), false], + ["total bytes", boundedHeaders(8, "x".repeat(3990)), true], + ["total byte overflow", boundedHeaders(8, "x".repeat(4096)), false], + ] as Array<[string, Record, boolean]>)("enforces metadata budget: %s", async (_name, headers, accepted) => { + const response = await receive({ ...refusal, headers }); + if (accepted) { + expect(response.status).toBe(429); + for (const [name, value] of Object.entries(headers)) expect(response.headers.get(name)).toBe(value); + expect(await response.json()).toEqual({ error: refusal.error }); + } else { + expect(response.status).toBe(200); + expect(isCodexWsUpstreamResponse(response)).toBe(true); + await expect(response.text()).rejects.toThrow("metadata"); + } + }); + + test("bounds the cumulative prelude and rejection metadata even when updates replace values", async () => { + const response = await receive({ ...refusal, headers: boundedHeaders(5, "x".repeat(4096)) }, [ + { type: "codex.response.metadata", headers: boundedHeaders(4, "y".repeat(4096)) }, + ]); + expect(response.status).toBe(200); + await expect(response.text()).rejects.toThrow("metadata"); + }); + + test.each([ + ["response.created", 429], ["response.output_text.delta", 429], + ["response.in_progress", 429], ["response.created", 502], + ] as Array<[string, number]>)( + "does not convert or retry a refusal after %s (status %i)", async (type, status_code) => { + const response = await receive({ ...refusal, status_code }, [{ type, response: { id: "r1" }, delta: "output" }]); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain(`event: ${type}`); + expect(text).toContain("event: error"); + expect(response.headers.has("cache-control")).toBe(false); + }); + + test.each(["websocket_connection_limit_reached", "previous_response_not_found"])( + "does not add native special-code reconnect for %s", async code => { + const response = await receive({ type: "error", error: { code } }); + expect(response.status).toBe(200); + expect(await response.text()).toContain(code); + }); + + test("keeps noncanonical providers on the stream path", async () => { + const response = await receive(refusal, [], "https://gateway.example/v1/responses"); + expect(response.status).toBe(200); + expect(await response.text()).toContain("event: error"); + }); + + test.each([CODEX_URL, "https://gateway.example/v1/responses"])( + "settles synchronous error/send-throw/close races and detaches deadlines for %s", async url => { + jest.useFakeTimers(); + const abort = new AbortController(); + let fallbacks = 0; + try { + installFake(ws => { + ws.send = data => { + ws.sent.push(data); + emit(ws, refusal); + throw new Error("send threw after a response was received"); + }; + ws.emit("open", {}); + }); + const response = await rawCodexWsUpstreamFetch(url, { ...streamingInit(), signal: abort.signal }, + (async () => { fallbacks++; throw new Error("unexpected fallback"); }) as typeof fetch, BOUNDED_WS_RUNTIME); + const ws = FakeWebSocket.instances.at(-1)!; + abort.abort(new Error("late abort")); + ws.emit("error", {}); + emit(ws, { type: "codex.rate_limits", rate_limits: { primary: { used_percent: 10 } } }); + ws.emit("close", {}); + jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS + 10_000); + expect(response.status).toBe(url === CODEX_URL ? 429 : 200); + if (url === CODEX_URL) expect(await response.json()).toEqual({ error: refusal.error }); + else expect(await response.text()).toContain("event: error"); + expect(ws.sent).toHaveLength(1); + expect(ws.closed).toBe(true); + expect(fallbacks).toBe(0); + expect([...ws.listeners.values()].every(listeners => listeners.length === 0)).toBe(true); + } finally { jest.useRealTimers(); } + }); + + test.each([false, true])("disposes a retained socket; correlation precedes conversion (foreign stream: %s)", async foreign => { + installFake(ws => { + ws.emit("open", {}); + emit(ws, { type: "response.created", response: { id: "completed-first" } }); + emit(ws, { type: "response.completed", response: { id: "completed-first", status: "completed" } }); + }); + const init = streamingInit(); + const prepared = prepareCodexWsRequest(CODEX_URL, init)!; + const session = new CodexWsSession("wss://chatgpt.com/backend-api/codex/responses", prepared.headers, true); + let fallbacks = 0; + const options = { session, url: CODEX_URL, init, prepared, sseFallback: (async () => { + fallbacks++; + throw new Error("retained create must not fall back"); + }) as typeof fetch }; + try { + expect(session.reserve()).toBe(true); + await (await codexWsExchange(options)).text(); + expect(session.reused).toBe(true); + expect(session.closed).toBe(false); + const ws = FakeWebSocket.instances.at(-1)!; + let terminations = 0; + Object.assign(ws, { terminate: () => { terminations++; } }); + ws.send = data => { ws.sent.push(data); emit(ws, { ...refusal, ...(foreign ? { stream_id: "foreign" } : {}) }); }; + expect(session.reserve()).toBe(true); + const response = await codexWsExchange(options); + if (foreign) { + expect(response.status).toBe(200); + await expect(response.text()).rejects.toThrow("identity mismatch"); + } else { + expect(response.status).toBe(429); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(await response.json()).toEqual({ error: refusal.error }); + } + expect(ws.sent).toHaveLength(2); + expect(ws.closed).toBe(true); + expect(terminations).toBe(1); + expect(session.closed).toBe(true); + expect(session.busy).toBe(false); + expect(session.hasCompleted("completed-first")).toBe(false); + expect(session.reserve()).toBe(false); + expect(fallbacks).toBe(0); + expect([...ws.listeners.values()].every(listeners => listeners.length === 0)).toBe(true); + } finally { session.dispose(); } + }); + }); + test.each(["error", "response.completed"])("multiline upstream %s JSON remains one valid SSE data value", async type => { const payload = type === "error" - ? { type, status: 400, error: { type: "invalid_request_error", message: "fixture refusal" } } + ? { type, error: { type: "invalid_request_error", message: "fixture refusal" } } : { type, response: { id: "pretty-response", status: "completed", output: [] } }; installFake(ws => { ws.emit("open", {}); @@ -787,6 +985,7 @@ describe("codexWsUpstreamFetch", () => { }); test("falls back to the HTTP fetch when the upgrade is rejected before open", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8080"; installFake(ws => ws.close()); const sentinel = new Response("sse-fallback", { status: 429 }); let fallbackCalls = 0; @@ -799,6 +998,7 @@ describe("codexWsUpstreamFetch", () => { expect(response).toBe(sentinel); expect(isCodexWsUpstreamResponse(response)).toBe(false); expect(fallbackCalls).toBe(1); + expect(FakeWebSocket.instances[0]!.options?.proxy).toBe("http://proxy.example:8080"); }); test("falls back to the HTTP fetch when the upgrade deadline elapses without open or close", async () => { @@ -933,15 +1133,17 @@ describe("codexWsUpstreamFetch", () => { }); test("preserves caller headers on the handshake without fabricating an originator", async () => { - const seen: Record[] = []; + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + process.env.NO_PROXY = "chatgpt.com:443"; + const seen: FakeWebSocketOptions[] = []; FakeWebSocket.script = ws => { ws.emit("open", {}); ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); }; class HeaderCapturingWebSocket extends FakeWebSocket { - constructor(url: string, options?: { headers?: Record }) { - super(url); - seen.push(options?.headers ?? {}); + constructor(url: string, options?: FakeWebSocketOptions) { + super(url, options); + seen.push(options ?? {}); } } globalThis.WebSocket = HeaderCapturingWebSocket as unknown as typeof WebSocket; @@ -950,18 +1152,19 @@ describe("codexWsUpstreamFetch", () => { await codexWsUpstreamFetch(CODEX_URL, streamingInit(), fallback); // Without a caller originator none is invented: pool/forward traffic must // not impersonate Codex CLI (metadata-integrity contract). - expect(seen[0].originator).toBeUndefined(); - expect(seen[0]["openai-beta"]).toContain("responses_websockets"); - expect(seen[0].authorization).toBe("Bearer test"); + expect(seen[0].proxy).toBeUndefined(); + expect(seen[0].headers?.originator).toBeUndefined(); + expect(seen[0].headers?.["openai-beta"]).toContain("responses_websockets"); + expect(seen[0].headers?.authorization).toBe("Bearer test"); // HTTP body-framing headers do not belong on a WS handshake. - expect(seen[0]["content-type"]).toBeUndefined(); + expect(seen[0].headers?.["content-type"]).toBeUndefined(); // A genuine caller originator is forwarded verbatim. await codexWsUpstreamFetch(CODEX_URL, { ...streamingInit(), headers: { ...streamingInit().headers as Record, originator: "codex_cli_rs" }, }, fallback); - expect(seen[1].originator).toBe("codex_cli_rs"); + expect(seen[1].headers?.originator).toBe("codex_cli_rs"); }); test("aborting before open rejects like an aborted fetch", async () => { @@ -1006,7 +1209,7 @@ describe("codexWsUpstreamFetch", () => { }); const response = await rawCodexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { throw new Error("fallback must not run"); - }) as unknown as typeof fetch, BOUNDED_WS_RUNTIME, { wsUpstream: true }, headers => observations.push(headers.get("x-codex-primary-used-percent")!)); + }) as unknown as typeof fetch, BOUNDED_WS_RUNTIME, headers => observations.push(headers.get("x-codex-primary-used-percent")!)); expect(response.headers.get("x-codex-primary-used-percent")).toBe("10"); expect(observations).toEqual(["10", "20"]); await response.text(); @@ -1237,86 +1440,6 @@ describe("oversized Codex create frames", () => { expect(FakeWebSocket.instances).toHaveLength(1); }); - test("routes over SSE when frame exceeds provider.maxWsFrameBytes", async () => { - installFake(() => { throw new Error("WS must not be dialed for frame exceeding maxWsFrameBytes"); }); - const sentinel = new Response("sse-fallback"); - let fallbackCalls = 0; - const fallback = (async () => { - fallbackCalls += 1; - return sentinel; - }) as unknown as typeof fetch; - - const customMax = 1000; - const response = await codexWsUpstreamFetch( - CODEX_URL, - streamingInit({ padding: "x".repeat(customMax) }), - fallback, - { maxWsFrameBytes: customMax }, - ); - expect(response).toBe(sentinel); - expect(fallbackCalls).toBe(1); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - - test("routes over SSE when frame exceeds OCX_CODEX_WS_MAX_FRAME_BYTES environment ceiling", async () => { - process.env.OCX_CODEX_WS_MAX_FRAME_BYTES = "2000"; - installFake(() => { throw new Error("WS must not be dialed for frame exceeding OCX_CODEX_WS_MAX_FRAME_BYTES"); }); - const sentinel = new Response("sse-fallback"); - let fallbackCalls = 0; - const fallback = (async () => { - fallbackCalls += 1; - return sentinel; - }) as unknown as typeof fetch; - - const response = await codexWsUpstreamFetch( - CODEX_URL, - streamingInit({ padding: "x".repeat(2000) }), - fallback, - ); - expect(response).toBe(sentinel); - expect(fallbackCalls).toBe(1); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - - test("never lets provider or environment ceilings exceed the backend hard limit", async () => { - installFake(ws => { - ws.emit("open", {}); - ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); - }); - const oversized = streamingInit({ padding: "x".repeat(CODEX_WS_CREATE_FRAME_LIMIT_BYTES) }); - const sentinel = new Response("sse-fallback"); - const fallback = (async () => sentinel) as unknown as typeof fetch; - - expect(await codexWsUpstreamFetch( - CODEX_URL, - oversized, - fallback, - { maxWsFrameBytes: MAX_CODEX_WS_CREATE_FRAME_BYTES + 1 }, - )).toBe(sentinel); - expect(FakeWebSocket.instances).toHaveLength(0); - - process.env.OCX_CODEX_WS_MAX_FRAME_BYTES = String(MAX_CODEX_WS_CREATE_FRAME_BYTES + 1); - expect(await codexWsUpstreamFetch(CODEX_URL, oversized, fallback)).toBe(sentinel); - expect(FakeWebSocket.instances).toHaveLength(0); - }); - - test("providerFetch respects maxWsFrameBytes config and routes oversized frames to HTTP SSE", async () => { - const seen: RequestInit[] = []; - const provider = { - maxWsFrameBytes: 1500, - fetch: (async (_input: unknown, init: RequestInit) => { - seen.push(init); - return new Response("sse-direct"); - }) as unknown as typeof fetch, - } as unknown as OcxProviderConfig; - const wrapped = providerFetch(provider, BOUNDED_WS_RUNTIME); - - const res = await wrapped(CODEX_URL, streamingInit({ padding: "x".repeat(1500) })); - expect(await res.text()).toBe("sse-direct"); - expect(FakeWebSocket.instances).toHaveLength(0); - expect(seen).toHaveLength(1); - }); - // The unit tests above measure the helper; these two measure the REAL serialized // frame, one byte on each side of the limit. That distinction matters because the // request body is not the frame: `stream` is deleted and `type` is added before @@ -1407,6 +1530,8 @@ describe("oversized Codex create frames", () => { }); test("dials the configured provider's own wss URL for an opt-in upstream", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + process.env.NO_PROXY = "sub2api.example.com:443"; installFake(ws => { ws.emit("open", {}); ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r-ws" } }) }); @@ -1419,6 +1544,7 @@ describe("oversized Codex create frames", () => { ); expect(FakeWebSocket.instances).toHaveLength(1); expect(FakeWebSocket.instances[0]!.url).toBe("wss://sub2api.example.com/v1/responses"); + expect(FakeWebSocket.instances[0]!.options?.proxy).toBeUndefined(); expect(response.headers.get("content-type")).toContain("text/event-stream"); expect(await response.text()).toContain("response.completed"); }); diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 97c927e391..b1ce38b8eb 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { comboStreamPayloadCommitsOutput, preflightComboStreamResponse, @@ -13,6 +13,41 @@ const sse = (...payloads: unknown[]): Response => new Response( const preflightChunkLimit = Math.max(1, Math.ceil(MAX_CLIENT_SSE_FRAME_BYTES / 1024)); +function prefixThenReadError(prefix: Uint8Array, error: Error): { + response: Response; + cancelSpy: () => ReturnType | undefined; +} { + let sentPrefix = false; + let cancelSpy: ReturnType | undefined; + const stream = new ReadableStream({ + pull(controller) { + if (!sentPrefix) { + sentPrefix = true; + controller.enqueue(prefix); + return; + } + return Promise.reject(error); + }, + }); + const originalGetReader = stream.getReader.bind(stream); + stream.getReader = (() => { + const reader = originalGetReader(); + cancelSpy = spyOn(reader, "cancel"); + return reader; + }) as ReadableStream["getReader"]; + return { + response: new Response(stream, { headers: { "content-type": "text/event-stream" } }), + cancelSpy: () => cancelSpy, + }; +} + +const createdPrefix = new TextEncoder().encode(`data: ${JSON.stringify({ + type: "response.created", + response: { id: "r1", status: "in_progress" }, +})} + +`); + describe("combo stream preflight", () => { test("keeps only lifecycle preamble replayable and treats unknown output conservatively", () => { expect(comboStreamPayloadCommitsOutput({ type: "response.created" })).toBe(false); @@ -256,4 +291,176 @@ describe("combo stream preflight", () => { }); expect(JSON.stringify(body)).not.toContain("provider_trace_id"); }); + + const DECRYPT_REJECTION = + "Encrypted function output content could not be decrypted or decoded."; + + const exactDecryptRetryable = (payload: unknown): boolean => { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const event = payload as { + type?: unknown; + message?: unknown; + error?: { message?: unknown }; + response?: { error?: { message?: unknown } }; + }; + if (event.type !== "error" && event.type !== "response.failed" && event.type !== "response.incomplete") { + return false; + } + const message = event.error?.message + ?? event.response?.error?.message + ?? (event.type === "error" ? event.message : undefined); + return message === DECRYPT_REJECTION; + }; + + test("default 2-arg preflight commits a bare error, including exact decrypt, and preserves bytes", async () => { + expect(comboStreamPayloadCommitsOutput({ type: "error" })).toBe(true); + for (const payload of [ + { type: "error", message: "unrelated upstream busy" }, + { type: "error", message: DECRYPT_REJECTION }, + { type: "error", error: { message: DECRYPT_REJECTION } }, + ]) { + const source = sse( + { type: "response.created", response: { id: "r1", status: "in_progress" } }, + payload, + ); + const expected = await source.clone().text(); + const result = await preflightComboStreamResponse(source, { model: "m1", provider: "a" }); + expect(result.kind).toBe("accepted"); + expect(await result.response.text()).toBe(expected); + } + }); + + test("explicit 3-arg decrypt predicate converts a pre-output bare error into a failed terminal", async () => { + const source = sse( + { type: "response.created", response: { id: "r1", status: "in_progress" } }, + { type: "error", message: DECRYPT_REJECTION }, + ); + const original = await source.clone().text(); + const result = await preflightComboStreamResponse( + source, + { model: "m1", provider: "a" }, + exactDecryptRetryable, + ); + + expect(result.kind).toBe("failed"); + expect(result.response.status).toBe(502); + expect(result.response.headers.get("content-type")).toContain("application/json"); + expect(await result.response.text()).not.toBe(original); + }); + + test("an unrelated error followed by a matching failed terminal does not retry", async () => { + const source = sse( + { type: "response.created", response: { id: "r1", status: "in_progress" } }, + { type: "error", message: "unrelated upstream busy" }, + { + type: "response.failed", + response: { + status: "failed", + error: { type: "server_error", message: DECRYPT_REJECTION }, + }, + }, + ); + const expected = await source.clone().text(); + const result = await preflightComboStreamResponse( + source, + { model: "m1", provider: "a" }, + exactDecryptRetryable, + ); + + expect(result.kind).toBe("accepted"); + expect(await result.response.text()).toBe(expected); + }); + + test("output before a decrypt bare error does not retry", async () => { + const source = sse( + { type: "response.created", response: { id: "r1", status: "in_progress" } }, + { type: "response.output_text.delta", delta: "visible" }, + { type: "error", message: DECRYPT_REJECTION }, + ); + const expected = await source.clone().text(); + const result = await preflightComboStreamResponse( + source, + { model: "m1", provider: "a" }, + exactDecryptRetryable, + ); + + expect(result.kind).toBe("accepted"); + expect(await result.response.text()).toBe(expected); + }); + + test("default missing content-type is refused, and allowMissingContentType accepts only an absent type", async () => { + const payloads = [ + { type: "response.created", response: { id: "r1", status: "in_progress" } }, + { type: "error", message: DECRYPT_REJECTION }, + ]; + const body = payloads.map(payload => "data: " + JSON.stringify(payload) + "\n\n").join(""); + const encoded = () => new TextEncoder().encode(body); + const missingTypeResponse = () => { + const headers = new Headers(); + headers.delete("content-type"); + const response = new Response(encoded(), { headers }); + response.headers.delete("content-type"); + return response; + }; + + const missing = missingTypeResponse(); + expect(missing.headers.get("content-type")).toBeNull(); + const missingDefault = await preflightComboStreamResponse(missing, { model: "m1", provider: "a" }); + expect(missingDefault.kind).toBe("accepted"); + expect(await missingDefault.response.text()).toBe(body); + + const allowedMissingSource = missingTypeResponse(); + expect(allowedMissingSource.headers.get("content-type")).toBeNull(); + const allowedMissing = await preflightComboStreamResponse( + allowedMissingSource, + { model: "m1", provider: "a" }, + exactDecryptRetryable, + { allowMissingContentType: true }, + ); + expect(allowedMissing.kind).toBe("failed"); + expect(allowedMissing.response.status).toBe(502); + + for (const contentType of ["application/json", "text/plain"]) { + const source = new Response(encoded(), { headers: { "content-type": contentType } }); + const result = await preflightComboStreamResponse( + source, + { model: "m1", provider: "a" }, + exactDecryptRetryable, + { allowMissingContentType: true }, + ); + expect(result.kind).toBe("accepted"); + expect(await result.response.text()).toBe(body); + } + }); + + test("default reader.read rejection still throws and does not cancel the reader", async () => { + const readError = new Error("preflight-read-reset"); + const source = prefixThenReadError(createdPrefix, readError); + await expect(preflightComboStreamResponse(source.response, { model: "m1", provider: "a" })) + .rejects.toBe(readError); + expect(source.cancelSpy()).toBeDefined(); + expect(source.cancelSpy()!.mock.calls).toHaveLength(0); + }); + + test("replayReadErrors accepts a reconstructed prefix and the same reader.read error", async () => { + const readError = new Error("preflight-read-reset"); + const source = prefixThenReadError(createdPrefix, readError); + const result = await preflightComboStreamResponse( + source.response, + { model: "m1", provider: "a" }, + undefined, + { replayReadErrors: true }, + ); + expect(result.kind).toBe("accepted"); + expect(source.cancelSpy()).toBeDefined(); + expect(source.cancelSpy()!.mock.calls).toHaveLength(0); + const reader = result.response.body!.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + expect(first.value).toEqual(createdPrefix); + await expect(reader.read()).rejects.toBe(readError); + expect(source.cancelSpy()).toBeDefined(); + expect(source.cancelSpy()!.mock.calls).toHaveLength(0); + }); + }); diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index 4e06b25a46..3f7763b0aa 100644 --- a/tests/routing/routing-capability-model-matching.test.ts +++ b/tests/routing/routing-capability-model-matching.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validateConfigCandidate } from "../../src/config"; +import { NoEligiblePolicyCandidateError, routeModel, routedProviderConfig } from "../../src/router"; import { candidateCapabilityEvidence } from "../../src/routing/capability"; +import { assemblePolicyCandidateEvidence } from "../../src/routing/compatibility/assemble"; import { evaluatePolicyProfile } from "../../src/routing/evaluator"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { getRoutingProfile } from "../../src/routing/profile"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import { modelRecordValue } from "../../src/reasoning-effort"; import { isModelTextOnly } from "../../src/vision"; -import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import type { OcxConfig, OcxProviderConfig, OcxRoutingProfileConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; /** * `candidateCapabilityEvidence` describes what the resolver will do with a candidate, @@ -35,6 +44,278 @@ function configFor(provider: OcxProviderConfig): OcxConfig { return { providers: { custom: provider } } as unknown as OcxConfig; } +describe("policy capability evidence uses the effective provider", () => { + let testDir: string; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-effective-capability-")); + process.env.OPENCODEX_HOME = testDir; + }); + + afterEach(() => { + closeRequestHistoryIndex(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); + }); + + function policyConfig( + name: string, + provider: OcxProviderConfig, + model: string, + require: OcxRoutingProfileConfig["require"], + ): OcxConfig { + const result = validateConfigCandidate({ + port: 10100, + defaultProvider: name, + providers: { [name]: provider }, + routingProfiles: { guarded: { candidates: [{ provider: name, model }], require } }, + }); + if (!result.ok) throw new Error(result.error); + return result.config; + } + + const localOnly = { localOnly: true, remoteAllowed: false }; + const loopback = "http://127.0.0.1:11434/v1"; + + test("a loopback URL discarded by registry routing cannot satisfy a local-only policy", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "deepseek-v4-flash", localOnly); + const before = structuredClone(config); + + expect(routeModel(config, "deepseek/deepseek-v4-flash").provider.baseUrl) + .toBe("https://api.deepseek.com"); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + expect(config).toEqual(before); + }); + + test.each(["custom-local", "ollama"])("a genuine local %s endpoint remains eligible", name => { + const config = policyConfig(name, { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", localOnly); + const before = structuredClone(config); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe(name); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.requirements).toEqual([ + { id: "local-only", expected: true, actual: true, outcome: "satisfied" }, + { id: "remote-allowed", expected: false, actual: false, outcome: "satisfied" }, + ]); + expect(config).toEqual(before); + }); + + test("an explicitly public endpoint remains ineligible for a local-only policy", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", + }, "deepseek-v4-flash", localOnly); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("a local candidate is selected after excluding a registry-pinned remote candidate", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "deepseek-v4-flash", localOnly); + config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; + config.routingProfiles!.guarded!.candidates.push({ provider: "local", model: "local-model" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + }); + + test("registry no-vision defaults participate before policy image requirements", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", + modelInputModalities: { "deepseek-v4-flash": ["text", "image"] }, + }, "deepseek-v4-flash", { imageInput: true }); + const routed = routeModel(config, "deepseek/deepseek-v4-flash"); + expect(isModelTextOnly(routed.provider, routed.modelId)).toBe(true); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("the effective model context ceiling gates a policy requirement", () => { + const config = policyConfig("openai-apikey", { + adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", + modelContextWindows: { "gpt-6-astra": 2_000_000 }, + }, "gpt-6-astra", { minContextWindow: 1_500_000 }); + const routed = routeModel(config, "openai-apikey/gpt-6-astra"); + expect(routed.provider.modelContextWindows?.["gpt-6-astra"]).toBe(1_050_000); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("canonical forward auth filled by routing satisfies the encrypted-task requirement", () => { + const config = policyConfig("openai", { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + }, "gpt-5.5", { encryptedCodexTasks: true }); + + const route = routeModel(config, "policy/guarded"); + expect(route.provider.authMode).toBe("forward"); + expect(route.routeDecision?.candidates[0]?.capability?.encryptedCodexTasks).toBe(true); + expect(config.providers.openai!.authMode).toBeUndefined(); + }); + + test("the effective provider-wide reasoning ladder participates in policy selection", () => { + const config = policyConfig("xiaomi-mimo", { + adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", + }, "mimo-v2.5", { reasoningEffort: "high" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.provider.reasoningEfforts).toEqual(["low", "medium", "high"]); + expect(route.routeDecision?.candidates[0]?.capability?.reasoningEfforts) + .toEqual(["low", "medium", "high"]); + expect(config.providers["xiaomi-mimo"]!.reasoningEfforts).toBeUndefined(); + }); + + test("a same-named custom transport does not inherit an unrelated registry model map", () => { + const config = policyConfig("meta-model", { + adapter: "openai-responses", baseUrl: "https://custom.example/v1", + }, "muse-spark-1.3", { reasoningEffort: "high" }); + const routed = routeModel(config, "meta-model/muse-spark-1.3"); + expect(routed.provider.baseUrl).toBe("https://custom.example/v1"); + expect(routed.provider.modelReasoningEfforts).toBeUndefined(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("an invalid unselected transport cannot prevent a healthy sibling from routing", () => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + config.providers.ollama = { adapter: "openai-chat", baseUrl: " " }; + config.routingProfiles!.guarded!.candidates.push({ provider: "ollama", model: "local-model" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.candidates[1]?.capability).toBeUndefined(); + }); + + test("an unresolved transport contributes no positive capability evidence", () => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + modelInputModalities: { "local-model": ["text", "image"] }, + }, "local-model", { imageInput: true }); + config.providers.ollama!.baseUrl = " "; + + const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig, + }); + expect(evidence[0]?.capability).toBeUndefined(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("missing and disabled providers are not resolved for capability evidence", () => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", { tools: true }); + config.providers.disabled = { ...config.providers.local!, disabled: true }; + config.routingProfiles!.guarded!.candidates.push( + { provider: "missing", model: "model" }, + { provider: "disabled", model: "model" }, + ); + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }); + + expect(resolved).toEqual(["local"]); + expect(evidence[0]?.capability?.tools).toBe(true); + expect(evidence[1]?.capability).toBeUndefined(); + expect(evidence[2]?.capability).toBeUndefined(); + }); + + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `${unavailable} first candidate is excluded under %s unknown policy`, + capability => { + // Empty requirements prevent another capability guard from masking availability. + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + const profile = config.routingProfiles!.guarded!; + profile.candidates.unshift({ provider: unavailable, model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + + for (const withSibling of [true, false]) { + if (!withSibling) profile.candidates.pop(); + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence( + config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }, + ); + expect(resolved).toEqual(withSibling ? ["local"] : []); + expect(evidence).toHaveLength(withSibling ? 2 : 1); + expect(evidence[0]?.routeResolutionFailed).toBe(true); + expect(evidence[0]?.capability).toBeUndefined(); + const evaluation = evaluatePolicyProfile(config, "guarded", {}, evidence); + expect(evaluation.selectedIndex).toBe(withSibling ? 1 : null); + expect(evaluation.candidates[0]).toMatchObject({ + provider: unavailable, + eligible: false, + requirements: [], + exclusions: [{ code: "route-unavailable" }], + }); + if (withSibling) { + expect(evidence[1]?.routeResolutionFailed).toBeUndefined(); + expect(evidence[1]?.capability?.tools).toBe(true); + expect(evaluation.candidates[1]?.eligible).toBe(true); + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + expect(route.routeDecision?.candidates[0]?.exclusions).toEqual([{ code: "route-unavailable" }]); + } else { + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + } + } + }, + ); + } + + test.each(["allow", "penalize", "exclude"] as const)( + "an unresolved first candidate is excluded when unknown capabilities are %s", + capability => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + }, "local-model", {}); + config.providers.ollama!.baseUrl = " "; + config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; + const profile = config.routingProfiles!.guarded!; + profile.candidates.push({ provider: "local", model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + expect(route.routeDecision?.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + expect(JSON.stringify(route.routeDecision)).not.toContain("Invalid baseUrl"); + }, + ); + + test("all unresolved candidates produce a policy exclusion while explicit routing keeps validation", () => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + }, "local-model", {}); + config.providers.ollama!.baseUrl = " "; + + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + expect(() => routeModel(config, "ollama/local-model")).toThrow('Invalid baseUrl for provider "ollama"'); + }); +}); + describe("candidateCapabilityEvidence model matching", () => { test("a family entry covers its tagged siblings, as the resolver does", () => { const provider = providerWithFamilyEntries(); diff --git a/tests/routing/routing-profile.test.ts b/tests/routing/routing-profile.test.ts index aafca468dd..0736ec96fb 100644 --- a/tests/routing/routing-profile.test.ts +++ b/tests/routing/routing-profile.test.ts @@ -476,6 +476,67 @@ describe("routing profiles (RI-04)", () => { expect(body.candidates?.[1]).toMatchObject({ provider: "b", eligible: false }); }); + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `API dry-run excludes ${unavailable} provider under %s unknown policy`, + async capability => { + const config = baseConfig({ + providers: { + local: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true }, + }, + defaultProvider: "local", + routingProfiles: { + guarded: { + candidates: [ + { provider: unavailable, model: "local-model" }, + { provider: "local", model: "local-model" }, + ], + require: {}, + unknownEvidence: { capability }, + }, + }, + }); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + for (const withSibling of [true, false]) { + if (!withSibling) config.routingProfiles!.guarded!.candidates.pop(); + const req = new ManagementRequest("http://localhost/api/routing-profiles/dry-run", { + method: "POST", + headers: { "content-type": "application/json" }, + // No synthetic candidates: exercise the same assembly as runtime routing. + body: JSON.stringify({ profile: "guarded", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + const body = await response!.json() as { + selectedIndex: number | null; + candidates: Array<{ + provider: string; + eligible: boolean; + requirements: unknown[]; + exclusions: Array<{ code: string }>; + }>; + }; + expect(body.selectedIndex).toBe(withSibling ? 1 : null); + expect(body.candidates).toHaveLength(withSibling ? 2 : 1); + expect(body.candidates[0]).toMatchObject({ + provider: unavailable, + eligible: false, + requirements: [], + exclusions: [{ code: "route-unavailable" }], + }); + if (withSibling) { + expect(body.candidates[1]).toMatchObject({ provider: "local", eligible: true }); + } + } + }, + ); + } + test("API dry-run mirrors live codex cooldown for openai candidates", async () => { const { clearCodexUpstreamHealth, recordCodexUpstreamOutcome } = await import("../../src/codex/routing"); clearCodexUpstreamHealth(); diff --git a/tests/routing/subagent-roster-retention.test.ts b/tests/routing/subagent-roster-retention.test.ts index b710eeb58c..0a4271da0f 100644 --- a/tests/routing/subagent-roster-retention.test.ts +++ b/tests/routing/subagent-roster-retention.test.ts @@ -8,9 +8,17 @@ * allowlist, or removing a provider therefore used to silently shrink a deliberate * 5-model roster. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { handleManagementAPI } from "../../src/server/management-api"; import { ManagementRequest as Request } from "../helpers/management-auth"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { handleAgentSettingsRoutes } from "../../src/server/management/agent-settings-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { deleteConfigTopLevelKey, loadConfig, saveConfigPreservingClaudeCode } from "../../src/config"; +import { configHasRebaseProvenance, configRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../src/config/rebase-provenance"; import type { OcxConfig } from "../../src/types"; function makeConfig(overrides: Partial = {}): OcxConfig { @@ -72,3 +80,149 @@ describe("/api/subagent-models roster retention", () => { expect(available).toContain("gpt-5.6-terra"); }); }); + + +describe("picker updates preserve roster and persistence intent", () => { + let directory: string; + let previousHome: string | undefined; + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + directory = mkdtempSync(join(tmpdir(), "ocx-picker-settings-")); + process.env.OPENCODEX_HOME = directory; + }); + afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(directory); + }); + const rows = [{ provider: "alpha", id: "one" }, { provider: "beta", id: "two" }]; + function context(config: OcxConfig, body: unknown): ManagementContext { + const url = new URL("http://localhost/api/subagent-models"); + return { + url, config, version: "fixture", + req: new Request(url, { method: "PUT", body: JSON.stringify(body) }), + deps: { fetchAllModels: async () => rows, saveConfigPreservingClaudeCode: mock(() => {}) }, + convergeCodexCatalog: mock(async () => ({ status: "committed", changed: true, degraded: false, notices: [] } as const)), + syncClaudeAgentDefsBestEffort: mock(async () => {}), + }; + } + test("picker save/reset never changes retained roster, version, fallback or Claude agents", async () => { + const config = makeConfig({ subagentModels: ["missing/model", "account/gpt-5.5"], subagentModelsVersion: 1, + subagentModelFallback: ["fallback/model"] }); + for (const pickerOrder of [["beta/two", "alpha/one"], null, []]) { + const ctx = context(config, { pickerOrder, pickerOrderMode: "most-used" }); + const res = await handleAgentSettingsRoutes(ctx); + expect(res?.status).toBe(200); + expect(config.subagentModels).toEqual(["missing/model", "account/gpt-5.5"]); + expect(config.subagentModelsVersion).toBe(1); + expect(config.subagentModelFallback).toEqual(["fallback/model"]); + expect(ctx.syncClaudeAgentDefsBestEffort).not.toHaveBeenCalled(); + expect(ctx.deps.saveConfigPreservingClaudeCode).toHaveBeenCalledTimes(1); + expect(ctx.convergeCodexCatalog).toHaveBeenCalledTimes(1); + const result = await res!.json() as { pickerOrder: string[]; pickerOrderMode: string | null }; + expect(result.pickerOrder).toEqual(pickerOrder ?? []); + expect(result.pickerOrderMode).toBe(pickerOrder?.length ? "most-used" : null); + } + }); + test("valid original roster arrays retain exact values, duplicates and five-slot cap", async () => { + const config = makeConfig({ modelPickerOrder: ["gpt-5.5", "alpha/one"], modelPickerOrderMode: "provider" }); + const values = ["missing/model", "missing/model", "account/gpt-5.5", " native ", "", "sixth"]; + const ctx = context(config, { models: values }); + expect((await handleAgentSettingsRoutes(ctx))?.status).toBe(200); + expect(config.subagentModels).toEqual(values.slice(0, 5)); + expect(config.modelPickerOrder).toEqual(["gpt-5.5", "alpha/one"]); + expect(config.modelPickerOrderMode).toBe("provider"); + expect(ctx.syncClaudeAgentDefsBestEffort).toHaveBeenCalledTimes(1); + }); + test.each([null, [], 1, "bad", {}, { pickerOrderMode: "provider" }, { models: null }, + { models: [1] }, { pickerOrder: [" "] }, { pickerOrder: ["alpha/one", " alpha/one "] }, + { pickerOrder: ["absent/model"], models: ["replacement"] }, + { pickerOrder: null, pickerOrderMode: "custom" }, { pickerOrder: ["gpt-5.5"] }, + ])("invalid body %j is rejected before any mutation", async body => { + const config = makeConfig({ subagentModels: ["keep"], modelPickerOrder: ["alpha/one"], modelPickerOrderMode: "most-used" }); + const before = structuredClone(config); + const ctx = context(config, body); + expect((await handleAgentSettingsRoutes(ctx))?.status).toBe(400); + expect(config).toEqual(before); + expect(ctx.deps.saveConfigPreservingClaudeCode).not.toHaveBeenCalled(); + expect(ctx.convergeCodexCatalog).not.toHaveBeenCalled(); + }); + test("picker eligibility uses current allowlists and disabled rows, not retained roster membership", async () => { + const config = makeConfig({ subagentModels: ["alpha/one", "beta/two"], disabledModels: ["beta/two"], + providers: { alpha: { adapter: "openai-chat", baseUrl: "https://example.test/v1", selectedModels: ["other"] } } }); + const ctx = context(config, {}); + ctx.req = new Request(ctx.url); + const res = await handleAgentSettingsRoutes(ctx); + const result = await res!.json() as { available: string[]; pickerAvailable: string[] }; + expect(result.available).toContain("alpha/one"); + expect(result.available).toContain("beta/two"); + expect(result.pickerAvailable).toEqual([]); + for (const id of ["alpha/one", "beta/two"]) { + expect((await handleAgentSettingsRoutes(context(config, { pickerOrder: [id] })))?.status).toBe(400); + } + }); + test.each([{ pickerOrder: null }, { pickerOrder: ["beta/two"] }])("failed picker save %j restores fields AND deletion provenance", async ({ pickerOrder }) => { + const config = makeConfig({ subagentModels: ["keep"], modelPickerOrder: ["alpha/one"], modelPickerOrderMode: "most-used" }); + deleteConfigTopLevelKey(config, "streamMode"); + const before = structuredClone(config); + const intent = [...configRebaseDeletionKeys(config)]; + const projected = projectConfigRebaseProvenance(config); + const ctx = context(config, { models: ["replacement"], pickerOrder }); + const save = mock((candidate: OcxConfig) => { + expect(candidate.subagentModels).toEqual(["replacement"]); + expect(candidate.modelPickerOrder).toEqual(pickerOrder ?? undefined); + throw new Error("disk full"); + }); + ctx.deps.saveConfigPreservingClaudeCode = save; + await expect(handleAgentSettingsRoutes(ctx)).rejects.toThrow("disk full"); + expect(save).toHaveBeenCalledTimes(1); + expect(config).toEqual(before); + expect([...configRebaseDeletionKeys(config)]).toEqual(intent); + expect(projectConfigRebaseProvenance(config)).toEqual(projected); + expect(ctx.convergeCodexCatalog).not.toHaveBeenCalled(); + // The next unrelated real save must not carry a phantom picker deletion. + saveConfigPreservingClaudeCode(config); + expect(loadConfig().modelPickerOrder).toEqual(["alpha/one"]); + expect(loadConfig().modelPickerOrderMode).toBe("most-used"); + }); + test("unknown deletion provenance rejects picker writes without overwriting the newer format", async () => { + const config = makeConfig({ modelPickerOrder: ["alpha/one"], configRebaseProvenance: { version: 2, deletedTopLevelKeys: [] } }); + const before = structuredClone(config); + const ctx = context(config, { pickerOrder: null }); + expect((await handleAgentSettingsRoutes(ctx))?.status).toBe(409); + expect(config).toEqual(before); + expect(ctx.deps.saveConfigPreservingClaudeCode).not.toHaveBeenCalled(); + }); + test("failed clear from absent fields leaves no phantom deletion or provenance", async () => { + const config = makeConfig(); + const ctx = context(config, { pickerOrder: null }); + ctx.deps.saveConfigPreservingClaudeCode = () => { throw new Error("disk full"); }; + await expect(handleAgentSettingsRoutes(ctx)).rejects.toThrow(); + expect(configHasRebaseProvenance(config)).toBe(false); + expect([...configRebaseDeletionKeys(config)]).toEqual([]); + expect(config).not.toHaveProperty("modelPickerOrder"); + }); + test("discovery yield cannot replace a concurrently saved roster", async () => { + const config = makeConfig({ subagentModels: ["old"] }); + const ctx = context(config, { pickerOrder: ["alpha/one"] }); + let release!: (models: typeof rows) => void; + let started!: () => void; + const enteredDiscovery = new Promise(resolve => { started = resolve; }); + ctx.deps.fetchAllModels = () => new Promise(resolve => { release = resolve; started(); }); + const pending = handleAgentSettingsRoutes(ctx); + await enteredDiscovery; + config.subagentModels = ["newer", "account/gpt-5.5"]; + release(rows); + const result = await (await pending)!.json() as { applied: string[] }; + expect(config.subagentModels).toEqual(["newer", "account/gpt-5.5"]); + expect(result.applied).toEqual(config.subagentModels); + }); + test("failed convergence reports durable order without rolling it back", async () => { + const config = makeConfig(); + const ctx = context(config, { pickerOrder: ["alpha/one"], pickerOrderMode: "provider" }); + ctx.convergeCodexCatalog = async () => ({ status: "failed", reason: "disk", phase: "commit", retryable: true, partialWrite: false }); + const result = await (await handleAgentSettingsRoutes(ctx))!.json() as { catalogRefresh: { status: string } }; + expect(result.catalogRefresh.status).toBe("failed"); + expect(config.modelPickerOrder).toEqual(["alpha/one"]); + }); +}); diff --git a/tests/server/agent-task-recovery-cache.test.ts b/tests/server/agent-task-recovery-cache.test.ts index 3107bff1a1..2ee994f8ce 100644 --- a/tests/server/agent-task-recovery-cache.test.ts +++ b/tests/server/agent-task-recovery-cache.test.ts @@ -1,8 +1,22 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + agentTaskRecoveryCacheSnapshotForTests, + agentTaskRecoveryWaiterCountForTests, + cachedAgentTaskRecovery, resetAgentTaskRecoveryCache, resolveCachedAgentTaskRecovery, } from "../../src/server/responses/agent-task-recovery-cache"; +import { + recoverEncryptedAgentTaskWithResult, + restoreCachedEncryptedAgentTasks, +} from "../../src/server/responses/agent-task-recovery"; +import { + codexHeaders, + encryptedInput, + originalFetch, + recoverySse, + routedConfig, +} from "../helpers/agent-task-recovery"; const realDateNow = Date.now; @@ -10,10 +24,218 @@ describe("agent task recovery cache", () => { beforeEach(() => resetAgentTaskRecoveryCache()); afterEach(() => { + globalThis.fetch = originalFetch; Date.now = realDateNow; resetAgentTaskRecoveryCache(); }); + test("shared failure gives each waiter its own result without contaminating another key", async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + let fetches = 0; + globalThis.fetch = (async () => { + const requestNumber = ++fetches; + await gate; + return requestNumber === 1 + ? new Response("raw-failure-sentinel", { status: 503 }) + : new Response(recoverySse("Independent assignment.")); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + const firstInput = encryptedInput(); + const secondInput = encryptedInput(); + const otherInput = encryptedInput(); + const first = recoverEncryptedAgentTaskWithResult(req, firstInput, {}, config); + const second = recoverEncryptedAgentTaskWithResult(req, secondInput, {}, config); + const other = recoverEncryptedAgentTaskWithResult(req, otherInput, {}, config, { parentThreadId: "other-parent" }); + try { + expect(agentTaskRecoveryWaiterCountForTests()).toBe(3); + expect(fetches).toBe(2); + release?.(); + const [firstResult, secondResult, otherResult] = await Promise.all([first, second, other]); + expect(firstResult).toEqual({ recovered: false, reason: "recovery_unavailable" }); + expect(secondResult).toEqual({ recovered: false, reason: "recovery_unavailable" }); + expect(firstResult).not.toBe(secondResult); + expect(otherResult).toEqual({ recovered: true }); + expect(firstInput).toEqual(encryptedInput()); + expect(secondInput).toEqual(encryptedInput()); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(0); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config, { parentThreadId: "other-parent" })).toBe(1); + expect(fetches).toBe(2); + } finally { + release?.(); + await Promise.all([first, second, other]); + } + }); + + for (const succeeds of [true, false]) { + test(`caller cancellation stays local when the remaining waiter ${succeeds ? "succeeds" : "fails"}`, async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + let sharedSignal: AbortSignal | null | undefined; + let fetches = 0; + globalThis.fetch = (async (_input, init) => { + fetches += 1; + sharedSignal = init?.signal; + await gate; + return succeeds ? new Response(recoverySse("Shared assignment.")) : new Response(null, { status: 503 }); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + const controller = new AbortController(); + const cancelledInput = encryptedInput(); + const first = recoverEncryptedAgentTaskWithResult(req, cancelledInput, {}, config, { abortSignal: controller.signal }); + const second = recoverEncryptedAgentTaskWithResult(req, encryptedInput(), {}, config); + try { + expect(agentTaskRecoveryWaiterCountForTests()).toBe(2); + controller.abort(new Error("private-cancellation-sentinel")); + expect(await first).toEqual({ recovered: false, reason: "caller_cancelled" }); + expect(cancelledInput).toEqual(encryptedInput()); + expect(sharedSignal?.aborted).toBe(false); + release?.(); + expect(await second).toEqual(succeeds + ? { recovered: true } + : { recovered: false, reason: "recovery_unavailable" }); + expect(fetches).toBe(1); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(succeeds ? 1 : 0); + } finally { + release?.(); + await Promise.all([first, second]); + } + }); + } + + test("already cancelled callers cannot inject a positive cache hit", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + let fetches = 0; + globalThis.fetch = (async () => { fetches += 1; return new Response(recoverySse("Cached assignment.")); }) as typeof fetch; + expect(await recoverEncryptedAgentTaskWithResult(req, encryptedInput(), {}, config)).toEqual({ recovered: true }); + const controller = new AbortController(); + controller.abort(); + const input = encryptedInput(); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, config, { abortSignal: controller.signal })) + .toEqual({ recovered: false, reason: "caller_cancelled" }); + expect(input).toEqual(encryptedInput()); + // The existing pre-abort/null path does not discard another caller's cache entry. + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(1); + expect(fetches).toBe(1); + }); + + test("cancellation after cache lookup retains the existing discard behavior", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + globalThis.fetch = (async () => new Response(recoverySse("Cached assignment."))) as typeof fetch; + expect(await recoverEncryptedAgentTaskWithResult(req, encryptedInput(), {}, config)).toEqual({ recovered: true }); + const controller = new AbortController(); + const input = encryptedInput(); + const pending = recoverEncryptedAgentTaskWithResult(req, input, {}, config, { abortSignal: controller.signal }); + // The cache lookup returned an assignment, but the caller has not resumed to inject it. + controller.abort(); + expect(await pending).toEqual({ recovered: false, reason: "caller_cancelled" }); + expect(input).toEqual(encryptedInput()); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + }); + + test("input replacement after admission reports input_changed and discards recovered plaintext", async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async () => { await gate; return new Response(recoverySse("Do not inject.")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + const input = encryptedInput(); + const pending = recoverEncryptedAgentTaskWithResult(req, input, {}, config); + try { + input[0] = { type: "message", role: "user", content: [] }; + const replaced = structuredClone(input); + release?.(); + expect(await pending).toEqual({ recovered: false, reason: "input_changed" }); + expect(input).toEqual(replaced); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(0); + } finally { + release?.(); + await pending; + } + }); + + test("recovery_unavailable does not imply a fetch when all flight slots are occupied", async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + const pending = Array.from({ length: 32 }, (_, index) => resolveCachedAgentTaskRecovery( + `occupied-${index}`, 200, async () => { await gate; return null; }, + )); + let fetches = 0; + globalThis.fetch = (async () => { fetches += 1; throw new Error("must-not-fetch"); }) as typeof fetch; + try { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const input = encryptedInput(); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())) + .toEqual({ recovered: false, reason: "recovery_unavailable" }); + expect(fetches).toBe(0); + expect(input).toEqual(encryptedInput()); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + } finally { + release?.(); + await Promise.all(pending); + } + }); + + test("read-only hits retain the original expiry and exact-expiry reads release UTF-8 bytes", async () => { + const insertedAt = 1_800_000_000_000; + let now = insertedAt; + Date.now = () => now; + let requests = 0; + expect(cachedAgentTaskRecovery("missing")).toBeNull(); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(await resolveCachedAgentTaskRecovery("task", 200, async () => { + requests++; + return "한😀"; // Three UTF-8 bytes plus four, rather than three UTF-16 code units. + })).toBe("한😀"); + + for (const elapsed of [0, 60_000, 15 * 60 * 1000 - 1]) { + now = insertedAt + elapsed; + expect(cachedAgentTaskRecovery("task")).toBe("한😀"); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 1, bytes: 7 }); + } + now = insertedAt + 15 * 60 * 1000; + expect(cachedAgentTaskRecovery("task")).toBeNull(); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(cachedAgentTaskRecovery("task")).toBeNull(); + expect(requests).toBe(1); + + // Repeated expiry reads must not subtract bytes belonging to a later entry. + await resolveCachedAgentTaskRecovery("later", 200, async () => "ok"); + expect(cachedAgentTaskRecovery("task")).toBeNull(); + expect(cachedAgentTaskRecovery("later")).toBe("ok"); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 1, bytes: 2 }); + }); + + test("read-only misses do not join or restart an in-flight recovery", async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + let requests = 0; + const pending = resolveCachedAgentTaskRecovery("pending", 200, async () => { + requests++; + await gate; + return "recovered"; + }); + try { + expect(cachedAgentTaskRecovery("pending")).toBeNull(); + expect(cachedAgentTaskRecovery("unknown")).toBeNull(); + expect(cachedAgentTaskRecovery("pending")).toBeNull(); + expect(agentTaskRecoveryWaiterCountForTests()).toBe(1); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(requests).toBe(1); + } finally { + release?.(); + await pending; + } + expect(cachedAgentTaskRecovery("pending")).toBe("recovered"); + expect(agentTaskRecoveryWaiterCountForTests()).toBe(0); + expect(requests).toBe(1); + }); + test("expires recovered plaintext after fifteen minutes", async () => { let now = 1_800_000_000_000; Date.now = () => now; diff --git a/tests/server/agent-task-recovery-combo.test.ts b/tests/server/agent-task-recovery-combo.test.ts index 0e6e22a590..8823270ff2 100644 --- a/tests/server/agent-task-recovery-combo.test.ts +++ b/tests/server/agent-task-recovery-combo.test.ts @@ -10,6 +10,11 @@ import { } from "../../src/responses/state"; import { resetAgentTaskRecoveryState } from "../../src/server/responses/agent-task-recovery"; import { agentTaskRecoveryCacheSnapshotForTests } from "../../src/server/responses/agent-task-recovery-cache"; +import { clearComboTargetCooldowns, coolComboTarget } from "../../src/combos/failover"; +import { + clearCachedProviderQuotas, + setCachedProviderQuotaForTests, +} from "../../src/providers/quota-routing-cache"; import { codexHeaders, encryptedInput, @@ -55,11 +60,15 @@ describe("combo path encrypted agent task recovery", () => { process.env["OPENCODEX_HOME"] = home; clearResponseStateMemoryForTests(); resetAgentTaskRecoveryState(); + clearCachedProviderQuotas(); + clearComboTargetCooldowns(); }); afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); + clearCachedProviderQuotas(); + clearComboTargetCooldowns(); clearResponseStateForTests(); removeTreeWithRetry(home); if (priorHome === undefined) delete process.env["OPENCODEX_HOME"]; @@ -172,6 +181,122 @@ describe("combo path encrypted agent task recovery", () => { expect(providerFetches).toBe(1); }); + test.each(["disabled", "cooldown"] as const)("recovers a mixed combo when the native target is blocked by %s", async (reason) => { + const config = comboConfig([ + { provider: "xai", model: "grok-4.5" }, + { provider: "openai", model: "gpt-5.5" }, + ]); + if (reason === "disabled") { + config.providers.openai!.disabled = true; + } else { + coolComboTarget("routed", { provider: "openai", model: "gpt-5.5" }, { cooldownMs: 60_000 }); + } + const assignment = "MIXED-RECOVERY-PRIVATE-ASSIGNMENT"; + const recoveryBodies: string[] = []; + const forwardedBodies: string[] = []; + globalThis.fetch = (async (input, init) => { + const body = typeof init?.body === "string" ? init.body : ""; + if (String(input).includes("chatgpt.com")) { + recoveryBodies.push(body); + return new Response(recoverySse(assignment), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + forwardedBodies.push(body); + return providerCompletion(); + }) as typeof fetch; + + const response = await post(config, "combo/routed", encryptedInput(), codexHeaders()); + await response.text(); + + expect(response.status).toBe(200); + expect(recoveryBodies).toHaveLength(1); + expect(forwardedBodies).toHaveLength(1); + expect(forwardedBodies[0]).toContain(assignment); + expect(forwardedBodies[0]).not.toContain(FERNET_TASK); + expect(responseContinuationRetainedStoreSnapshot().count).toBe(0); + }); + + test("fails closed without routed dispatch when mixed-combo recovery fails", async () => { + const config = comboConfig([ + { provider: "xai", model: "grok-4.5" }, + { provider: "openai", model: "gpt-5.5" }, + ]); + coolComboTarget("routed", { provider: "openai", model: "gpt-5.5" }, { cooldownMs: 60_000 }); + const urls: string[] = []; + globalThis.fetch = (async (input) => { + urls.push(String(input)); + return new Response("unavailable", { status: 503 }); + }) as typeof fetch; + + const response = await post(config, "combo/routed", encryptedInput(), codexHeaders()); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "unreadable_encrypted_agent_task" } }); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain("chatgpt.com/backend-api/codex/responses"); + expect(responseContinuationRetainedStoreSnapshot().count).toBe(0); + }); + + test("recovers once when the selected native target fails model authorization", async () => { + const config = comboConfig([ + { provider: "openai", model: "gpt-5.5" }, + { provider: "xai", model: "grok-4.5" }, + ]); + const assignment = "RECOVERED-AFTER-NATIVE-401"; + const chatgptBodies: string[] = []; + const forwardedBodies: string[] = []; + globalThis.fetch = (async (input, init) => { + const body = typeof init?.body === "string" ? init.body : ""; + if (String(input).includes("chatgpt.com")) { + chatgptBodies.push(body); + if (body.includes("capture_assignment")) { + return new Response(recoverySse(assignment), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return Response.json( + { error: { message: "model is not enabled for this account", code: "model_not_found" } }, + { status: 401 }, + ); + } + forwardedBodies.push(body); + return providerCompletion(); + }) as typeof fetch; + + const response = await post(config, "combo/routed", encryptedInput(), codexHeaders()); + await response.text(); + + expect(response.status).toBe(200); + expect(chatgptBodies).toHaveLength(2); + expect(chatgptBodies[0]).not.toContain("capture_assignment"); + expect(chatgptBodies[1]).toContain("capture_assignment"); + expect(forwardedBodies).toHaveLength(1); + expect(forwardedBodies[0]).toContain(assignment); + expect(forwardedBodies[0]).not.toContain(FERNET_TASK); + expect(responseContinuationRetainedStoreSnapshot().count).toBe(0); + }); + + test("does not recover when every mixed-combo target is unavailable", async () => { + const config = comboConfig([ + { provider: "xai", model: "grok-4.5" }, + { provider: "openai", model: "gpt-5.5" }, + ]); + coolComboTarget("routed", { provider: "openai", model: "gpt-5.5" }, { cooldownMs: 60_000 }); + setCachedProviderQuotaForTests("xai", { updatedAt: Date.now(), weeklyPercent: 100 }); + globalThis.fetch = (async () => { + throw new Error("No network call is permitted without an eligible execution target"); + }) as typeof fetch; + + const response = await post(config, "combo/routed", encryptedInput(), codexHeaders()); + + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: { code: "combo_unavailable" } }); + expect(responseContinuationRetainedStoreSnapshot().count).toBe(0); + }); + test("keeps an opted-in Responses target out of encrypted combo dispatch", async () => { const config = comboConfig([ { provider: "relay", model: "relay-model" }, @@ -251,4 +376,68 @@ describe("combo path encrypted agent task recovery", () => { expect(forwardedBodies[0]).toContain(FERNET_TASK); expect(forwardedBodies[0]).not.toContain("capture_assignment"); }); + + test.each([ + { site: "native-disabled", expectedNative: 0 }, + { site: "native-401", expectedNative: 1 }, + ] as const)("cancels $site recovery before routed dispatch or plaintext cache", async ({ site, expectedNative }) => { + const config = comboConfig([ + { provider: "openai", model: "gpt-5.5" }, + { provider: "xai", model: "grok-4.5" }, + ]); + if (site === "native-disabled") { + config.providers.openai!.disabled = true; + } + const controller = new AbortController(); + let markRecoveryStarted: (() => void) | undefined; + const recoveryStarted = new Promise((resolve) => { + markRecoveryStarted = resolve; + }); + let nativeFetches = 0; + let recoveryFetches = 0; + let routedFetches = 0; + globalThis.fetch = ((input, init) => { + const body = typeof init?.body === "string" ? init.body : ""; + if (!String(input).includes("chatgpt.com")) { + routedFetches += 1; + return Promise.resolve(providerCompletion()); + } + if (body.includes("capture_assignment")) { + recoveryFetches += 1; + markRecoveryStarted?.(); + return new Promise((_resolve, reject) => { + const signal = init?.signal; + const rejectAbort = () => reject(signal?.reason ?? new DOMException("aborted", "AbortError")); + if (signal?.aborted) rejectAbort(); + else signal?.addEventListener("abort", rejectAbort, { once: true }); + }); + } + nativeFetches += 1; + return Promise.resolve(Response.json( + { error: { message: "model is not enabled for this account", code: "model_not_found" } }, + { status: 401 }, + )); + }) as typeof fetch; + + const pending = post( + config, + "combo/routed", + encryptedInput(), + codexHeaders(), + controller.signal, + ); + await recoveryStarted; + controller.abort(new DOMException("client disconnected", "AbortError")); + const response = await pending; + await runPendingResponseStatePersistForTests(); + const payload = await response.json() as { error?: { code?: string } }; + + expect(response.status).toBe(499); + expect(payload).toMatchObject({ error: { code: "client_cancelled" } }); + expect(nativeFetches).toBe(expectedNative); + expect(recoveryFetches).toBe(1); + expect(routedFetches).toBe(0); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(responseContinuationRetainedStoreSnapshot().count).toBe(0); + }); }); diff --git a/tests/server/agent-task-recovery-security.test.ts b/tests/server/agent-task-recovery-security.test.ts index 2f4253d9a4..0822f39272 100644 --- a/tests/server/agent-task-recovery-security.test.ts +++ b/tests/server/agent-task-recovery-security.test.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { resetAgentTaskRecoveryState } from "../../src/server/responses/agent-task-recovery"; import { + discardEncryptedAgentTaskRecovery, + recoverEncryptedAgentTask, + recoverEncryptedAgentTaskWithResult, + resetAgentTaskRecoveryState, + restoreCachedEncryptedAgentTasks, +} from "../../src/server/responses/agent-task-recovery"; +import { + agentMessage, codexHeaders, encryptedInput, fakeChatGptJwt, @@ -24,6 +31,55 @@ describe("agent task recovery security", () => { resetAgentTaskRecoveryState(); }); + test("diagnoses unsupported envelopes before admission without exposing their content", async () => { + const req = new Request("http://localhost/v1/responses"); // No credentials. + let fetches = 0; + globalThis.fetch = (async () => { fetches += 1; throw new Error("must-not-fetch"); }) as typeof fetch; + const header = { type: "input_text", text: ROUTING_ENVELOPE }; + const encrypted = { type: "encrypted_content", encrypted_content: FERNET_TASK }; + const inputs = [ + agentMessage([header, encrypted, encrypted]), + agentMessage([header, { ...encrypted, encrypted_content: FERNET_TASK.slice(0, 50) }, + { ...encrypted, encrypted_content: FERNET_TASK.slice(50) }]), + agentMessage([{ ...header, text: ROUTING_ENVELOPE.replace("NEW_TASK", "new_task") }, encrypted]), + encryptedInput({ ciphertext: "unsupported-ciphertext-sentinel" }), + ]; + for (const input of inputs) { + const original = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())) + .toEqual({ recovered: false, reason: "unsupported_envelope" }); + expect(await recoverEncryptedAgentTask(req, input, {}, routedConfig())).toBe(false); + expect(input).toEqual(original); + } + expect(fetches).toBe(0); + }); + + test("typed admission denial cannot read or discard an authenticated cached assignment", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("private-assignment-sentinel")); + }) as typeof fetch; + expect(await recoverEncryptedAgentTaskWithResult(req, encryptedInput(), {}, config)) + .toEqual({ recovered: true }); + + const deniedHeaders = codexHeaders(); + deniedHeaders.set("chatgpt-account-id", "mismatched-account-sentinel"); + const denied = new Request(req.url, { headers: deniedHeaders }); + const input = encryptedInput(); + const original = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(denied, input, {}, config)) + .toEqual({ recovered: false, reason: "admission_denied" }); + expect(await recoverEncryptedAgentTask(denied, input, {}, config)).toBe(false); + expect(restoreCachedEncryptedAgentTasks(denied, input, config)).toBe(0); + discardEncryptedAgentTaskRecovery(denied, input, config); + expect(input).toEqual(original); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(1); + expect(fetches).toBe(1); + }); + test("uses only the fixed ChatGPT endpoint and forwards only allowlisted credentials", async () => { const accountId = "acct-boundary"; const token = fakeChatGptJwt(accountId); diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 6ee312b89f..ceb1c5b6b5 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1,7 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { warnAgentTaskRecoveryStartup } from "../../src/server"; -import { agentTaskRecoveryConfig, resetAgentTaskRecoveryState } from "../../src/server/responses/agent-task-recovery"; +import { + discardEncryptedAgentTaskRecovery, + recoverEncryptedAgentTask, + recoverEncryptedAgentTaskWithResult, + resetAgentTaskRecoveryState, + restoreCachedEncryptedAgentTasks, +} from "../../src/server/responses/agent-task-recovery"; import { agentTaskRecoveryWaiterCountForTests } from "../../src/server/responses/agent-task-recovery-cache"; import { agentMessage, @@ -29,6 +35,84 @@ describe("agent task recovery (opt-in, default off)", () => { resetAgentTaskRecoveryState(); }); + for (const messageType of ["NEW_TASK", "MESSAGE"] as const) { + test(`typed ${messageType} recovery preserves boolean, replay and discard contracts`, async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + const context = { parentThreadId: "parent-diagnostics" }; + const input = () => agentMessage([ + { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType) }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered diagnostic fixture.")); + }) as typeof fetch; + + const typedInput = input(); + expect(await recoverEncryptedAgentTaskWithResult(req, typedInput, {}, config, context)) + .toEqual({ recovered: true }); + const booleanInput = input(); + expect(await recoverEncryptedAgentTask(req, booleanInput, {}, config, context)).toBe(true); + expect(booleanInput).toEqual(typedInput); + expect(typedInput).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType) }, + { type: "input_text", text: "Recovered diagnostic fixture." }, + ], + }]); + const replay = input(); + expect(restoreCachedEncryptedAgentTasks(req, replay, config, context)).toBe(1); + expect(replay).toEqual(typedInput); + expect(fetches).toBe(1); + + const otherType = agentMessage([ + { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType === "MESSAGE" ? "NEW_TASK" : "MESSAGE") }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + expect(restoreCachedEncryptedAgentTasks(req, otherType, config, context)).toBe(0); + discardEncryptedAgentTaskRecovery(req, input(), config, context); + expect(restoreCachedEncryptedAgentTasks(req, input(), config, context)).toBe(0); + expect(fetches).toBe(1); + }); + } + + const failedRecoveries: Array<[string, () => Response]> = [ + ["HTTP 503", () => new Response("raw-error-sentinel", { status: 503 })], + ["network exception", () => { throw new Error("raw-error-sentinel"); }], + ["malformed SSE", () => new Response("data: {not-json}\n\n")], + ["missing completion", () => new Response(recoverySse("payload-sentinel").split("data: {\"type\":\"response.completed\"")[0])], + ["conflicting assignment", () => new Response(recoverySse("payload-sentinel") + recoveryCompletedSse("other-payload-sentinel"))], + ["failed terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.failed","response":{"error":{"message":"raw-error-sentinel"}}}\n\n')], + ["incomplete terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.incomplete"}\n\n')], + ["bare error", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"error","error":{"message":"raw-error-sentinel"}}\n\n')], + // Exact-case events are also used by the pinned official Codex source. Recovery's + // additional completed-status requirement remains deliberately stricter. + ["mixed-case completion", () => new Response(recoverySse("payload-sentinel").replace("response.completed", "Response.Completed"))], + ["mixed-case status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed"', '"status":"Completed"'))], + ["missing status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed",', ""))], + ["ciphertext assignment", () => new Response(recoverySse(FERNET_TASK))], + ]; + for (const [name, response] of failedRecoveries) { + test(`typed recovery keeps ${name} coarse and preserves false without retrying`, async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + let fetches = 0; + globalThis.fetch = (async () => { fetches += 1; return response(); }) as typeof fetch; + const input = encryptedInput(); + const original = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, config)) + .toEqual({ recovered: false, reason: "recovery_unavailable" }); + expect(input).toEqual(original); + expect(fetches).toBe(1); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(0); + expect(await recoverEncryptedAgentTask(req, input, {}, config)).toBe(false); + expect(fetches).toBe(2); // One request per explicit invocation; no internal retry. + expect(input).toEqual(original); + }); + } + test("keeps the disabled fail-fast response byte-identical to the absent feature", async () => { const snapshot = async (config: ReturnType) => { let fetchCalls = 0; @@ -138,10 +222,11 @@ describe("agent task recovery (opt-in, default off)", () => { encryptedInput(), codexHeaders(), ); - const json = await response.json() as { error?: { code?: string } }; + const json = await response.json() as { error?: { code?: string; recovery_reason?: string } }; expect(response.status).toBe(400); expect(json.error?.code).toBe("unreadable_encrypted_agent_task"); + expect(json.error?.recovery_reason).toBe("recovery_unavailable"); expect(fetchedUrls.length).toBeGreaterThan(0); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex"); }); @@ -693,34 +778,7 @@ describe("agent task recovery (opt-in, default off)", () => { expect(fetchedUrls).toHaveLength(1); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex/responses"); expect(await response.json()).toMatchObject({ - error: { code: "unreadable_encrypted_agent_task" }, + error: { code: "unreadable_encrypted_agent_task", recovery_reason: "recovery_unavailable" }, }); }); - - test("agentTaskRecoveryConfig defaults model to gpt-5.6-luna", () => { - const cfg = agentTaskRecoveryConfig(routedConfig({ enabled: true })); - expect(cfg?.model).toBe("gpt-5.6-luna"); - }); - - test("recovery request sends gpt-5.6-luna by default in recovery payload", async () => { - let recoveryBody: any; - globalThis.fetch = (async (input, init) => { - const url = String(input); - if (url === "https://chatgpt.com/backend-api/codex/responses") { - recoveryBody = JSON.parse(String(init?.body)); - return new Response(recoverySse("recovered assignment payload"), { status: 200 }); - } - return providerResponse(); - }) as typeof fetch; - - const response = await post( - routedConfig({ enabled: true }), - "xai/grok-4.5", - encryptedInput(), - codexHeaders(), - ); - - expect(response.status).toBe(200); - expect(recoveryBody?.model).toBe("gpt-5.6-luna"); - }); }); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts new file mode 100644 index 0000000000..a21f0168b6 --- /dev/null +++ b/tests/server/aside-profiles-routes.test.ts @@ -0,0 +1,252 @@ +import { loadConfig } from "../../src/config"; +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration } from "../../src/integrations/writer"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let home: string; +let store: IntegrationStateStore; +let config: OcxConfig; +let isolation: IsolatedCodexHome; +let priorOcxHome: string | undefined; +let saved: OcxConfig | undefined; +const env: NodeJS.ProcessEnv = {}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-profile-routes-")); + home = join(root, "home"); + priorOcxHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "config"); + isolation = installIsolatedCodexHome("ocx-aside-profile-codex-"); + store = createIntegrationStateStore(join(root, "store")); + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }], + sessions: { private: { accessToken: "do-not-project" } }, + })); + for (const id of [0,1,2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(path(id), JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + } + config = { port: 10100, hostname: "127.0.0.1", defaultProvider: "fixture", fastRows: false, providers: { + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one","two"] }, + } } as OcxConfig; + saved = undefined; + setIntegrationPathTestHooks({ home, env }); + setIntegrationMutationFlightTestHooks({ store }); +}); + +afterEach(() => { + setIntegrationPathTestHooks(null); + setIntegrationMutationFlightTestHooks(null); + isolation.restore(); + if (priorOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorOcxHome; + removeTreeWithRetry(root); +}); + +function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } +async function api(pathname: string, method = "GET", body?: unknown) { + return rawApi(pathname, method, body === undefined ? undefined : JSON.stringify(body)); +} +async function rawApi(pathname: string, method: string, body?: string) { + const url = new URL(`http://127.0.0.1:10100${pathname}`); + const response = await handleManagementAPI(new Request(url, { + method, headers: { Host: url.host, "content-type": "application/json" }, + ...(body === undefined ? {} : { body }), + }), url, config, { + saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, + createManagementConvergeCodex: catalogConvergenceFactory(), + refreshOwnedCatalogIntegrations: input => refreshOwnedCatalogIntegrations({ ...input, store, env, home }), + }); + if (!response) throw new Error("route missing"); + return response; +} + +async function prepareAsideSync(): Promise { + config.providers.fixture!.selectedModels = ["one"]; + const enabled = await api("/api/client-integrations/aside/profiles", "PUT", { enabled: true }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ ok: true }); + for (const id of [0, 1, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/one"]); + // Change the runtime selection without triggering a different endpoint's sync. + config.providers.fixture!.selectedModels = ["two"]; +} + +function fixtureModelIds(id: number): string[] { + return document(id).providers.opencodex.models + .filter((model: { id: string }) => model.id.startsWith("fixture/")) + .map((model: { id: string }) => model.id); +} + +test.each([undefined, "{}"])("Aside sync accepts body %j and refreshes every enabled profile with HTTP 200", async body => { + await prepareAsideSync(); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, clientId: "aside", + results: [0, 1, 2].map(profileId => ({ client: "aside", profileId, ok: true, changed: true })), + }); + for (const id of [0, 1, 2]) { + expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(document(id).theme).toBe("keep"); + expect(document(id).providers.personal).toEqual({ models: [] }); + } +}); + +test("bodyless Aside sync returns HTTP 207 for one conflict while refreshing its siblings", async () => { + await prepareAsideSync(); + const edited = document(1); + edited.providers.opencodex.baseUrl = "https://user-edit.example.test/v1"; + const editedBytes = JSON.stringify(edited); + writeFileSync(path(1), editedBytes); + const response = await api("/api/client-integrations/aside/sync", "POST"); + expect(response.status).toBe(207); + expect(await response.json()).toMatchObject({ + ok: false, clientId: "aside", results: [ + { client: "aside", profileId: 0, ok: true, changed: true }, + { client: "aside", profileId: 1, ok: false, state: "conflict", refusalReason: "conflict" }, + { client: "aside", profileId: 2, ok: true, changed: true }, + ], + }); + expect(readFileSync(path(1), "utf8")).toBe(editedBytes); + for (const id of [0, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(await (await api("/api/client-integrations/aside/profiles/1")).json()) + .toMatchObject({ enabled: true, state: "conflict" }); +}); + +test.each(['{"enabled":true}', '{"profile":1}', '{"overwriteConflict":true}', "[]", "null", "true", "{"])( + "Aside sync rejects nonempty options or invalid JSON %s before mutation", async body => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "invalid_aside_profile", clientId: "aside" }); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); + }, +); + +test.each(["?profile=0", "?profile=invalid", "?client=pi"])("bodyless Aside sync rejects selector %s", async selector => { + expect((await api(`/api/client-integrations/aside/sync${selector}`, "POST")).status).toBe(400); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test("Aside sync retains the JSON body size limit before accepting an empty-body fallback", async () => { + const response = await rawApi("/api/client-integrations/aside/sync", "POST", " ".repeat(MANAGEMENT_JSON_BODY_MAX_BYTES + 1)); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ error: "request body too large" }); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test.each(["/api/client-integrations/aside/profiles", "/api/client-integrations/aside/profiles/1"])( + "Aside PUT still requires its enabled body at %s", async pathname => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + expect((await api(pathname, "PUT")).status).toBe(400); + expect((await api(pathname, "PUT", {})).status).toBe(400); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + }, +); + +test("legacy connection refreshes all profiles, and an individual off survives selection refresh and reload", async () => { + expect(applyIntegration({ clientId: "aside", config, port: 10100, store, env, home, + models: [{ provider: "fixture", id: "one", namespaced: "fixture/one" }] }).ok).toBe(true); + const initial = await (await api("/api/client-integrations/aside/profiles")).json(); + expect(initial.profiles).toHaveLength(3); + expect(JSON.stringify(initial)).not.toContain("do-not-project"); + expect((await api("/api/selected-models", "PUT", { provider: "fixture", models: ["one"] })).status).toBe(200); + for (const id of [0,1,2]) { + expect(document(id).providers.opencodex.models.filter((m: { id: string }) => m.id.startsWith("fixture/")).map((m: { id: string }) => m.id)).toEqual(["fixture/one"]); + expect(document(id).theme).toBe("keep"); + expect(document(id).providers.personal).toEqual({ models: [] }); + } + expect((await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: false })).status).toBe(200); + config = structuredClone(saved!); + expect((await api("/api/selected-models", "PUT", { provider: "fixture", models: ["two"] })).status).toBe(200); + expect(document(1).providers.opencodex).toBeUndefined(); + for (const id of [0,2]) expect(document(id).providers.opencodex.models.some((m: { id: string }) => m.id === "fixture/two")).toBe(true); + const state = await (await api("/api/client-integrations/aside?profile=1")).json(); + expect(state).toMatchObject({ profileId: 1, enabled: false, state: "absent" }); +}); + +test("profile history and Undo cannot recreate an undone enable on the next sync", async () => { + const enabled = await (await api("/api/client-integrations/aside?profile=2", "PUT", { enabled: true })).json(); + expect(enabled.ok).toBe(true); + const journal = await (await api("/api/client-integrations/journal?client=aside&profile=2")).json(); + expect(journal.operations[0]).toMatchObject({ profileId: 2, opId: enabled.opId, undoable: true }); + expect((await api("/api/client-integrations/restore?client=aside&profile=2", "POST", { opId: enabled.opId })).status).toBe(200); + config = structuredClone(saved!); + await api("/api/selected-models", "PUT", { provider: "fixture", models: ["one"] }); + expect(document(2).providers.opencodex).toBeUndefined(); + expect(document(0).providers.opencodex).toBeUndefined(); +}); + +test.each(["../0", "01", "-1", "9007199254740992"])("rejects invalid profile %s before file mutation", async id => { + const before = [0,1,2].map(i => readFileSync(path(i), "utf8")); + const response = await api(`/api/client-integrations/aside?profile=${encodeURIComponent(id)}`, "PUT", { enabled: true }); + expect(response.status).toBe(400); + expect([0,1,2].map(i => readFileSync(path(i), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); +}); + +test("a non-Aside client cannot silently consume a profile selector", async () => { + expect((await api("/api/client-integrations/pi?profile=0", "PUT", { enabled: true })).status).toBe(400); + expect(saved).toBeUndefined(); +}); + + +test("invalid persisted profile policy fails closed without resetting the surrounding config", () => { + const configRoot = process.env.OPENCODEX_HOME!; + mkdirSync(configRoot, { recursive: true }); + writeFileSync(join(configRoot, "config.json"), JSON.stringify({ ...config, asideProfileSync: { allProfiles: true, profiles: { "1": "off" } } })); + const loaded = loadConfig(); + expect(loaded.asideProfileSync).toEqual({ allProfiles: false }); + expect(loaded.port).toBe(10100); + expect(loaded.providers.fixture).toBeDefined(); +}); + +test.each(["%61side", "as%69de"])("alternate Aside spelling %s cannot reach the legacy writer", async spelling => { + const before = [0,1,2].map(id => readFileSync(path(id), "utf8")); + expect((await api(`/api/client-integrations/${spelling}`, "PUT", { enabled: true })).status).toBe(400); + expect(saved).toBeUndefined(); + expect([0,1,2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(store.listOperations("aside")).toEqual([]); +}); + +test("conflicting client selectors cannot restore Aside or delete its history", async () => { + const on = await (await api("/api/client-integrations/aside/profiles/0", "PUT", { enabled: true })).json(); + await api("/api/client-integrations/aside/profiles/0", "PUT", { enabled: false }); + const before = readFileSync(path(0), "utf8"); + const policy = structuredClone(config.asideProfileSync); + expect((await api("/api/client-integrations/restore?client=pi&profile=0", "POST", { opId: on.opId })).status).toBe(400); + expect((await api(`/api/client-integrations/journal?client=pi&profile=0&opId=${on.opId}`, "DELETE")).status).toBe(400); + expect(readFileSync(path(0), "utf8")).toBe(before); + expect(config.asideProfileSync).toEqual(policy); + const history = await (await api("/api/client-integrations/aside/profiles/0/journal")).json(); + expect(history.operations.some((row: { opId: string }) => row.opId === on.opId)).toBe(true); +}); + +test("dedicated nested paths retain profile scope for status, history and restore", async () => { + const on = await (await api("/api/client-integrations/aside/profiles/2", "PUT", { enabled: true })).json(); + expect(on).toMatchObject({ ok: true, profileId: 2 }); + expect(await (await api("/api/client-integrations/aside/profiles/2")).json()).toMatchObject({ profileId: 2, enabled: true }); + expect((await api("/api/client-integrations/aside/profiles/2?profile=1", "PUT", { enabled: false })).status).toBe(400); + expect((await api("/api/client-integrations/aside/profiles/2/restore", "POST", { opId: on.opId })).status).toBe(200); + expect(document(2).providers.opencodex).toBeUndefined(); + expect(document(0).providers.opencodex).toBeUndefined(); +}); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 29908ff181..320eedd982 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -152,6 +152,22 @@ describe("Astra-first subagent upgrade", () => { } }); + test("picker preset provenance round-trips independently of the roster", () => { + const config = { ...getDefaultConfig(), subagentModels: ["saved/model"], + modelPickerOrder: ["provider/two", "provider/one"], modelPickerOrderMode: "most-used" as const }; + saveConfig(config); + const loaded = loadConfig(); + expect(loaded.modelPickerOrder).toEqual(config.modelPickerOrder); + expect(loaded.modelPickerOrderMode).toBe("most-used"); + expect(loaded.subagentModels).toEqual(["saved/model"]); + delete loaded.modelPickerOrder; + delete loaded.modelPickerOrderMode; + saveConfig(loaded); + expect(loadConfig().modelPickerOrder).toBeUndefined(); + expect(loadConfig().modelPickerOrderMode).toBeUndefined(); + expect(loadConfig().subagentModels).toEqual(["saved/model"]); + }); + test("startup upgrades the newest disk roster and preserves unrelated disk edits", () => { const legacy = { ...getDefaultConfig(), subagentModelsVersion: undefined, subagentModels: ["old"], claudeCode: {}, modelPickerOrder: ["old/model"] }; saveConfig(legacy); diff --git a/tests/server/consume-for-inspection-cancel.test.ts b/tests/server/consume-for-inspection-cancel.test.ts index 166642d4fc..681b91a717 100644 --- a/tests/server/consume-for-inspection-cancel.test.ts +++ b/tests/server/consume-for-inspection-cancel.test.ts @@ -317,3 +317,111 @@ describe("inspection consumer teardown", () => { expect(metadataSpy.disposes()).toBe(1); }); }); + +function errorFrame(payload: Record): Uint8Array { + return encoder.encode("data: " + JSON.stringify(payload) + "\n\n"); +} + +describe("consumeForInspection bare-error EOF finality", () => { + test("custom onParsedPayload still runs for a witnessed bare error", async () => { + const source = controlledStream(); + const parsed: unknown[] = []; + const terminals: string[] = []; + const done = new Promise(resolve => { + consumeForInspection( + source.stream, + status => terminals.push(status), + undefined, + resolve, + undefined, + undefined, + undefined, + undefined, + { onParsedPayload: payload => parsed.push(payload) }, + ); + }); + + source.push(errorFrame({ type: "error", message: "flat reset" })); + source.close(); + await done; + + expect(parsed).toEqual([{ type: "error", message: "flat reset" }]); + expect(terminals).toEqual(["failed"]); + }); + + test("a real completed terminal after a bare error still wins", async () => { + const source = controlledStream(); + const terminals: string[] = []; + const completed: unknown[] = []; + const done = new Promise(resolve => { + consumeForInspection( + source.stream, + status => terminals.push(status), + undefined, + resolve, + undefined, + undefined, + response => completed.push(response), + ); + }); + + source.push(errorFrame({ type: "error", error: { message: "nested reset" } })); + source.push(completedFrame("after-error")); + source.close(); + await done; + + expect(terminals).toEqual(["completed"]); + expect(completed).toHaveLength(1); + }); + + test("stale logCtx.upstreamError without a bare error remains incomplete", async () => { + const source = controlledStream(); + const logCtx: RequestLogContext = { model: "m", provider: "p", upstreamError: "stale borrowed failure" }; + let terminalStatus: string | null = null; + const done = new Promise(resolve => { + consumeForInspection( + source.stream, + status => { terminalStatus = status; }, + undefined, + resolve, + logCtx, + ); + }); + + source.push(encoder.encode("data: {\"type\":\"response.output_item.added\"}\n\n")); + source.close(); + await done; + + expect(terminalStatus).toBe("incomplete"); + }); + + test("cancellation after a bare error stays neutral", async () => { + const source = controlledStream(); + const ac = new AbortController(); + let terminals = 0; + let cancels = 0; + let markParsed!: () => void; + const parsed = new Promise(resolve => { markParsed = resolve; }); + const done = new Promise(resolve => { + consumeForInspection( + source.stream, + () => { terminals += 1; }, + ac.signal, + resolve, + undefined, + () => { cancels += 1; }, + undefined, + undefined, + { onParsedPayload: () => markParsed() }, + ); + }); + + source.push(errorFrame({ type: "error", message: "reset then cancel" })); + await parsed; + ac.abort(); + await done; + + expect(terminals).toBe(0); + expect(cancels).toBe(1); + }); +}); diff --git a/tests/server/local-management-direct-transport.test.ts b/tests/server/local-management-direct-transport.test.ts index 939a15dd03..c8c74ce4fa 100644 --- a/tests/server/local-management-direct-transport.test.ts +++ b/tests/server/local-management-direct-transport.test.ts @@ -6,9 +6,13 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { directLocalHttpFetch } from "../../src/server/direct-local-http"; import { repoPath, repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; const PID = 4242; const SECRET = "A".repeat(43); +const CONTROL_TIMEOUT_MS = 2_000; +// Startup/imports + control + two liveness probes + capability read + process exit. +const DIRECT_CHILD_BUDGET_MS = watchdogMs(3_000 + CONTROL_TIMEOUT_MS + 750 + 750 + 2_000 + 1_000); async function listen(server: Server, hostname = "127.0.0.1"): Promise { return await new Promise((resolve, reject) => { @@ -299,14 +303,22 @@ describe("local management direct transport", () => { const localClientUrl = pathToFileURL(repoPath("src", "server", "local-management-read-client.ts")).href; const capabilityUrl = pathToFileURL(repoPath("src", "lib", "local-management-capability.ts")).href; const childSource = ` + const phase = name => console.error("DIRECT_PHASE:" + name); + phase("imports"); const liveness = await import(${JSON.stringify(proxyLivenessUrl)}); const client = await import(${JSON.stringify(localClientUrl)}); const capability = await import(${JSON.stringify(capabilityUrl)}); const port = ${targetPort}; const pid = ${PID}; - const control = await fetch(\`http://127.0.0.1:\${port}/__proxy-control\`).then(response => response.json()); + phase("control"); + const control = await fetch(\`http://127.0.0.1:\${port}/__proxy-control\`, { + signal: AbortSignal.timeout(${CONTROL_TIMEOUT_MS}), + }).then(response => response.json()); + phase("identity"); const identity = await liveness.proxyIdentityAt(port, { hostname: "127.0.0.1", expectedPid: pid }); + phase("readiness"); const readiness = await liveness.probeReadiness(port, { hostname: "127.0.0.1", expectedPid: pid }); + phase("memory"); const read = await client.fetchBoundLocalManagementRead( { hostname: "127.0.0.1", port, pid, source: "runtime" }, capability.LOCAL_MANAGEMENT_READ_PATHS.systemMemory, @@ -319,6 +331,7 @@ describe("local management direct transport", () => { const memory = read.kind === "response" ? await read.response.json() : null; const result = { control, identity, readiness, readKind: read.kind, memory }; console.log(JSON.stringify(result)); + phase("complete"); if (control?.via !== "proxy" || identity?.pid !== pid || readiness?.ready !== true || read.kind !== "response" || memory?.pid !== pid) { process.exitCode = 2; } @@ -338,20 +351,17 @@ describe("local management direct transport", () => { stderr: "pipe", }); let childTimedOut = false; - // A fresh Bun process took longer than 3s to boot on a loaded hosted - // Windows shard (promotion run 33743291747). Keep the child deadline - // inside the enclosing test budget while leaving each transport probe's - // own 2s semantic timeout unchanged. const childWatchdog = setTimeout(() => { childTimedOut = true; child.kill(); - }, 20_000); + }, DIRECT_CHILD_BUDGET_MS); const [exitCode, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]).finally(() => clearTimeout(childWatchdog)); - if (childTimedOut) throw new Error("direct-transport child timed out"); + const phase = [...stderr.matchAll(/DIRECT_PHASE:(imports|control|identity|readiness|memory|complete)/g)].at(-1)?.[1] ?? "startup"; + if (childTimedOut) throw new Error(`direct-transport child timed out (phase=${phase}; targetRequests=${targetPaths.length}; proxyRequests=${proxyPaths.length})`); if (exitCode !== 0) { throw new Error(`direct-transport child failed (${exitCode}): ${stderr.trim()}\n${stdout.trim()}`); } @@ -379,32 +389,5 @@ describe("local management direct transport", () => { if (proxyPort !== 0) await close(proxy); if (targetPort !== 0) await close(target); } - }, 30_000); -}); - -describe("direct local HTTPS transport", () => { - test("https request rejects non-HTTP(S) protocols with widened message", async () => { - await expect(directLocalHttpFetch("ftp://127.0.0.1/healthz", {})) - .rejects.toThrow("direct local request must use HTTP or HTTPS"); - }); - - test("succeeds against a real local HTTPS server", async () => { - const certFile = new URL("../fixtures/network-tls-test-cert.pem", import.meta.url); - const keyFile = new URL("../fixtures/network-tls-test-key.pem", import.meta.url); - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - tls: { cert: Bun.file(certFile), key: Bun.file(keyFile) }, - fetch() { - return Response.json({ ok: true }); - }, - }); - try { - const response = await directLocalHttpFetch(`https://127.0.0.1:${server.port}/healthz`); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ok: true }); - } finally { - server.stop(true); - } - }); + }, DIRECT_CHILD_BUDGET_MS + 1_000); }); diff --git a/tests/server/logs-timezone.test.ts b/tests/server/logs-timezone.test.ts index 9372b6a0c1..8d4bdf26b9 100644 --- a/tests/server/logs-timezone.test.ts +++ b/tests/server/logs-timezone.test.ts @@ -9,7 +9,7 @@ const config = { providers: [] } as unknown as OcxConfig; * #725: the dashboard rendered request-log timestamps in the BROWSER's zone, so a proxy * running in KST viewed from a UTC browser reported every request nine hours off. * - * The zone is on /api/settings and also on the /api/logs envelope (`{ timeZone, total, logs }`, + * The zone is on /api/settings and also on the /api/logs envelope (`{ timeZone, generatedAt, total, logs }`, * #726). Consumers that still need the row list go through `logsFromApiBody`. */ describe("log timestamp timezone (#725)", () => { @@ -24,16 +24,23 @@ describe("log timestamp timezone (#725)", () => { expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow(); }); - test("/api/logs envelope includes a usable timeZone", async () => { + test("/api/logs envelope includes the proxy clock and a usable timeZone", async () => { const url = new URL("http://localhost/api/logs"); + const before = Date.now(); const response = await handleManagementAPI(new Request(url), url, config); + const after = Date.now(); expect(response?.status).toBe(200); const body = await response!.json() as { timeZone?: unknown; + generatedAt?: unknown; total?: unknown; logs?: unknown; }; expect(typeof body.timeZone).toBe("string"); + expect(typeof body.generatedAt).toBe("number"); + expect(Number.isFinite(body.generatedAt)).toBe(true); + expect(body.generatedAt as number).toBeGreaterThanOrEqual(before); + expect(body.generatedAt as number).toBeLessThanOrEqual(after); expect(typeof body.total).toBe("number"); expect(Array.isArray(body.logs)).toBe(true); expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow(); diff --git a/tests/server/management-api-logs-metrics.test.ts b/tests/server/management-api-logs-metrics.test.ts index 76fbd8024e..f811810809 100644 --- a/tests/server/management-api-logs-metrics.test.ts +++ b/tests/server/management-api-logs-metrics.test.ts @@ -7,6 +7,7 @@ import { usageLogPath } from "../../src/usage/log"; import { addRequestLog, clearRequestLogsForTests, + evictOldestRequestLogForBudget, getRequestLogEntries, type RequestLogEntry, } from "../../src/server/request-log"; @@ -14,6 +15,30 @@ import type { OcxConfig } from "../../src/types"; import { buildRouteDecisionTrace } from "../../src/routing/trace"; import { summarizeUsage } from "../../src/usage/summary"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { refreshUserCostOverlays } from "../../src/usage/user-cost-overlays"; + +interface LogPollEnvelope { + logs: Array>; + cursor: string; + reset: boolean; + generatedAt: number; + timeZone: string; + total: number; +} + +async function readLogPoll(query = "", cursor?: string): Promise { + const url = new URL(`http://localhost/api/logs?${query}`); + if (cursor) url.searchParams.set("cursor", cursor); + const before = Date.now(); + const response = await handleManagementAPI(new Request(url), url, config); + expect(response?.status).toBe(200); + const body = await response!.json() as LogPollEnvelope; + expect(body.generatedAt).toBeGreaterThanOrEqual(before); + expect(body.generatedAt).toBeLessThanOrEqual(Date.now()); + expect(body.timeZone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + expect(typeof body.cursor).toBe("string"); + return body; +} const config = { providers: [] } as unknown as OcxConfig; @@ -294,3 +319,115 @@ describe("GET /api/logs display metrics", () => { }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; + + +describe("GET /api/logs snapshot polling", () => { + beforeEach(() => clearRequestLogsForTests()); + + test("poll application equals full reads across append, nested live mutation, eviction and clear", async () => { + let accepted: Array> = []; + let cursor: string | undefined; + const check = async (reset: boolean, deltaLength: number) => { + const poll = await readLogPoll("limit=2000", cursor); + expect(poll.reset).toBe(reset); + expect(poll.logs).toHaveLength(deltaLength); + accepted = !cursor || poll.reset ? poll.logs : [...accepted, ...poll.logs]; + const snapshot = await readLogPoll("limit=2000"); + expect(accepted).toEqual(snapshot.logs); + expect(poll.total).toBe(snapshot.total); + cursor = poll.cursor; + }; + await check(false, 0); + addRequestLog(baseEntry({ requestId: "older", usage: { inputTokens: 10, outputTokens: 5 } })); + await check(false, 1); + await check(false, 0); + addRequestLog(baseEntry({ requestId: "newest", firstOutputMs: 4 })); + await check(false, 1); + getRequestLogEntries()[0]!.usage!.outputTokens = 15; + await check(true, 2); + getRequestLogEntries()[1]!.status = 500; + delete getRequestLogEntries()[1]!.firstOutputMs; + await check(true, 2); + getRequestLogEntries()[0]!.attempts = [{ + ordinal: 1, provider: "anthropic", model: "claude-3-haiku-20240307", adapter: "anthropic", + status: 200, durationMs: 50, sendCount: 1, recoveryKinds: [], usageStatus: "reported", + usage: { inputTokens: 10, outputTokens: 5 }, + }]; + await check(true, 2); + getRequestLogEntries()[0]!.attempts![0]!.usage!.outputTokens = 20; + await check(true, 2); + // The newest cursor anchor survives this real memory-budget eviction. + evictOldestRequestLogForBudget(); + await check(true, 1); + clearRequestLogsForTests(); + await check(true, 0); + await check(false, 0); + }); + + test("pagination/filter changes and shifted windows reset against the full filtered snapshot", async () => { + for (const [requestId, provider] of [["a", "anthropic"], ["b", "openai"], ["c", "anthropic"]] as const) { + addRequestLog(baseEntry({ requestId, provider })); + } + let query = "provider=anthropic&limit=1&offset=1"; + const initial = await readLogPoll(query); + expect(initial.logs.map(row => row.requestId)).toEqual(["a"]); + expect(initial.total).toBe(2); + addRequestLog(baseEntry({ requestId: "d", provider: "anthropic" })); + let poll = await readLogPoll(query, initial.cursor); + expect(poll.reset).toBe(true); + expect(poll.logs).toEqual((await readLogPoll(query)).logs); + expect(poll.logs.map(row => row.requestId)).toEqual(["c"]); + expect(poll.total).toBe(3); + for (const changed of ["provider=openai&limit=1", "tail=2&limit=1", "model=absent", "status=5xx", "conversation=absent"]) { + query = changed; + poll = await readLogPoll(query, poll.cursor); + const full = await readLogPoll(query); + expect(poll.reset).toBe(true); + expect(poll.logs).toEqual(full.logs); + expect(poll.total).toBe(full.total); + } + const filtered = await readLogPoll("provider=openai"); + addRequestLog(baseEntry({ requestId: "not-in-filter", provider: "anthropic" })); + expect(await readLogPoll("provider=openai", filtered.cursor)) + .toMatchObject({ logs: [], reset: false, cursor: filtered.cursor, total: 1 }); + }); + + test("display-time cost changes reset even when raw entries are unchanged", async () => { + const priceConfig: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["fixture-model"], + modelCosts: { "fixture-model": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }, + } } }; + try { + refreshUserCostOverlays(priceConfig); + addRequestLog(baseEntry({ provider: "fixture", model: "fixture-model", usage: { inputTokens: 100, outputTokens: 10 } })); + const initial = await readLogPoll(); + const rawBefore = structuredClone(getRequestLogEntries()); + priceConfig.providers.fixture!.modelCosts!["fixture-model"]!.output = 20; + refreshUserCostOverlays(priceConfig); + const changed = await readLogPoll("", initial.cursor); + expect(changed.reset).toBe(true); + expect(changed.logs[0]!.displayMetrics).not.toEqual(initial.logs[0]!.displayMetrics); + expect(changed.logs).toEqual((await readLogPoll()).logs); + expect(getRequestLogEntries()).toEqual(rawBefore); + } finally { + refreshUserCostOverlays(config); + } + }); + + test("legacy cursors reset; invalid cursors return generic errors without reflecting input", async () => { + addRequestLog(baseEntry({ requestId: "private-row" })); + const legacy = Buffer.from(JSON.stringify({ v: 1, t: 1, id: "private-row" })).toString("base64url"); + const poll = await readLogPoll("provider=anthropic", legacy); + expect(poll.reset).toBe(true); + const payload = Buffer.from(poll.cursor, "base64url").toString(); + expect(payload).not.toContain("private-row"); + expect(payload).not.toContain("anthropic"); + for (const cursor of ["", "private-invalid-cursor", "x".repeat(513)]) { + const url = new URL("http://localhost/api/logs"); + url.searchParams.set("cursor", cursor); + const response = await handleManagementAPI(new Request(url), url, config); + expect(response?.status).toBe(400); + expect(await response!.json()).toEqual({ error: { code: "invalid_cursor", message: "invalid cursor" } }); + } + }); +}); diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index a7d4d080cb..d3d91aebdf 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -8,7 +8,7 @@ import { seedCodexModelEntitlementsForTests, } from "../../src/codex/model-entitlements"; import { handleManagementAPI } from "../../src/server/management-api"; -import { loadExportModels } from "../../src/server/management/model-rows"; +import { listManagementModelRows, loadExportModels } from "../../src/server/management/model-rows"; import { OPENCODE_API_KEY_ENV, OPENCODE_CONFIG_SCHEMA, @@ -425,6 +425,51 @@ describe("GET /api/client-config", () => { expect(body.modelCount).toBe(enabled.modelCount - 1); }, 15_000); + test("manual OpenAI replacement, disable, and removal reach the loader and exported selectors", async () => { + const config = baseConfig({ + // Test base-selector identity here; the Fast projection has its own regressions below. + fastRows: false, + providers: { + ...baseConfig().providers, + openai: { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", models: [], + }, + }, + }); + const manual = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5", contextWindow: 128_000 }]; + const stages = [ + { customModels: [], disabledModels: [], selectors: ["gpt-5.5"], native: true }, + { customModels: manual, disabledModels: [], selectors: ["openai/gpt-5.5"], native: false }, + { customModels: manual, disabledModels: ["openai/gpt-5.5"], selectors: [], native: false }, + // Removing the manual row restores the bare route even while its routed disable key remains. + { customModels: [], disabledModels: ["openai/gpt-5.5"], selectors: ["gpt-5.5"], native: true }, + ]; + for (const stage of stages) { + config.customModels = stage.customModels; + config.disabledModels = stage.disabledModels; + const rows = await loadExportModels(config); + const matchingRows = rows.filter(row => row.provider === "openai" && row.id === "gpt-5.5"); + expect(matchingRows.map(row => row.namespaced)).toEqual(stage.selectors); + if (stage.selectors.length > 0) { + expect(matchingRows[0]!.native === true).toBe(stage.native); + if (!stage.native) expect(matchingRows[0]!.contextWindow).toBe(128_000); + } + const response = await clientConfigApi(config, "?client=pi"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + const models = (body.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID].models; + // An array export exposes duplicates that a keyed document could silently overwrite. + expect(models.filter(model => model.id === "gpt-5.5" || model.id === "openai/gpt-5.5") + .map(model => model.id)).toEqual(stage.selectors); + if (stage.selectors[0] === "openai/gpt-5.5") { + expect(models.find(model => model.id === "openai/gpt-5.5")?.contextWindow).toBe(128_000); + } + expect(models.filter(model => model.id === "a/m1")).toHaveLength(1); + expect(body.modelCount).toBe(models.length); + } + }, 15_000); + test("model order and dedupe are stable across repeated calls", async () => { const config = baseConfig(); const first = await (await clientConfigApi(config, "?client=opencode")).json() as ClientConfigEnvelope; @@ -600,3 +645,67 @@ describe("default Fast availability reaches external exports", () => { expect(result.providers.opencodex.models.map(model => model.id)).not.toContain("fixture/m--fast"); }); }); + +describe("Pi and Aside provider selection", () => { + test.each(["pi", "aside"] as const)("%s exports selected Grok models while management retains the full roster", async client => { + const config = baseConfig({ + fastRows: false, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", + liveModels: false, models: ["grok-4.6", "grok-4.5", "grok-4.3"], + selectedModels: ["grok-4.6"], + }, + }, + }); + const ids = async () => { + const models = await loadExportModels(config); + const doc = buildClientConfig(client, { baseUrl: "http://127.0.0.1:10100/v1", config, models }) as PiGeneratedConfig; + return doc.providers.opencodex!.models.map(model => model.id).filter(id => id.startsWith("xai/")); + }; + const management = await listManagementModelRows(config); + expect(management.filter(row => row.provider === "xai")).toHaveLength(3); + expect(await ids()).toEqual(["xai/grok-4.6"]); + config.disabledModels = ["xai/grok-4.6"]; + expect(await ids()).toEqual([]); + config.disabledModels = []; + config.providers.xai!.selectedModels = []; + expect(await ids()).toEqual(["xai/grok-4.3", "xai/grok-4.5", "xai/grok-4.6"]); + }); +}); + +describe("visibility changes refresh connected client catalogs", () => { + test.each([ + ["/api/selected-models", { provider: "a", models: ["m1"] }, ["a/m1"]], + ["/api/disabled-models", { models: ["a/m2"] }, ["a/m1"]], + ["/api/model-visibility", { scope: "models", provider: "a", targets: [{ id: "m2" }], enabled: false }, ["a/m1"]], + ["/api/model-presets", { provider: "a", mode: "all" }, ["a/m1", "a/m2"]], + ] as const)("%s refreshes from the persisted selection and reports refused clients", async (path, body, expected) => { + const config = baseConfig({ fastRows: false }); + let saved = false; + let refreshCalls = 0; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, "content-type": "application/json" }, body: JSON.stringify(body), + }), url, config, { + saveConfigPreservingClaudeCode: () => { saved = true; }, + createManagementConvergeCodex: catalogConvergenceFactory(), + refreshOwnedCatalogIntegrations: async input => { + expect(saved).toBe(true); + expect(input.config).toBe(config); + expect(input.port).toBe(10100); + const models = typeof input.models === "function" ? await input.models() : input.models; + expect(models.filter(row => row.provider === "a").map(row => row.namespaced)).toEqual([...expected]); + refreshCalls += 1; + return [{ client: "pi", ok: false, reason: "integration_mutation_busy" }, { client: "aside", ok: true, changed: true }]; + }, + }); + expect(response?.status).toBe(200); + expect(refreshCalls).toBe(1); + expect(await response!.json()).toMatchObject({ + ok: true, + clientIntegrations: [{ client: "pi", ok: false, reason: "integration_mutation_busy" }, { client: "aside", ok: true, changed: true }], + }); + }); +}); diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 70d4ff3993..1f8cba92a2 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -245,10 +245,17 @@ describe("GET /api/client-integrations", () => { // The route must read through the SAME store the caller bound, or a test // that isolates writes still reads the developer's real snapshots. const models = await exportModels(); - expect(body.clients).toEqual(INTEGRATION_CLIENT_IDS.map(clientId => + expect(body.clients.filter(client => client.clientId !== "aside")).toEqual(INTEGRATION_CLIENT_IDS.filter(clientId => clientId !== "aside").map(clientId => JSON.parse(JSON.stringify(readIntegrationState({ clientId, models, config, port: 10100, store, env: routeEnv, home, }))))); + // Aside now returns an aggregate even when this fixture has no account manifest. + expect(body.clients.find(client => client.clientId === "aside")).toEqual({ + clientId: "aside", configPath: join(home, ".aside", "u"), + profiles: [], total: 0, enabledCount: 0, appliedCount: 0, allEnabled: false, + state: "unsafe", installed: false, reason: "unresolvable-path", + snapshotCount: -1, retentionDegraded: true, error: expect.any(String), + }); expect(text).not.toContain(REAL_LOOKING_KEY); }); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 7ca3aabd1b..7c7b9e0db5 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -29,7 +29,7 @@ import { startServer, } from "../../src/server"; import { handleManagementAPI } from "../../src/server/management-api"; -import { providerManagementConfigError } from "../../src/server/auth-cors"; +import { providerEditorConfigDTO, providerManagementConfigError } from "../../src/server/auth-cors"; import { providerEmptyToolOutputConfigError } from "../../src/config/provider-validation"; import { providerServiceTierConfigError, withProviderServiceTierDTO } from "../../src/server/management/provider-capability-config"; import { clearModelCache, markProviderDiscoveryFailed, markProviderDiscoveryOk } from "../../src/codex/model-cache"; @@ -4472,6 +4472,13 @@ describe("provider management validation", () => { }); expect(disabled.status).toBe(200); expect(await disabled.json()).toMatchObject({ ok: true, caps: {} }); + expect(loadConfig().providerContextCapValues?.["test-openai"]).toBe(350_000); + const uncapped = await fetch(new URL("/api/models", server.url)); + expect(uncapped.status).toBe(200); + const uncappedRows = await uncapped.json() as Array<{ id: string; contextWindow?: number; contextCap?: number }>; + const wide = uncappedRows.find(row => row.id === "wide-model"); + expect(wide).toMatchObject({ contextWindow: 500_000 }); + expect(wide?.contextCap).toBeUndefined(); } finally { await server.stop(true); } @@ -5177,3 +5184,170 @@ describe("provider transport option management contract (#1668, #2816)", () => { }); }); }); + +test("OpenAI provider cap remembers an explicit window across off, reload, and on", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + let live: OcxConfig = { + port: 0, defaultProvider: "openai", contextCapValue: 350_000, + providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", liveModels: false } }, + }; + saveConfig(live); + const put = async (body: unknown) => { + const url = new URL("http://localhost/api/provider-context-caps"); + const response = await handleManagementAPI(new Request(url, {method:"PUT", headers:{"content-type":"application/json"}, body:JSON.stringify(body)}), url, live, {createManagementConvergeCodex:catalogConvergenceFactory()}); + expect(response?.status).toBe(200); + return response!.json(); + }; + expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:350_000}}); + await put({provider:"openai",enabled:true,value:128_000}); + expect(await put({provider:"openai",enabled:false})).toMatchObject({caps:{},values:{openai:128_000}}); + live = loadConfig(); + expect(live.providerContextCaps).toBeUndefined(); + expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:128_000}}); + const {nativeModelRows} = await import("../../src/codex/catalog"); + expect(nativeModelRows(live).filter(row=>row.contextWindow !== undefined).every(row=>row.contextWindow! <= 128_000)).toBe(true); + await put({setAll:false}); + expect(loadConfig().providerContextCapValues?.openai).toBe(128_000); + await put({setAll:true}); + expect(loadConfig().providerContextCaps?.openai).toBe(350_000); +}); + +describe("remembered provider context selections", () => { + function selectionConfig(remembered = true): OcxConfig { + const live: OcxConfig = { + port: 0, + defaultProvider: "alpha", + contextCapValue: 350_000, + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", liveModels: false }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.example.test/v1", liveModels: false }, + }, + providerContextCaps: { alpha: 128_000 }, + ...(remembered ? { providerContextCapValues: { alpha: 128_000, beta: 256_000 } } : {}), + }; + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(live); + return loadConfig(); + } + + async function request(live: OcxConfig, path: string, method: string, body?: unknown): Promise { + const url = new URL(path, "http://localhost"); + const response = await handleManagementAPI(new Request(url, { + method, + ...(body === undefined ? {} : { + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }); + if (!response) throw new Error(`unhandled management route: ${path}`); + return response; + } + + test.each(["toString", "valueOf"])("first enable ignores inherited remembered values for %s", async (provider) => { + let live = selectionConfig(); + live.providers[provider] = { adapter: "openai-chat", baseUrl: "https://context.example.test/v1", liveModels: false }; + saveConfig(live); + live = loadConfig(); + + const first = await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: true }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ caps: { [provider]: 350_000 }, values: { [provider]: 350_000 } }); + expect(loadConfig().providerContextCaps?.[provider]).toBe(350_000); + expect(Object.hasOwn(loadConfig().providerContextCapValues ?? {}, provider)).toBe(true); + + expect((await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: true, value: 128_000 })).status).toBe(200); + expect((await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: false })).status).toBe(200); + live = loadConfig(); + expect(Object.hasOwn(live.providerContextCaps ?? {}, provider)).toBe(false); + expect((await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: true })).status).toBe(200); + expect(loadConfig().providerContextCaps?.[provider]).toBe(128_000); + }); + + test.each([ + { body: { value: 600_000, setAll: true }, caps: { alpha: 600_000 }, values: { alpha: 600_000, beta: 256_000 }, restored: 256_000 }, + { body: { setAll: true }, caps: { alpha: 350_000, beta: 350_000 }, values: { alpha: 350_000, beta: 350_000 }, restored: 350_000 }, + ])("setAll payload $body preserves or replaces a disabled selection as documented", async ({ body, caps, values, restored }) => { + let live = selectionConfig(); + const response = await request(live, "/api/provider-context-caps", "PUT", body); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ caps, values }); + live = loadConfig(); + expect(live.providerContextCaps).toEqual(caps); + expect(live.providerContextCapValues).toEqual(values); + const enabled = await request(live, "/api/provider-context-caps", "PUT", { provider: "beta", enabled: true }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ caps: { ...caps, beta: restored } }); + expect(loadConfig().providerContextCaps?.beta).toBe(restored); + }); + + test("an active-only legacy selection survives off, reload and implicit enable", async () => { + let live = selectionConfig(false); + expect(live.providerContextCapValues).toBeUndefined(); + const initial = await request(live, "/api/provider-context-caps", "GET"); + expect(initial.status).toBe(200); + expect(await initial.json()).toMatchObject({ caps: { alpha: 128_000 }, values: { alpha: 128_000 } }); + const off = await request(live, "/api/provider-context-caps", "PUT", { provider: "alpha", enabled: false }); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ caps: {}, values: { alpha: 128_000 } }); + live = loadConfig(); + expect(live.providerContextCaps).toBeUndefined(); + expect(live.providerContextCapValues).toEqual({ alpha: 128_000 }); + const on = await request(live, "/api/provider-context-caps", "PUT", { provider: "alpha", enabled: true }); + expect(on.status).toBe(200); + expect(await on.json()).toMatchObject({ caps: { alpha: 128_000 }, values: { alpha: 128_000 } }); + expect(loadConfig().providerContextCaps).toEqual({ alpha: 128_000 }); + }); + + test("rejected cap requests leave active and disabled selections untouched in memory and on disk", async () => { + const live = selectionConfig(); + const before = structuredClone(live); + const beforeBytes = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + for (const [body, status] of [ + [{ provider: "beta", enabled: true, value: 0.5 }, 400], + [{ provider: "beta", enabled: "yes", value: 700_000 }, 400], + [{ provider: "beta", enabled: true, setAll: true }, 400], + [{ value: 700_000, setAll: "yes" }, 400], + [{ value: 0.5 }, 400], + [{ provider: "missing", enabled: true }, 404], + [[1, 2, 3], 400], + [null, 400], + ] as const) { + const response = await request(live, "/api/provider-context-caps", "PUT", body); + expect(response.status).toBe(status); + expect(live).toEqual(before); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(beforeBytes); + } + }); + + test.each(["DELETE", "editor"] as const)("%s removal forgets active and disabled selections in persisted and live state", async mode => { + const live = selectionConfig(); + live.providers.retained = { adapter: "openai-chat", baseUrl: "https://retained.example.test/v1", liveModels: false }; + live.defaultProvider = "retained"; + saveConfig(live); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + for (const name of ["alpha", "beta"]) { + const baseline = providerEditorConfigDTO(loadConfig()); + const next = structuredClone(baseline); + delete next.providers[name]; + const response = mode === "DELETE" + ? await request(live, `/api/providers?name=${name}`, "DELETE") + : await request(live, "/api/providers", "PUT", { baseline, next }); + expect(response.status).toBe(200); + for (const snapshot of [live, loadConfig()]) { + expect(snapshot.providers[name]).toBeUndefined(); + expect(snapshot.providers.retained).toBeDefined(); + expect(snapshot.providerContextCaps).toBeUndefined(); + expect(snapshot.providerContextCapValues).toEqual(name === "alpha" ? { beta: 256_000 } : undefined); + } + const caps = await request(live, "/api/provider-context-caps", "GET"); + expect(caps.status).toBe(200); + expect(await caps.json()).toMatchObject({ caps: {}, values: name === "alpha" ? { beta: 256_000 } : {} }); + } + } finally { + resolved.mockRestore(); + } + }); +}); diff --git a/tests/server/memory-watchdog.test.ts b/tests/server/memory-watchdog.test.ts index 80c38dd020..918503456c 100644 --- a/tests/server/memory-watchdog.test.ts +++ b/tests/server/memory-watchdog.test.ts @@ -196,6 +196,9 @@ describe("GET /api/system/memory", () => { spillWriteStatus: "initial" | "healthy" | "degraded"; spillWriteConsecutiveFailures: number; spillLastWriteFailureCode: string | null; + spillLastWriteFailureOrigin: string | null; + spillAclRetryReturnedTimeouts: number; + spillAclTimeoutMemoRefusals: number; spillLastWriteFailureAt: number | null; spillLastWriteSuccessAt: number | null; replayScopeMismatchDrops: number; @@ -221,11 +224,12 @@ describe("GET /api/system/memory", () => { // responseState is a scalar-only continuation-store attribution block: numbers plus fixed // enum/null fields (no paths, messages, tokens, or account identifiers). // The exact count is pinned on purpose: a new field must be reviewed for privacy safety - // before it reaches this surface. 17 after #3522 added spill-write health diagnostics. - expect(Object.keys(body.responseState)).toHaveLength(17); + // before it reaches this surface. 20 after #3522 added failure origins and counters. + expect(Object.keys(body.responseState)).toHaveLength(20); const { spillWriteStatus, spillLastWriteFailureCode, + spillLastWriteFailureOrigin, spillLastWriteFailureAt, spillLastWriteSuccessAt, ...numericResponseState @@ -233,6 +237,9 @@ describe("GET /api/system/memory", () => { expect(Object.values(numericResponseState) .every(value => typeof value === "number" && Number.isFinite(value))).toBe(true); expect(["initial", "healthy", "degraded"]).toContain(spillWriteStatus); + expect(spillLastWriteFailureOrigin === null || [ + "retry_returned_timeout", "timeout_memo_refusal", + ].includes(spillLastWriteFailureOrigin)).toBe(true); expect(spillLastWriteFailureCode === null || [ "EACLRETRYEXHAUSTED", "ETIMEDOUT", "EACCES", "ENOSPC", "EFBIG", "EIO", "ECAPACITY", "ELOOP", "EUNKNOWN", diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index c5043520e8..c795c6cf2d 100644 --- a/tests/server/proxy-env.test.ts +++ b/tests/server/proxy-env.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createServer } from "node:http"; import { applyProxyEnv } from "../../src/config"; +import { resolveProxyRoute } from "../../src/lib/proxy-env"; import type { OcxConfig } from "../../src/types"; -const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; let saved: Record; beforeEach(() => { @@ -30,6 +32,128 @@ function configWithRawProxy(proxy: unknown, noProxy?: unknown): OcxConfig { return { proxy, noProxy, providers: {} } as unknown as OcxConfig; } +describe("resolveProxyRoute", () => { + test("wss uses HTTPS_PROXY and never HTTP_PROXY", () => { + const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); + expect(resolveProxyRoute(target, { + HTTPS_PROXY: "http://secure-proxy.example:8443", + HTTP_PROXY: "http://plain-proxy.example:8080", + })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); + expect(resolveProxyRoute(target, { + HTTP_PROXY: "http://plain-proxy.example:8080", + })).toEqual({ kind: "direct" }); + }); + + test.each([ + ["exact host", "wss://chatgpt.com/path", "chatgpt.com", "direct"], + ["domain suffix", "wss://api.chatgpt.com/path", ".chatgpt.com", "direct"], + ["wildcard suffix", "wss://api.chatgpt.com/path", "*.chatgpt.com", "direct"], + ["wss default port", "wss://chatgpt.com/path", "chatgpt.com:443", "direct"], + ["ws default port", "ws://chatgpt.com/path", "chatgpt.com:80", "direct"], + ["port mismatch", "wss://chatgpt.com/path", "chatgpt.com:80", "proxy"], + ["bracketed IPv6", "wss://[2001:db8::1]/path", "[2001:db8::1]:443", "direct"], + ["URL-style entry", "wss://chatgpt.com/path", "https://chatgpt.com/ignored", "direct"], + ] as const)("honors NO_PROXY for %s", (_label, target, noProxy, expectedKind) => { + expect(resolveProxyRoute(new URL(target), { + HTTPS_PROXY: "http://secure-proxy.example:8443", + NO_PROXY: noProxy, + }).kind).toBe(expectedKind); + }); + + test("uses stable proxy precedence and fails closed on the first unusable proxy", () => { + const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); + const route = (env: Record) => resolveProxyRoute(target, env); + expect([ + route({ HTTPS_PROXY: "http://upper-https:1", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), + route({ HTTPS_PROXY: " ", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3" }), + route({ ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), + route({ all_proxy: "https://lower-all:4" }), + route({ HTTPS_PROXY: "socks5://unsupported:1080", ALL_PROXY: "http://must-not-win:3" }), + route({ HTTPS_PROXY: "not a proxy URL", ALL_PROXY: "http://must-not-win:3" }), + route({}), + ]).toEqual([ + { kind: "proxy", proxy: "http://upper-https:1" }, + { kind: "proxy", proxy: "http://lower-https:2" }, + { kind: "proxy", proxy: "http://upper-all:3" }, + { kind: "proxy", proxy: "https://lower-all:4" }, + { kind: "fallback" }, + { kind: "fallback" }, + { kind: "direct" }, + ]); + }); + + test("preserves uppercase NO_PROXY precedence when it is explicitly empty", () => { + expect(resolveProxyRoute(new URL("wss://chatgpt.com/path"), { + HTTPS_PROXY: "http://secure-proxy.example:8443", + NO_PROXY: "", + no_proxy: "chatgpt.com", + })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); + }); + + test("Bun WebSocket sends WSS through an HTTP CONNECT proxy", async () => { + let resolveConnect!: (target: string) => void; + const connected = new Promise(resolve => { resolveConnect = resolve; }); + const proxy = createServer(); + proxy.on("connect", (request, socket) => { + resolveConnect(request.url ?? ""); + socket.end("HTTP/1.1 502 Probe Complete\r\nContent-Length: 0\r\n\r\n"); + }); + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(0, "127.0.0.1", resolve); + }); + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("proxy did not bind a TCP port"); + const socket = new WebSocket("wss://proxy-probe.invalid/backend-api/codex/responses", { + proxy: `http://127.0.0.1:${address.port}`, + } as unknown as string[]); + try { + expect(await Promise.race([ + connected, + new Promise((_, reject) => setTimeout(() => reject(new Error("CONNECT was not observed")), 5_000)), + ])).toBe("proxy-probe.invalid:443"); + } finally { + try { socket.close(); } catch { /* probe is already complete */ } + await new Promise(resolve => proxy.close(() => resolve())); + } + }, 10_000); + + test.skipIf(process.platform !== "win32")("Bun fetch honors NO_PROXY on Windows", async () => { + let providerRequests = 0; + let proxyRequests = 0; + const provider = createServer((_request, response) => { + providerRequests += 1; + response.end("direct"); + }); + const proxy = createServer((_request, response) => { + proxyRequests += 1; + response.end("proxied"); + }); + const listen = async (server: typeof provider): Promise => { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server did not bind a TCP port"); + return address.port; + }; + const [providerPort, proxyPort] = await Promise.all([listen(provider), listen(proxy)]); + process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`; + process.env.NO_PROXY = "127.0.0.1"; + try { + expect(await (await fetch(`http://127.0.0.1:${providerPort}/models`)).text()).toBe("direct"); + expect(providerRequests).toBe(1); + expect(proxyRequests).toBe(0); + } finally { + await Promise.all([ + new Promise(resolve => provider.close(() => resolve())), + new Promise(resolve => proxy.close(() => resolve())), + ]); + } + }); +}); + describe("applyProxyEnv with values the schema does not constrain", () => { test("warns once per discarded proxy setting without exposing its raw value", () => { const secret = "raw-proxy-credential-sentinel-2947"; @@ -122,6 +246,14 @@ describe("applyProxyEnv", () => { expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080"); }); + test.each(["ALL_PROXY", "all_proxy"])("config fills a scheme proxy ahead of %s for WSS", key => { + process.env[key] = "http://fallback-proxy.example:8081"; + applyProxyEnv(configWithProxy("http://configured-proxy.example:8080")); + expect(process.env[key]).toBe("http://fallback-proxy.example:8081"); + expect(resolveProxyRoute(new URL("wss://chatgpt.com/backend-api/codex/responses"))) + .toEqual({ kind: "proxy", proxy: "http://configured-proxy.example:8080" }); + }); + test("appends loopback entries to an existing NO_PROXY without duplicating", () => { process.env.NO_PROXY = "internal.corp,localhost"; applyProxyEnv(configWithProxy("http://proxy.corp:8080")); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts new file mode 100644 index 0000000000..caaad660aa --- /dev/null +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -0,0 +1,427 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { createKiroAdapter } from "../../src/adapters/kiro"; +import { ADAPTER_REGISTRY } from "../../src/adapters/registry"; +import { parseRequest } from "../../src/responses/parser"; +import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../src/responses/turn-termination"; +import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; +import type { OcxParsedRequest } from "../../src/types"; +import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + +test("replay reuses admitted recovery after a tool result without another network call", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Read nonce.txt exactly.")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, { parentThreadId: "parent" })).toBe(true); + const replay = [...encryptedInput(), { type: "function_call_output", call_id: "tool", output: "result" }]; + expect(restoreCachedEncryptedAgentTasks(req, replay, config, { parentThreadId: "parent" })).toBe(1); + expect(JSON.stringify(replay)).toContain("Read nonce.txt exactly."); + expect(JSON.stringify(replay)).not.toContain(FERNET_TASK); + expect(calls).toBe(1); +}); + +test("replay does not recover unseen envelopes, other parents, or other callers", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private assignment.")); }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config, { parentThreadId: "parent" })).toBe(0); + expect(calls).toBe(0); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, { parentThreadId: "parent" })).toBe(true); + for (const [request, parent] of [[req, "another-parent"], [new Request("http://localhost/v1/responses", { headers: codexHeaders("another-account") }), "parent"], [new Request("http://localhost/v1/responses"), "parent"]] as const) { + const input = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(request, input, config, { parentThreadId: parent })).toBe(0); + expect(JSON.stringify(input)).toContain(FERNET_TASK); + } + expect(calls).toBe(1); +}); + +test("a rotated token for the same account cannot reuse the previous credential's recovery", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(recoverySse(calls === 1 ? "Original credential assignment." : "Rotated credential assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const rotatedHeaders = new Headers(headers); + rotatedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + const original = new Request("http://localhost/v1/responses", { headers }); + const rotated = new Request("http://localhost/v1/responses", { headers: rotatedHeaders }); + expect(await recoverEncryptedAgentTask(original, encryptedInput(), {}, config)).toBe(true); + const missed = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(rotated, missed, config)).toBe(0); + expect(missed).toEqual(encryptedInput()); + expect(calls).toBe(1); + const replay = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(original, replay, config)).toBe(1); + expect(JSON.stringify(replay)).toContain("Original credential assignment."); + // The rotated credential is valid, but must perform its own admitted recovery. + const fresh = encryptedInput(); + expect(await recoverEncryptedAgentTask(rotated, fresh, {}, config)).toBe(true); + expect(JSON.stringify(fresh)).toContain("Rotated credential assignment."); + expect(calls).toBe(2); +}); + +test("Responses handler restores a cached task in a continued child turn", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + let recoveries = 0; + const bodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + recoveries++; + return new Response(recoverySse("Read nonce.txt exactly.")); + } + bodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedInput(), headers)).status).toBe(200); + now += 10; + // A freshly generated fixture JWT would be a different caller across this boundary. + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain("Read nonce.txt exactly."); + expect(bodies[1]).not.toContain(FERNET_TASK); + } finally { + clock.mockRestore(); + } +}); + +function encryptedMessage(): unknown[] { + return JSON.parse(JSON.stringify(encryptedInput()).replace("Message Type: NEW_TASK", "Message Type: MESSAGE")); +} + +test.each([true, false, undefined])("fresh recovery and cache-only reparse preserve cohort marker %s and replay metadata", async (cohort) => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + const parentThread = `affinity-parent-${crypto.randomUUID()}`; + const headers = codexHeaders("acct-caller", { + "x-codex-parent-thread-id": parentThread, + "thread-id": "distinct-child-thread", + session_id: "distinct-session", + }); + const config = routedConfig({ enabled: true }); + let recoveries = 0; + const recoveryBodies: string[] = []; + const providerBodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const body = String(init?.body); + if (String(url).includes("chatgpt.com")) { + recoveries++; + recoveryBodies.push(body); + return new Response(recoverySse("Read the affinity assignment.")); + } + providerBodies.push(body); + return providerResponse(); + }) as typeof fetch; + + const observations: Array<{ + cohort: boolean | undefined; + thread: string | undefined; + replay: OcxParsedRequest["_reasoningReplayScope"]; + raw: string; + }> = []; + const createChat = ADAPTER_REGISTRY["openai-chat"].create; + const factory = spyOn(ADAPTER_REGISTRY["openai-chat"], "create").mockImplementation((provider, context) => { + const adapter = createChat(provider, context); + return { + ...adapter, + buildRequest(...[parsed, incoming]: Parameters) { + observations.push({ + cohort: parsed._promptCacheKeyIsSharedCohort, + thread: parsed._clientThreadId, + replay: structuredClone(parsed._reasoningReplayScope), + raw: JSON.stringify(parsed._rawBody), + }); + return adapter.buildRequest(parsed, incoming); + }, + }; + }); + try { + const turns = [ + encryptedInput(), + [...encryptedInput(), { type: "message", role: "user", content: "Continue the affinity assignment." }], + ]; + for (const [index, input] of turns.entries()) { + const response = await post(config, "xai/grok-4.5", input, headers, undefined, { + promptCacheKeyIsSharedCohort: cohort, + }); + expect(response.status).toBe(200); + await response.text(); + expect(recoveries).toBe(1); + expect(observations).toHaveLength(index + 1); + expect(providerBodies).toHaveLength(index + 1); + const observed = observations[index]!; + expect(observed.cohort).toBe(cohort); + expect(observed.thread).toBe(parentThread); + expect(observed.replay).toMatchObject({ clientThreadId: parentThread }); + expect(observed.replay).toEqual(observations[0]!.replay); + for (const body of [observed.raw, providerBodies[index]!]) { + expect(body).toContain("Read the affinity assignment."); + expect(body).not.toContain(FERNET_TASK); + expect(body).not.toContain("promptCacheKeyIsSharedCohort"); + } + } + expect(providerBodies[1]).toContain("Continue the affinity assignment."); + expect(recoveryBodies).toHaveLength(1); + expect(recoveryBodies[0]).toContain(FERNET_TASK); + expect(recoveryBodies[0]).not.toContain("promptCacheKeyIsSharedCohort"); + } finally { + factory.mockRestore(); + } +}); + +test("MESSAGE recovery reaches the provider and survives tool-result replay", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + let recoveries = 0; + const bodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + expect(String(init?.body)).toContain("Message Type: MESSAGE"); + recoveries++; + return new Response(recoverySse("Stop waiting and report your result.")); + } + bodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), headers)).status).toBe(200); + now += 10; + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { + type: "message", role: "user", content: "Continue after the tool result.", + }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body).toContain("Stop waiting and report your result."); + expect(body).not.toContain(FERNET_TASK); + } + } finally { + clock.mockRestore(); + } +}); + +test("a changed valid token cannot read another credential snapshot's recovery", async () => { + let recoveries = 0; + globalThis.fetch = (async () => { + recoveries++; + return new Response(recoverySse("Original caller assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1_000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const req = new Request("http://localhost/v1/responses", { headers }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config)).toBe(true); + + const changedHeaders = new Headers(headers); + changedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + expect(changedHeaders.get("authorization")).not.toBe(headers.get("authorization")); + const changedCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(new Request("http://localhost/v1/responses", { + headers: changedHeaders, + }), changedCallerInput, config)).toBe(0); + expect(JSON.stringify(changedCallerInput)).toContain(FERNET_TASK); + expect(JSON.stringify(changedCallerInput)).not.toContain("Original caller assignment."); + + const sameCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(req, sameCallerInput, config)).toBe(1); + expect(JSON.stringify(sameCallerInput)).toContain("Original caller assignment."); + expect(JSON.stringify(sameCallerInput)).not.toContain(FERNET_TASK); + expect(recoveries).toBe(1); +}); + +test("MESSAGE cache remains isolated by message type, account, parent and sender", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private message.")); }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + expect(await recoverEncryptedAgentTask(req, encryptedMessage(), {}, config, { parentThreadId: "parent" })).toBe(true); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config, { parentThreadId: "parent" })).toBe(0); + for (const [request, parent] of [[req, "other-parent"], [new Request("http://localhost/v1/responses", { headers: codexHeaders("other-account") }), "parent"]] as const) { + expect(restoreCachedEncryptedAgentTasks(request, encryptedMessage(), config, { parentThreadId: parent })).toBe(0); + } + const malformed = JSON.parse(JSON.stringify(encryptedMessage())); + malformed[0].author = "/root/wrong-sender"; + expect(await recoverEncryptedAgentTask(req, malformed, {}, config)).toBe(false); + const unknown = JSON.parse(JSON.stringify(encryptedMessage()).replace("Message Type: MESSAGE", "Message Type: UNKNOWN")); + expect(await recoverEncryptedAgentTask(req, unknown, {}, config)).toBe(false); + expect(calls).toBe(1); +}); + + +test("mixed history restores cached NEW_TASK and MESSAGE separately before recovering only the new tail", async () => { + let calls = 0; + const payloads = ["Initial assignment.", "First message.", "Second message."]; + globalThis.fetch = (async () => new Response(recoverySse(payloads[calls++]!))) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + const scope = { parentThreadId: "parent" }; + const nextMessage = () => JSON.parse(JSON.stringify(encryptedMessage()).replace(FERNET_TASK, SECOND_FERNET_TASK)); + + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, scope)).toBe(true); + expect(await recoverEncryptedAgentTask(req, encryptedMessage(), {}, config, scope)).toBe(true); + const input = [...encryptedInput(), ...encryptedMessage(), ...nextMessage()]; + expect(restoreCachedEncryptedAgentTasks(req, input, config, scope)).toBe(2); + expect(calls).toBe(2); + expect(await recoverEncryptedAgentTask(req, input, {}, config, scope)).toBe(true); + expect(calls).toBe(3); + for (const payload of payloads) expect(JSON.stringify(input)).toContain(payload); + expect(JSON.stringify(input)).not.toContain(SECOND_FERNET_TASK); + + const replay = [...encryptedInput(), ...encryptedMessage(), ...nextMessage(), { + type: "function_call_output", call_id: "tool", output: "done", + }]; + expect(restoreCachedEncryptedAgentTasks(req, replay, config, scope)).toBe(3); + expect(calls).toBe(3); +}); + +test("Responses handler restores known history and recovers only the new MESSAGE tail", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + const assignments = ["Initial assignment.", "First message.", "Second message."]; + const recoveryBodies: string[] = []; + const providerBodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const requestBody = String(init?.body); + if (String(url).includes("chatgpt.com")) { + recoveryBodies.push(requestBody); + return new Response(recoverySse(assignments[recoveryBodies.length - 1] ?? "Unexpected extra recovery.")); + } + providerBodies.push(requestBody); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const headers = codexHeaders(); + const nextMessage = () => JSON.parse(JSON.stringify(encryptedMessage()).replace(FERNET_TASK, SECOND_FERNET_TASK)); + const turns = [ + encryptedInput(), + [...encryptedInput(), ...encryptedMessage()], + [...encryptedInput(), ...encryptedMessage(), ...nextMessage()], + ]; + + for (const [index, input] of turns.entries()) { + const response = await post(config, "xai/grok-4.5", input, headers); + expect(response.status).toBe(200); + await response.text(); + expect(recoveryBodies).toHaveLength(index + 1); + expect(providerBodies).toHaveLength(index + 1); + const sent = providerBodies[index]!; + let previousPosition = -1; + for (const assignment of assignments.slice(0, index + 1)) { + const position = sent.indexOf(assignment); + expect(position).toBeGreaterThan(previousPosition); + previousPosition = position; + } + expect(sent).not.toContain(FERNET_TASK); + expect(sent).not.toContain(SECOND_FERNET_TASK); + } + // Recovery may receive only the fresh tail, never a batch of cached history. + expect(JSON.parse(recoveryBodies[2]!).input).toEqual(nextMessage()); + + const response = await post(config, "xai/grok-4.5", [ + ...encryptedInput(), ...encryptedMessage(), ...nextMessage(), + { type: "message", role: "user", content: "Continue with all three instructions." }, + ], headers); + expect(response.status).toBe(200); + await response.text(); + expect(recoveryBodies).toHaveLength(3); + expect(providerBodies).toHaveLength(4); + for (const assignment of assignments) expect(providerBodies[3]).toContain(assignment); + expect(providerBodies[3]).toContain("Continue with all three instructions."); + expect(providerBodies[3]).not.toContain(FERNET_TASK); + expect(providerBodies[3]).not.toContain(SECOND_FERNET_TASK); +}); + +test("cached-history reparse preserves recorded final-answer scope without suppressing a user follow-up", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + const sessionId = `recovery-final-replay-${crypto.randomUUID()}`; + const headers = codexHeaders("acct-caller", { session_id: sessionId }); + const config = routedConfig({ enabled: true }); + const deliveredAnswer = "The assignment is complete."; + const recorded = parseRequest({ model: "xai/grok-4.5", input: "Earlier turn" }); + bindTurnTerminationScope(recorded, conversationIdFromResponsesRequest({ sessionIdHeader: sessionId })); + rememberDeliveredFinalAnswer(recorded, { output: [{ + type: "message", role: "assistant", phase: "final_answer", + content: [{ type: "output_text", text: deliveredAnswer }], + }] }); + + let recoveries = 0; + const providerBodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + recoveries++; + return new Response(recoverySse("Read the assignment.")); + } + providerBodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config)).toBe(true); + + // Keep the ordinary transport fixture, but exercise Kiro's real pre-send termination hook. + // The remembered record above belongs to a different parsed object: only core can bind + // the new object produced by recovery reparse to the same conversation. + const kiro = createKiroAdapter({ adapter: "kiro", baseUrl: "https://kiro.test", authMode: "key", apiKey: "synthetic-key" }); + const createChat = ADAPTER_REGISTRY["openai-chat"].create; + const inspectedBodies: string[] = []; + const factory = spyOn(ADAPTER_REGISTRY["openai-chat"], "create").mockImplementation((provider, context) => ({ + ...createChat(provider, context), + localTerminal(parsed: OcxParsedRequest) { + inspectedBodies.push(JSON.stringify(parsed._rawBody)); + return kiro.localTerminal?.(parsed); + }, + })); + const finalMessage = { type: "message", role: "assistant", content: [{ type: "output_text", text: deliveredAnswer }] }; + try { + for (let attempt = 0; attempt < 2; attempt++) { + const response = await post(config, "xai/grok-4.5", [...encryptedInput(), finalMessage], headers); + expect(response.status).toBe(200); + expect((await response.json() as { output: unknown[] }).output).toEqual([]); + expect(providerBodies).toHaveLength(0); + } + const followUp = await post(config, "xai/grok-4.5", [ + ...encryptedInput(), finalMessage, + { type: "message", role: "user", content: "Now explain your result." }, + ], headers); + expect(followUp.status).toBe(200); + await followUp.text(); + expect(providerBodies).toHaveLength(1); + expect(providerBodies[0]).toContain("Now explain your result."); + expect(inspectedBodies).toHaveLength(3); + for (const inspected of inspectedBodies) { + expect(inspected).toContain("Read the assignment."); + expect(inspected).not.toContain(FERNET_TASK); + } + expect(recoveries).toBe(1); + } finally { + factory.mockRestore(); + } +}); + +test("fresh recovery only handles the current tail, leaving uncached history unchanged", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Current message.")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + const historical = encryptedInput(); + const input = [...historical, ...encryptedMessage()]; + expect(await recoverEncryptedAgentTask(req, input, {}, config)).toBe(true); + expect(input[0]).toEqual(encryptedInput()[0]); + expect(JSON.stringify(input[1])).toContain("Current message."); + expect(calls).toBe(1); +}); diff --git a/tests/server/server-auth.test.ts b/tests/server/server-auth.test.ts index 0303ee4da8..54e69bec0a 100644 --- a/tests/server/server-auth.test.ts +++ b/tests/server/server-auth.test.ts @@ -38,7 +38,7 @@ import { import { clearRequestLogsForTests, getRequestLogEntries } from "../../src/server/request-log"; import { readUsageEntries } from "../../src/usage/log"; import { handleManagementAPI } from "../../src/server/management-api"; -import { handleResponses } from "../../src/server/responses"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -602,6 +602,49 @@ describe("server local API auth", () => { })).toBe(false); }); + test("compact keeps the idle guard until a valid request body is complete", async () => { + let bodyController!: ReadableStreamDefaultController; + const readerWaiting = Promise.withResolvers(); + let readRequests = 0; + const body = new ReadableStream({ + start(controller) { bodyController = controller; }, + pull(controller) { + if (readRequests++ === 0) controller.enqueue(new TextEncoder().encode('{"model":"fixture/gpt-test","input":[')); + else readerWaiting.resolve(); + }, + }, { highWaterMark: 0 }); + const cfg = config(); + cfg.defaultProvider = "fixture"; + cfg.providers = { fixture: { ...cfg.providers.openai!, disabled: true } }; + const request = new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { "content-type": "application/json" }, body, + }); + let accepted = 0; + const result = handleResponsesCompact(request, cfg, { model: "unknown", provider: "unknown" }, undefined, undefined, { + onRequestBodyRead: () => { accepted++; }, + }); + await readerWaiting.promise; + expect(accepted).toBe(0); + bodyController.enqueue(new TextEncoder().encode(']}')); + bodyController.close(); + expect((await result).status).toBe(404); + expect(accepted).toBe(1); + }); + + for (const body of ["{", "[]", "{}", '{"model":0}', '{"model":""}']) { + test(`compact does not release idle protection for rejected body ${body}`, async () => { + let accepted = false; + const request = new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { "content-type": "application/json" }, body, + }); + const response = await handleResponsesCompact(request, config(), { model: "unknown", provider: "unknown" }, undefined, undefined, { + onRequestBodyRead: () => { accepted = true; }, + }); + expect(response.status).toBe(400); + expect(accepted).toBe(false); + }); + } + test("responses handler keeps the request timeout until the body is fully accepted", async () => { let controller!: ReadableStreamDefaultController; const body = new ReadableStream({ diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 0dbba6c548..f5bd739d6d 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -21,7 +21,7 @@ import { XAI_OAUTH_DISCOVERY_URL } from "../../src/oauth/xai"; import { XAI_GROK_CLI_BASE_URL } from "../../src/providers/xai-transport"; import type { AdapterEvent, OcxConfig, OcxProviderConfig, OcxProviderContinuationState } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; -import { clearRequestLogsForTests, hydrateRequestLogsFromDisk, type RequestLogContext } from "../../src/server/request-log"; +import { clearRequestLogsForTests, hydrateRequestLogsFromDisk, httpStatusForRequestLogTerminal, inspectResponseLogSsePayload, type RequestLogContext } from "../../src/server/request-log"; import { responseWithDeferredRequestLog } from "../../src/server/relay"; import { readUsageEntries } from "../../src/usage/log"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -420,6 +420,23 @@ async function within(promise: Promise, ms = 2_000): Promise { } } +function heldNativeTerminal(payload: Record) { + const release = deferred(); + const encoder = new TextEncoder(); + const upstream = serve(() => new Response(new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(`event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", item_id: "msg_late", output_index: 0, + content_index: 0, delta: "already visible", + })}\n\n`)); + await release.promise; + controller.enqueue(encoder.encode(`event: ${payload.type}\ndata: ${JSON.stringify(payload)}\n\n`)); + controller.close(); + }, + }), { headers: { "content-type": "text/event-stream" } })); + return { upstream, release: release.resolve }; +} + describe("server combo failover 030 activation matrix", () => { test("dispatches a selected concrete target despite a shadowing combo alias", async () => { const hits: string[] = []; @@ -587,6 +604,79 @@ describe("server combo failover 030 activation matrix", () => { } }); + for (const scenario of [ + { + name: "quota incomplete", status: "incomplete", logStatus: 429, + details: { incomplete_details: { reason: "usage_limit_reached" }, error: { message: "quota exhausted after output" } }, + message: "quota exhausted after output", + }, + { + name: "normal output limit", status: "incomplete", logStatus: 200, + details: { incomplete_details: { reason: "max_output_tokens" }, error: { message: "output limit reached" } }, + message: "output limit reached", + }, + { + name: "policy refusal", status: "failed", logStatus: 400, + details: { error: { code: "cyber_policy", message: "blocked by cyber policy" } }, + message: "blocked by cyber policy", + }, + ]) { + test(`late committed native ${scenario.name} reaches the HTTP combo log`, async () => { + const held = heldNativeTerminal({ + type: `response.${scenario.status}`, + response: { ...responsesSuccess("already visible", "m1"), status: scenario.status, ...scenario.details }, + }); + let backupHits = 0; + const backup = serve(() => { backupHits++; return chatStream("must not replay"); }); + const config = comboConfig({ + a: provider("openai-responses", baseUrl(held.upstream), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + }); + config.streamMode = "legacy-tee"; + saveConfig(config); + const server = startServer(0); + try { + const response = await within(fetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: true }), + })); + expect(response.status).toBe(200); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let text = ""; + while (!text.includes("already visible")) { + const chunk = await within(reader.read()); + expect(chunk.done).toBe(false); + text += decoder.decode(chunk.value, { stream: true }); + } + // Client-visible content proves preflight committed and copied childLog. + // There is still no terminal to inspect, so no finalized parent receipt. + expect(logsFromApiBody(await (await fetch(new URL("/api/logs?tail=1", server.url))).json())).toHaveLength(0); + held.release(); + for (;;) { + const chunk = await within(reader.read()); + if (chunk.done) break; + text += decoder.decode(chunk.value, { stream: true }); + } + expect(text).toContain(`response.${scenario.status}`); + expect(backupHits).toBe(0); + const logs = logsFromApiBody(await (await fetch(new URL("/api/logs?tail=1", server.url))).json()); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ + provider: "combo", model: "combo/free", resolvedModel: "m1", + status: scenario.logStatus, terminalStatus: scenario.status, + closeReason: "terminal", upstreamError: scenario.message, + }); + expect(logs[0]!.attempts).toMatchObject([{ provider: "a", model: "m1", status: scenario.logStatus }]); + expect(logs[0]!.attempts).toHaveLength(1); + if (scenario.status === "failed") expect(logs[0]!.errorCode).toBe("cyber_policy"); + } finally { + held.release(); + await server.stop(true); + } + }); + } + test("terminal SSE failure after output stays on the first target and never replays", async () => { const hits: string[] = []; const a = serve(() => { @@ -2928,12 +3018,15 @@ describe("server combo failover 030 activation matrix", () => { test("failed passthrough child callbacks stay buffered and only B finalizes", async () => { const terminalFrame = (status: "failed" | "completed") => [ `event: response.${status}`, - `data: ${JSON.stringify({ type: `response.${status}`, response: { id: `resp_${status}`, status, output: [] } })}`, + `data: ${JSON.stringify({ type: `response.${status}`, response: { + id: `resp_${status}`, status, output: [], + ...(status === "failed" ? { error: { code: "rate_limit_exceeded", message: "discarded quota failure" } } : {}), + } })}`, "", "", ].join("\n"); const a = serve(() => new Response(terminalFrame("failed"), { - status: 503, + status: 200, headers: { "content-type": "text/event-stream" }, })); const b = serve(() => new Response(terminalFrame("completed"), { @@ -2946,9 +3039,15 @@ describe("server combo failover 030 activation matrix", () => { const finalized = deferred(); const statuses: string[] = []; let cancels = 0; - const response = await post(config, { stream: true }, { + const parent: RequestLogContext = { model: "", provider: "" }; + const snapshots: RequestLogContext[] = []; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: true }), + }), config, parent, { onNativePassthroughTerminal: status => { statuses.push(status); + snapshots.push({ ...parent }); finalized.resolve(); }, onNativePassthroughCancel: () => { cancels += 1; }, @@ -2958,6 +3057,72 @@ describe("server combo failover 030 activation matrix", () => { await within(finalized.promise); expect(statuses).toEqual(["completed"]); expect(cancels).toBe(0); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ provider: "combo", model: "combo/free", resolvedModel: "m2" }); + for (const field of ["terminalHttpStatus", "terminalIncompleteReason", "terminalErrorCode", "upstreamError"] as const) { + expect(snapshots[0]![field]).toBeUndefined(); + } + expect(parent.attempts).toMatchObject([ + { provider: "a", model: "m1", status: 429 }, + { provider: "b", model: "m2" }, + ]); + }); + + test("a metadata-less committed child preserves independently inspected parent metadata and scope", async () => { + const held = heldNativeTerminal({ + type: "response.incomplete", + response: { ...responsesSuccess("already visible", "m1"), status: "incomplete" }, + }); + const config = comboConfig({ a: provider("openai-responses", baseUrl(held.upstream), "key-a") }); + config.streamMode = "legacy-tee"; + const parent: RequestLogContext = { model: "", provider: "" }; + const finalized = deferred(); + const observed: Array<{ status: number; log: RequestLogContext }> = []; + try { + const response = await within(handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: true }), + }), config, parent, { + onNativePassthroughTerminal: status => { + observed.push({ status: httpStatusForRequestLogTerminal(status, parent), log: { ...parent } }); + finalized.resolve(); + }, + })); + expect(response.status).toBe(200); + expect(observed).toHaveLength(0); + const parentTrace = parent.routeDecision; + const parentAttempts = parent.attempts; + parent.firstOutputMs = 17; + // Scope-boundary regression, not a claim about WS scheduling: the WS + // bridge can inspect into its parent log independently of child inspection. + // Populate that state through the real inspector after preflight committed; + // the held child terminal deliberately defines none of these four fields. + inspectResponseLogSsePayload(parent, JSON.stringify({ + type: "response.incomplete", + response: { + incomplete_details: { reason: "usage_limit_reached" }, + error: { message: "parent-observed quota" }, + }, + })); + expect(parent.terminalHttpStatus).toBe(429); + held.release(); + expect(await within(response.text())).toContain("response.incomplete"); + await within(finalized.promise); + expect(observed).toHaveLength(1); + expect(observed[0]).toMatchObject({ + status: 429, + log: { + provider: "combo", model: "combo/free", requestedModel: "combo/free", + resolvedModel: "m1", comboId: "free", firstOutputMs: 17, + terminalHttpStatus: 429, terminalIncompleteReason: "usage_limit_reached", + upstreamError: "parent-observed quota", + }, + }); + expect(observed[0]!.log.routeDecision).toBe(parentTrace); + expect(observed[0]!.log.attempts).toBe(parentAttempts); + } finally { + held.release(); + } }); test("connect cancellation wins with 499, no backup, warning, or cooldown", async () => { diff --git a/tests/server/server-google-antigravity-oauth-401-replay.test.ts b/tests/server/server-google-antigravity-oauth-401-replay.test.ts new file mode 100644 index 0000000000..b1f55c238c --- /dev/null +++ b/tests/server/server-google-antigravity-oauth-401-replay.test.ts @@ -0,0 +1,679 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { forceRefreshOAuthAccessSnapshot, getValidAccessTokenSnapshot } from "../../src/oauth"; +import { getAccountSet, saveCredential } from "../../src/oauth/store"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const PROD_API_BASE = "https://cloudcode-pa.googleapis.com"; +const DAILY_API_BASE = "https://daily-cloudcode-pa.googleapis.com"; +const PUBLIC_OAUTH_AUTHENTICATION_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; +const WINDOWS_PATH_CANARY = "C:\\Users\\Alice\\.opencodex\\auth.json.ocx-tmp"; +const UNC_PATH_CANARY = "\\\\server\\share\\opencodex\\auth.json.ocx-tmp"; +const POSIX_PATH_CANARY = "/home/alice/.opencodex/auth.json.ocx-tmp"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let originalFetch: typeof fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-google-401-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-google-401-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +async function seedOAuth(expires = Date.now() + 3_600_000, projectId?: string | null): Promise { + await saveCredential("google-antigravity", { + access: "rejected-access", + refresh: "initial-refresh", + expires, + accountId: "antigravity-test-account", + ...(projectId !== undefined ? (projectId ? { projectId } : {}) : { projectId: "initial-project-id" }), + source: "oauth", + }); +} + +function antigravityConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: DAILY_API_BASE, + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "initial-project-id", + models: ["gemini-3.8-flash"], + }, + }, + } as OcxConfig; +} + +function antigravityPassthroughConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + // Canonical routing restores the Google adapter. The supported model-level + // override is applied afterwards and again when the OAuth replay is rebuilt. + // Synthetic native-branch coverage, not a claim about Google's supported API. + adapter: "google", + modelAdapters: { "gemini-3.8-flash": "openai-responses" }, + baseUrl: DAILY_API_BASE, + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "initial-project-id", + models: ["gemini-3.8-flash"], + }, + }, + } as OcxConfig; +} + +function jsonSuccessBody(text: string): Record { + return { + response: { + candidates: [{ + content: { + role: "model", + parts: [{ text }], + }, + finishReason: "STOP", + }], + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 3, + totalTokenCount: 8, + }, + }, + }; +} + +function sseSuccessBody(text: string): string { + return `data: ${JSON.stringify(jsonSuccessBody(text))}\n\n`; +} + +async function postResponses(server: ReturnType, stream = false, providerName = "google-antigravity"): Promise { + return originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `${providerName}/gemini-3.8-flash`, + input: "hello", + stream, + }), + }); +} + +async function postChat(server: ReturnType): Promise { + return originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.8-flash", + messages: [{ role: "user", content: "hello" }], + stream: false, + }), + }); +} + +function installOAuthFetch( + apiStatuses: number[], + options: { + tokenErrorDescription?: string; + refreshedProjectId?: string | null; + beforeFirstUnauthorized?: () => Promise; + } = {}, +): { chatAuth: string[]; chatProjects: string[]; requestPaths: string[]; counts: { refresh: number } } { + const chatAuth: string[] = []; + const chatProjects: string[] = []; + const requestPaths: string[] = []; + const counts = { refresh: 0 }; + let unauthorizedObserved = false; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + + const parsedUrl = new URL(url); + + // Google OAuth refresh token endpoint + if (url === GOOGLE_TOKEN_ENDPOINT) { + counts.refresh += 1; + if (options.tokenErrorDescription !== undefined) { + return new Response(JSON.stringify({ + error: "invalid_grant", + error_description: options.tokenErrorDescription, + }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }), { headers: { "content-type": "application/json" } }); + } + + // Google Cloud Code Assist project discovery + if (url === `${PROD_API_BASE}/v1internal:loadCodeAssist`) { + if (options.refreshedProjectId === null) { + return new Response(JSON.stringify({}), { status: 404, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ + cloudaicompanionProject: options.refreshedProjectId ?? "refreshed-project-id", + }), { headers: { "content-type": "application/json" } }); + } + + if (url === `${DAILY_API_BASE}/v1internal:onboardUser`) { + if (options.refreshedProjectId === null) { + return new Response(JSON.stringify({}), { status: 404, headers: { "content-type": "application/json" } }); + } + } + + // Responses passthrough endpoint + if (url === `${DAILY_API_BASE}/v1/responses`) { + requestPaths.push(parsedUrl.pathname); + const auth = new Headers(init?.headers).get("authorization") ?? ""; + chatAuth.push(auth); + const status = apiStatuses.shift() ?? 200; + if (status === 401 && !unauthorizedObserved) { + unauthorizedObserved = true; + await options.beforeFirstUnauthorized?.(); + } + if (status >= 400) { + return new Response(JSON.stringify({ + error: { + code: status, + message: "Request had invalid authentication credentials.", + status: status === 401 ? "UNAUTHENTICATED" : "PERMISSION_DENIED", + }, + }), { + status, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ + id: "resp-passthrough", + output: [{ + id: "msg-passthrough", + type: "message", + content: [{ type: "output_text", text: "ok after passthrough" }], + }], + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + // Google Antigravity Generate Content endpoint + if (parsedUrl.origin === DAILY_API_BASE + && ["/v1internal:streamGenerateContent", "/v1internal:generateContent"].includes(parsedUrl.pathname)) { + requestPaths.push(parsedUrl.pathname); + const auth = new Headers(init?.headers).get("authorization") ?? ""; + chatAuth.push(auth); + if (typeof init?.body === "string") { + try { + const parsedBody = JSON.parse(init.body) as { project?: string }; + if (parsedBody.project) chatProjects.push(parsedBody.project); + } catch { /* ignore */ } + } + const status = apiStatuses.shift() ?? 200; + if (status === 401 && !unauthorizedObserved) { + unauthorizedObserved = true; + await options.beforeFirstUnauthorized?.(); + } + if (status >= 400) { + return new Response(JSON.stringify({ + error: { + code: status, + message: "Request had invalid authentication credentials.", + status: status === 401 ? "UNAUTHENTICATED" : "PERMISSION_DENIED", + }, + }), { + status, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("alt=sse")) { + return new Response(sseSuccessBody("ok after google refresh"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response(JSON.stringify(jsonSuccessBody("ok after google refresh")), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (parsedUrl.hostname === "127.0.0.1" || parsedUrl.hostname === "localhost") return originalFetch(input, init); + throw new Error("Unexpected external request in Antigravity replay fixture"); + }) as typeof fetch; + return { chatAuth, chatProjects, requestPaths, counts }; +} + +describe("Google Antigravity OAuth upstream 401 replay", () => { + test.each([200, 401])("native passthrough replays once and returns the second HTTP %i", async secondStatus => { + await seedOAuth(); + saveConfig(antigravityPassthroughConfig()); + const observed = installOAuthFetch([401, secondStatus]); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(observed.requestPaths).toEqual(["/v1/responses", "/v1/responses"]); + expect(response.status).toBe(secondStatus); + const text = await response.text(); + if (secondStatus === 200) expect(text).toContain("ok after passthrough"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + } finally { + await server.stop(true); + } + }); + + test.each([false, true])("HTTP 403 never triggers OAuth refresh (native=%s)", async native => { + await seedOAuth(); + saveConfig(native ? antigravityPassthroughConfig() : antigravityConfig()); + const observed = installOAuthFetch([403]); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(observed.requestPaths).toEqual([native ? "/v1/responses" : "/v1internal:generateContent"]); + expect(response.status).toBe(403); + await response.text(); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + + test.each([false, true])("a custom key route does not consume Antigravity OAuth credentials (native=%s)", async native => { + await seedOAuth(); + const config = native ? antigravityPassthroughConfig() : antigravityConfig(); + // The canonical Antigravity name is normalized to OAuth by the router. A separately + // named key route is the supported non-OAuth boundary, not a fake canonical key mode. + const name = "antigravity-key-test"; + const provider = config.providers["google-antigravity"]!; + config.providers = { [name]: { ...provider, authMode: "key", apiKey: "static-key-sentinel" } }; + config.defaultProvider = name; + saveConfig(config); + const observed = installOAuthFetch([401]); + const server = startServer(0); + try { + const response = await postResponses(server, false, name); + expect(observed.requestPaths).toEqual([native ? "/v1/responses" : "/v1internal:generateContent"]); + expect(response.status).toBe(401); + await response.text(); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual(["Bearer static-key-sentinel"]); + } finally { + await server.stop(true); + } + }); + + test("retains the same account's stored project when refresh discovery has no project", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "initial-project-id"]); + const snapshot = await getValidAccessTokenSnapshot("google-antigravity"); + expect(snapshot.projectId).toBe("initial-project-id"); + expect(snapshot.accessToken).toBe("fresh-access"); + } finally { + await server.stop(true); + } + }); + + test.each([false, true])("401 recovery follows the newly selected account and its project (newer A generation=%s)", async newerGeneration => { + await seedOAuth(); + const accountA = getAccountSet("google-antigravity")!.activeAccountId; + const config = antigravityConfig(); + config.oauthAccountFailover = { enabled: false }; + config.providers["google-antigravity"]!.oauthAccountFailover = { enabled: false }; + saveConfig(config); + const observed = installOAuthFetch([401, 200], { + refreshedProjectId: "refreshed-project-a", + beforeFirstUnauthorized: async () => { + // Deterministic race point: the original A request was built and observed, but + // its HTTP 401 has not reached the recovery loop. No timing sleeps are needed. + if (newerGeneration) { + await saveCredential("google-antigravity", { + access: "newer-access-a", refresh: "newer-refresh-a", expires: Date.now() + 3_600_000, + accountId: "antigravity-test-account", projectId: "newer-project-a", source: "oauth", + }); + } + await saveCredential("google-antigravity", { + access: "access-b", refresh: "refresh-b", expires: Date.now() + 3_600_000, + accountId: "account-b", projectId: "project-b", source: "oauth", + }); + }, + }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ok after google refresh"); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer access-b"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "project-b"]); + const accounts = getAccountSet("google-antigravity")!; + expect(accounts.activeAccountId).not.toBe(accountA); + expect(accounts.accounts.find(account => account.id === accounts.activeAccountId)?.credential).toMatchObject({ + access: "access-b", projectId: "project-b", + }); + expect(accounts.accounts.find(account => account.id === accountA)?.credential).toMatchObject({ + access: newerGeneration ? "newer-access-a" : "rejected-access", + projectId: newerGeneration ? "newer-project-a" : "initial-project-id", + }); + } finally { + await server.stop(true); + } + }); + + test("forceRefreshOAuthAccessSnapshot supports google-antigravity", async () => { + await seedOAuth(); + installOAuthFetch([], { refreshedProjectId: "rediscovered-project-xyz" }); + + const snapshot = await getValidAccessTokenSnapshot("google-antigravity"); + expect(snapshot.provider).toBe("google-antigravity"); + expect(snapshot.accessToken).toBe("rejected-access"); + + const refreshed = await forceRefreshOAuthAccessSnapshot(snapshot); + expect(refreshed.provider).toBe("google-antigravity"); + expect(refreshed.accessToken).toBe("fresh-access"); + expect(refreshed.projectId).toBe("rediscovered-project-xyz"); + }); + + test("initial OAuth refresh projects raw provider failures before responding", async () => { + await seedOAuth(0); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([], { + tokenErrorDescription: `EACCES writing ${WINDOWS_PATH_CANARY}, ${UNC_PATH_CANARY}, or ${POSIX_PATH_CANARY}`, + }); + const server = startServer(0); + try { + const response = await postResponses(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + const message = json.error?.message ?? ""; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(message).not.toContain(WINDOWS_PATH_CANARY); + expect(message).not.toContain(UNC_PATH_CANARY); + expect(message).not.toContain(POSIX_PATH_CANARY); + expect(message).not.toContain("auth.json"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("OAuth 401 replay projects raw refresh failures before responding", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401], { + tokenErrorDescription: `EACCES writing ${WINDOWS_PATH_CANARY}, ${UNC_PATH_CANARY}, or ${POSIX_PATH_CANARY}`, + }); + const server = startServer(0); + try { + const response = await postResponses(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + const message = json.error?.message ?? ""; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(message).not.toContain(WINDOWS_PATH_CANARY); + expect(message).not.toContain(UNC_PATH_CANARY); + expect(message).not.toContain(POSIX_PATH_CANARY); + expect(message).not.toContain("auth.json"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + + test("401 then 200 on /v1/responses performs one refresh and one replay with refreshed token and project", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: "new-project-456" }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(200); + const json = await response.json() as { output?: { type: string; content?: { text?: string }[] }[] }; + expect(json.output?.find(item => item.type === "message")?.content?.[0]?.text).toBe("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "new-project-456"]); + } finally { + await server.stop(true); + } + }); + + test("401 then 200 on /v1/chat/completions performs one refresh and one replay seamlessly", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: "chat-project-789" }); + const server = startServer(0); + try { + const response = await postChat(server); + expect(response.status).toBe(200); + const json = await response.json() as { choices?: { message?: { content?: string } }[] }; + expect(json.choices?.[0]?.message?.content).toBe("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "chat-project-789"]); + } finally { + await server.stop(true); + } + }); + + test("401 then 401 replays once and propagates the second error cleanly", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 401]); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(401); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + } finally { + await server.stop(true); + } + }); + + test("concurrent 401 responses join one IdP refresh", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + let refreshCalls = 0; + let signalRefreshStarted!: () => void; + const refreshStarted = new Promise(resolve => { signalRefreshStarted = resolve; }); + let releaseRefresh!: () => void; + const refreshGate = new Promise(resolve => { releaseRefresh = resolve; }); + let releaseRejectedRequests!: () => void; + const rejectedRequestsReady = new Promise(resolve => { releaseRejectedRequests = resolve; }); + const attemptsByBearer = new Map(); + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const parsedUrl = new URL(url); + if (url === GOOGLE_TOKEN_ENDPOINT) { + refreshCalls += 1; + signalRefreshStarted(); + await refreshGate; + return new Response(JSON.stringify({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }), { headers: { "content-type": "application/json" } }); + } + if (url === `${PROD_API_BASE}/v1internal:loadCodeAssist`) { + return new Response(JSON.stringify({ + cloudaicompanionProject: "concurrent-project-id", + }), { headers: { "content-type": "application/json" } }); + } + if (parsedUrl.origin === DAILY_API_BASE + && ["/v1internal:streamGenerateContent", "/v1internal:generateContent"].includes(parsedUrl.pathname)) { + const bearer = new Headers(init?.headers).get("authorization") ?? ""; + attemptsByBearer.set(bearer, (attemptsByBearer.get(bearer) ?? 0) + 1); + if (bearer === "Bearer rejected-access") { + if (attemptsByBearer.get(bearer) === 2) releaseRejectedRequests(); + await rejectedRequestsReady; + return new Response(JSON.stringify({ + error: { + code: 401, + message: "Request had invalid authentication credentials.", + status: "UNAUTHENTICATED", + }, + }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("alt=sse")) { + return new Response(sseSuccessBody("concurrent ok"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response(JSON.stringify(jsonSuccessBody("concurrent ok")), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + const hostname = parsedUrl.hostname; + if (hostname === "127.0.0.1" || hostname === "localhost") return originalFetch(input, init); + throw new Error("Unexpected external request in concurrent Antigravity replay fixture"); + }) as typeof fetch; + + const server = startServer(0); + try { + const first = postResponses(server); + const second = postResponses(server); + await refreshStarted; + releaseRefresh(); + const [a, b] = await Promise.all([first, second]); + expect([a.status, b.status]).toEqual([200, 200]); + expect(refreshCalls).toBe(1); + expect(attemptsByBearer.get("Bearer rejected-access")).toBe(2); + expect(attemptsByBearer.get("Bearer fresh-access")).toBe(2); + } finally { + await server.stop(true); + } + }); + + test("project-less account is refused before dispatch in native Responses passthrough", async () => { + await seedOAuth(undefined, null); + saveConfig(antigravityPassthroughConfig()); + const observed = installOAuthFetch([401], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(observed.requestPaths).toEqual([]); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("project-less account is refused before dispatch in generic adapter", async () => { + await seedOAuth(undefined, null); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(observed.requestPaths).toEqual([]); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("project-less account is refused before dispatch in chat completions", async () => { + await seedOAuth(undefined, null); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postChat(server); + const json = await response.json() as { error?: { message?: string; type?: string } }; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("401 then 200 on /v1/responses with stream: true performs one refresh and one replay with refreshed token and project", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: "stream-project-999" }); + const server = startServer(0); + try { + const response = await postResponses(server, true); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let streamText = ""; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + streamText += decoder.decode(chunk.value, { stream: true }); + } + expect(streamText).toContain("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "stream-project-999"]); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 57ab87b2cb..d23bf848dd 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; @@ -10,6 +10,11 @@ import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { managementFetch } from "../helpers/management-auth"; +import { resetProviderRequestPacingForTest, setProviderRequestPacingRuntimeForTest, waitForProviderRequestSlot } from "../../src/providers/request-pacing"; +import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../../src/providers/api-key-selection"; +import { routedProviderConfig } from "../../src/router"; +import type { OcxProviderTransport } from "../../src/providers/xai-transport"; let testDir = ""; let previousHome: string | undefined; @@ -38,6 +43,137 @@ afterEach(() => { }); describe("server 429 key failover (end-to-end)", () => { + test("physical key selection rejects disabled, removed, and changed-auth providers", () => { + const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", authMode: "key", apiKey: "synthetic-first" } as const; + const config = { providers: { current: { ...provider } } } as unknown as OcxConfig; + const routed = routedProviderConfig("current", config.providers.current); + expect(providerApiKeySelectionIsCurrent(config, "current", routed)).toBe(true); + // A model-level wire override does not change which key was selected. + expect(providerApiKeySelectionIsCurrent(config, "current", { ...routed, adapter: "openai-responses" })).toBe(true); + for (const replacement of [{ ...provider, disabled: true }, { ...provider, authMode: "oauth" }, { ...provider, apiKey: undefined }]) { + config.providers.current = replacement as OcxConfig["providers"][string]; + expect(providerApiKeySelectionIsCurrent(config, "current", routed)).toBe(false); + expect(resolveCurrentProviderApiKeyTransport(config, "current", routed)).toBeNull(); + } + delete config.providers.current; + expect(providerApiKeySelectionIsCurrent(config, "current", routed)).toBe(false); + expect(resolveCurrentProviderApiKeyTransport(config, "current", routed)).toBeNull(); + }); + + test("physical transport refresh keeps its executor and affinity but takes current static headers", () => { + const config = { providers: { current: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", authMode: "key", apiKey: "synthetic-first", + headers: { "x-old-static": "old" }, apiKeySelectionRevision: "first-revision", + } } } as unknown as OcxConfig; + const executor = (async () => Response.json({})) as typeof fetch; + const routed: OcxProviderTransport = { + ...routedProviderConfig("current", config.providers.current), fetch: executor, + headers: { "x-old-static": "old", "x-opencode-session": "runtime-session" }, + }; + config.providers.current = { ...config.providers.current, apiKey: "synthetic-second", + apiKeySelectionRevision: "second-revision", headers: { "x-new-static": "new" }, + }; + expect(providerApiKeySelectionIsCurrent(config, "current", routed)).toBe(false); + const current = resolveCurrentProviderApiKeyTransport(config, "current", routed) as OcxProviderTransport; + expect(current.apiKey).toBe("synthetic-second"); + expect(current.fetch).toBe(executor); + expect(current.headers).toEqual({ "x-new-static": "new", "x-opencode-session": "runtime-session" }); + expect(providerApiKeySelectionIsCurrent(config, "current", current)).toBe(true); + }); + + test("native Chat rebuilds a queued request after a manual key selection during pacing", async () => { + let now = 0; + let resumePacing: (() => void) | undefined; + const queued = Promise.withResolvers(); + setProviderRequestPacingRuntimeForTest({ + now: () => now, + setTimer(callback, delayMs) { + resumePacing = () => { now += delayMs; callback(); }; + queued.resolve(); + return callback; + }, + clearTimer() {}, + enqueueMicrotask: queueMicrotask, + }); + const seen: Headers[] = []; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + seen.push(new Headers(req.headers)); + return Response.json({ id: "chatcmpl-paced", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "current selection" }, finish_reason: "stop" }], + }); + } }); + const config = { port: 0, hostname: "127.0.0.1", defaultProvider: "paced", providers: { paced: { + adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, + authMode: "key", apiKey: "synthetic-first", headers: { "x-static-test": "retained" }, + apiKeyPool: [{ id: "first", key: "synthetic-first" }, { id: "second", key: "synthetic-second" }], + requestPacing: { enabled: true, minIntervalMs: 100 }, + } } } as OcxConfig; + saveConfig(config); + const server = startServer(0); + const abort = new AbortController(); + try { + await waitForProviderRequestSlot("paced", config.providers.paced); + const pending = fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", headers: { "content-type": "application/json" }, signal: abort.signal, + body: JSON.stringify({ model: "paced/test", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + await queued.promise; + expect(seen).toHaveLength(0); + const selected = await managementFetch(new URL("/api/providers/keys/active", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "paced", id: "second" }), + }); + expect(selected.status).toBe(200); + await selected.text(); + resumePacing!(); + const response = await pending; + expect(response.status).toBe(200); + expect(await response.text()).toContain("current selection"); + expect(seen.map(headers => headers.get("authorization"))).toEqual(["Bearer synthetic-second"]); + expect(seen[0]!.get("x-static-test")).toBe("retained"); + } finally { + abort.abort(); + await server.stop(true); + resetProviderRequestPacingForTest(); + } + }); + + test.each(["responses", "chat/completions"])("%s carries the configured env-key identity through 429 recovery", async inbound => { + const seen: string[] = []; + process.env.OCX_SELECTION_E2E_KEY = "synthetic-env-first"; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + seen.push(req.headers.get("authorization") ?? ""); + if (seen.length === 1) return Response.json({ error: { message: "rate limited" } }, { status: 429 }); + return Response.json({ id: "chatcmpl-env", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "recovered" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } }); + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "pooled", providers: { pooled: { + adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, + authMode: "key", apiKey: "${OCX_SELECTION_E2E_KEY}", apiKeyPool: [ + { id: "first", key: "${OCX_SELECTION_E2E_KEY}" }, { id: "second", key: "synthetic-second" }, + ], + } } } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL(`/v1/${inbound}`, server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pooled/test", stream: false, + ...(inbound === "responses" ? { input: "hello" } : { messages: [{ role: "user", content: "hello" }] }), + }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("recovered"); + expect(seen).toEqual(["Bearer synthetic-env-first", "Bearer synthetic-second"]); + expect(loadConfig().providers.pooled.apiKey).toBe("synthetic-second"); + expect(loadConfig().providers.pooled._apiKeyAttempt).toBeUndefined(); + } finally { + await server.stop(true); + delete process.env.OCX_SELECTION_E2E_KEY; + } + }); + test("xAI API-key rotation preserves cache affinity and never adds OAuth CLI headers", async () => { const originalFetch = globalThis.fetch; const promptCacheKey = "codex-session-high-entropy-429-e2e"; diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index 2bc804da5e..340ad70bd3 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; -import { findAvailablePort } from "../../src/server/ports"; import type { OcxConfig } from "../../src/types"; import { serveGuiFile, serveSessionBootstrap } from "../../src/server/gui-static"; import { isProxyAdmissionSecret } from "../../src/server/auth-cors"; @@ -116,6 +115,37 @@ function hubConfig(publicOrigin = "https://hub.example.test"): OcxConfig { }; } +/** Keep real ingress/handlers while the kernel allocates both ports at the actual bind. */ +async function startEphemeralHubServer(deps: Parameters[1]) { + const nativeServe = Bun.serve.bind(Bun); + const listeners: Array> = []; + const hostnames: unknown[] = []; + const serveSpy = spyOn(Bun, "serve").mockImplementation((options) => { + const listener = nativeServe({ ...options, port: 0 } as Parameters[0]); + listeners.push(listener); + hostnames.push("hostname" in options ? options.hostname : undefined); + return listener; + }); + try { + let server: ReturnType; + try { + server = startServer(0, deps); + } finally { + // startServer is synchronous; restore before requests or any awaited cleanup. + serveSpy.mockRestore(); + } + expect(listeners).toHaveLength(2); + expect(listeners[0]).toBe(server); + expect(hostnames).toEqual(["0.0.0.0", "127.0.0.1"]); + const managementPort = listeners[1]?.port; + if (!managementPort || managementPort === server.port) throw new Error("expected distinct live ingress ports"); + return { server, managementPort }; + } catch (error) { + await Promise.allSettled(listeners.map(async listener => { await listener.stop(true); })); + throw error; + } +} + function websocketHandshakeOpens(url: URL, token: string): Promise { return new Promise(resolve => { const target = new URL("/v1/responses", url); @@ -978,19 +1008,15 @@ describe("management and data-plane credential separation", () => { }); test("the live listener trusts Tailscale identity only on hub management ingress", async () => { - const managementPort = await findAvailablePort(0, "127.0.0.1"); const config = hubConfig(); config.hub = { ...config.hub, - managementIngress: { enabled: true, port: managementPort }, + managementIngress: { enabled: true, port: 10101 }, }; saveConfig(config); const state = initializeManagementAuthState(config); if (!state.available) throw new Error("expected management auth state"); - // The public listener's exact port is irrelevant to this contract. Let the - // kernel allocate it atomically instead of probing and releasing a port that - // another parallel test can claim before startServer binds it. - const server = startServer(0, { managementAuthState: state }); + const { server, managementPort } = await startEphemeralHubServer({ managementAuthState: state }); const headers = { Host: "hub.example.test", "Tailscale-User-Login": "alice@example.test" }; try { const spoofedPublic = await fetch(new URL("/opencodex-session", server.url), { headers }); @@ -1166,15 +1192,13 @@ describe("management and data-plane credential separation", () => { }); test("the management ingress preserves the one-use pairing exchange contract", async () => { - const managementPort = await findAvailablePort(0, "127.0.0.1"); - const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: managementPort }); const config = hubConfig(); - config.hub = { ...config.hub, managementIngress: { enabled: true, port: managementPort } }; + config.hub = { ...config.hub, managementIngress: { enabled: true, port: 10101 } }; saveConfig(config); const state = initializeManagementAuthState(config); if (!state.available) throw new Error("expected management auth state"); const created = createGuiPairingGrant("https://dashboard.example.test", config, state); - const server = startServer(publicPort, { managementAuthState: state }); + const { server, managementPort } = await startEphemeralHubServer({ managementAuthState: state }); const url = `http://127.0.0.1:${managementPort}/opencodex-session`; const headers = { Host: "hub.example.test", @@ -1628,3 +1652,63 @@ describe("codex app-server restart routes ride the management gate", () => { } }); }); + + +test("log cursors remain behind management admission and origin gates", async () => { + const config = remoteConfig(); + saveConfig(config); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const server = startServer(0, { managementAuthState: state }); + const origin = server.url.origin; + const token = "ocx_session_log_cursor_test"; + state.sessions.set(token, { + serverOrigin: origin, browserOrigin: origin, csrfToken: "csrf-log-test", + expiresAt: Date.now() + 60_000, issuance: "loopback", + }); + const adminHeaders = { "x-opencodex-api-key": "admin-secret" }; + const acceptedHeaders: HeadersInit[] = [adminHeaders, { + Origin: origin, "x-opencodex-api-key": token, "x-opencodex-gui-origin": origin, + }]; + try { + const initial = await fetch(new URL("/api/logs", server.url), { headers: adminHeaders }); + expect(initial.status).toBe(200); + const body = await initial.json() as { cursor: string }; + expect(typeof body.cursor).toBe("string"); + for (const suffix of ["", `?cursor=${body.cursor}`, "?cursor=malformed"]) { + const url = new URL(`/api/logs${suffix}`, server.url); + for (const credential of [undefined, "data-secret", "wrong-admin"]) { + const response = await fetch(url, { headers: credential ? { "x-opencodex-api-key": credential } : {} }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "opencodex admin token required" }); + } + const foreign = await fetch(url, { headers: { ...adminHeaders, Origin: "https://attacker.test" } }); + expect(foreign.status).toBe(403); + await foreign.text(); + for (const headers of acceptedHeaders) { + const allowed = await fetch(url, { headers }); + expect(allowed.status).toBe(suffix.includes("malformed") ? 400 : 200); + await allowed.text(); + } + } + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); + +test("unavailable management authority rejects log cursors before parsing", async () => { + saveConfig(remoteConfig()); + const server = startServer(0, { managementAuthState: { available: false, reason: "fixture unavailable" } }); + try { + const legacy = Buffer.from(JSON.stringify({ v: 1, t: 1, id: "fixture" })).toString("base64url"); + for (const suffix of ["", `?cursor=${legacy}`, "?cursor=malformed"]) { + const response = await fetch(new URL(`/api/logs${suffix}`, server.url), { + headers: { "x-opencodex-api-key": "admin-secret" }, + }); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: "management API unavailable" }); + } + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); diff --git a/tests/server/server-xai-oauth-401-replay.test.ts b/tests/server/server-xai-oauth-401-replay.test.ts index 6ad96b59d2..aab488ccb5 100644 --- a/tests/server/server-xai-oauth-401-replay.test.ts +++ b/tests/server/server-xai-oauth-401-replay.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync} from "node:fs"; +import { mkdtempSync, readFileSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { XAI_OAUTH_DISCOVERY_URL } from "../../src/oauth/xai"; import { saveCredential } from "../../src/oauth/store"; import { XAI_GROK_CLI_BASE_URL } from "../../src/providers/xai-transport"; +import { readUsageEntries, usageLogPath } from "../../src/usage/log"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -209,6 +210,14 @@ describe("xAI OAuth Responses opt-in upstream 401 replay", () => { expect(json.output?.find(item => item.type === "message")?.content?.[0]?.text).toBe("ok after refresh"); expect(observed.counts.refresh).toBe(1); expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + const attempt = readUsageEntries().at(-1)?.attempts?.[0]; + expect(attempt?.credentialSource).toBe("grok-oauth"); + expect(attempt?.sendCount).toBe(2); + expect(attempt?.totalTokens).toBe(5); + const persisted = readFileSync(usageLogPath(), "utf8"); + expect(persisted).not.toContain("rejected-access"); + expect(persisted).not.toContain("fresh-access"); + expect(persisted).not.toContain("xai-test-account"); } finally { await server.stop(true); } @@ -231,6 +240,96 @@ describe("xAI OAuth Responses opt-in upstream 401 replay", () => { } }); + test("native Chat records canonical API-key provenance", async () => { + saveConfig(xaiConfig("key")); + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + expect(url).toBe("https://api.x.ai/v1/chat/completions"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer xai-api-key"); + return Response.json({ + id: "chat-native-xai", object: "chat.completion", model: "grok-4.5", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }); + }) as typeof fetch; + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "xai/grok-4.5", messages: [{ role: "user", content: "hello" }], stream: false }), + }); + expect(response.status).toBe(200); + await response.json(); + const entry = readUsageEntries().at(-1); + expect(entry?.inboundProtocol).toBe("chat"); + expect(entry?.attempts?.[0]?.credentialSource).toBe("xai-api-key"); + expect(entry?.attempts?.[0]?.totalTokens).toBe(5); + expect(entry?.attempts?.[0]?.sendCount).toBe(1); + } finally { + await server.stop(true); + } + }); + + test("native Chat 429 rotates configured apiKeyPool and keeps canonical xAI source", async () => { + const firstKey = "xai-pool-key-alpha-000111222333"; + const secondKey = "xai-pool-key-beta-444555666777"; + saveConfig({ + ...xaiConfig("key"), + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: firstKey, + apiKeyPool: [ + { id: "k1", key: firstKey, addedAt: 1 }, + { id: "k2", key: secondKey, addedAt: 2 }, + ], + models: ["grok-4.5"], + }, + }, + } as OcxConfig); + const seenAuth: string[] = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + expect(url).toBe("https://api.x.ai/v1/chat/completions"); + seenAuth.push(new Headers(init?.headers).get("authorization") ?? ""); + if (seenAuth.length === 1) { + return new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "retry-after": "30", "content-type": "application/json" }, + }); + } + return Response.json({ + id: "chat-native-xai-rotate", object: "chat.completion", model: "grok-4.5", + choices: [{ index: 0, message: { role: "assistant", content: "ok after rotate" }, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }); + }) as typeof fetch; + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "xai/grok-4.5", messages: [{ role: "user", content: "hello" }], stream: false }), + }); + expect(response.status).toBe(200); + await response.json(); + expect(seenAuth).toEqual([`Bearer ${firstKey}`, `Bearer ${secondKey}`]); + const entries = readUsageEntries(); + expect(entries).toHaveLength(1); + const attempt = entries[0]?.attempts?.[0]; + expect(entries[0]?.attempts).toHaveLength(1); + expect(attempt?.credentialSource).toBe("xai-api-key"); + expect(attempt?.sendCount).toBe(2); + expect(attempt?.adapter).toBe("openai-chat"); + const persisted = readFileSync(usageLogPath(), "utf8"); + expect(persisted).not.toContain(firstKey); + expect(persisted).not.toContain(secondKey); + } finally { + await server.stop(true); + } + }); + test("API-key xAI path never attempts OAuth refresh", async () => { saveConfig(xaiConfig("key")); let refreshCalls = 0; @@ -257,6 +356,7 @@ describe("xAI OAuth Responses opt-in upstream 401 replay", () => { expect(response.status).toBe(401); expect(chatCalls).toBe(1); expect(refreshCalls).toBe(0); + expect(readUsageEntries().at(-1)?.attempts?.[0]?.credentialSource).toBe("xai-api-key"); } finally { await server.stop(true); } diff --git a/tests/service/container-bootstrap.test.ts b/tests/service/container-bootstrap.test.ts index fd1eef7412..ada807d1d0 100644 --- a/tests/service/container-bootstrap.test.ts +++ b/tests/service/container-bootstrap.test.ts @@ -1,23 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { - bootstrapContainerTls, - CONTAINER_TLS_CERT_NAME, - CONTAINER_TLS_IDENTITY_DIR_NAME, - CONTAINER_TLS_KEY_NAME, - ensureContainerTls, - type OpenSslRunner, -} from "../../docker/bootstrap-tls"; -import { bootstrapToken, readBoundedToken } from "../../docker/bootstrap-token"; +import { pathToFileURL } from "node:url"; +import { readBoundedToken } from "../../docker/bootstrap-token"; import { verifyCompatibilitySnapshot } from "../../docker/verify-compatibility"; -import { loadServiceTokenFromFile } from "../../src/lib/service-secrets"; -import { - REQUIRED_COMPATIBILITY_FILES, - type CompatibilityVersionManifest, -} from "../../scripts/generate-compatibility-version"; +import type { CompatibilityVersionManifest } from "../../scripts/generate-compatibility-version"; +import type { SerializedCatalog } from "../../src/server/catalog-download"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -37,165 +28,58 @@ describe("container token bootstrap", () => { }); test("accepts a maximum-size token followed by a shell newline", async () => { - const token = "x".repeat(512); + const token = "x".repeat(4096); await expect(readBoundedToken(input(token, "\n"))).resolves.toBe(token); }); - test("round-trips a maximum-size bootstrap token through the startup reader", async () => { - const root = mkdtempSync(join(tmpdir(), "ocx-container-token-")); - snapshotDirs.push(root); - const previousHome = process.env.OPENCODEX_HOME; - process.env.OPENCODEX_HOME = root; - try { - const token = "x".repeat(512); - await bootstrapToken(input(token, "\n")); - const path = join(root, "service-api-token"); - expect(lstatSync(path).size).toBe(513); - expect(loadServiceTokenFromFile({ OCX_API_TOKEN_FILE: path })).toBe(token); - } finally { - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - } - }); - test("rejects empty, multiline, and oversized input", async () => { await expect(readBoundedToken(input(" \n"))).rejects.toThrow("token input is empty"); await expect(readBoundedToken(input("first\nsecond\n"))).rejects.toThrow("exactly one line"); await expect(readBoundedToken(input("first\n\n"))).rejects.toThrow("exactly one line"); await expect(readBoundedToken(input("\nfirst\n"))).rejects.toThrow("exactly one line"); - await expect(readBoundedToken(input("x".repeat(513), "\n"))).rejects.toThrow("exceeds 512 bytes"); + await expect(readBoundedToken(input("x".repeat(4097), "\n"))).rejects.toThrow("exceeds 4096 bytes"); }); }); -describe("container TLS bootstrap", () => { - function fakeOpenSsl(options: { mismatch?: boolean } = {}): OpenSslRunner { - return argv => { - if (argv.includes("req")) { - writeFileSync(argv[argv.indexOf("-keyout") + 1]!, "private key"); - writeFileSync(argv[argv.indexOf("-out") + 1]!, "certificate"); - } - if (argv.includes("-pubkey")) return { exitCode: 0, stdout: "public-key\n" }; - if (argv.includes("pkey")) return { exitCode: 0, stdout: options.mismatch ? "other-key\n" : "public-key\n" }; - return { exitCode: 0, stdout: "" }; - }; - } - - test("creates an owner-only key and reuses the complete per-volume identity", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-container-tls-")); - snapshotDirs.push(root); - let calls = 0; - const delegate = fakeOpenSsl(); - const run: OpenSslRunner = argv => { - if (argv.includes("req")) calls++; - return delegate(argv); +describe("container deployment contract", () => { + test("persists separate OCX and Codex homes under the read-only root", () => { + const compose = Bun.YAML.parse(readFileSync(repoPath("compose.yaml"), "utf8")) as { + services: { hub: { + environment: Record; volumes: string[]; read_only: boolean; + security_opt: string[]; cap_drop: string[]; + } }; + volumes: Record; }; - expect(ensureContainerTls(root, run)).toBe("created"); - expect(ensureContainerTls(root, run)).toBe("present"); - expect(calls).toBe(1); - const identity = join(root, CONTAINER_TLS_IDENTITY_DIR_NAME); - expect(readFileSync(join(identity, CONTAINER_TLS_CERT_NAME), "utf8")).toBe("certificate"); - expect(readFileSync(join(identity, CONTAINER_TLS_KEY_NAME), "utf8")).toBe("private key"); - if (process.platform !== "win32") { - expect(lstatSync(root).mode & 0o777).toBe(0o700); - expect(lstatSync(identity).mode & 0o777).toBe(0o700); - expect(lstatSync(join(identity, CONTAINER_TLS_KEY_NAME)).mode & 0o077).toBe(0); - } - }); - - test("refuses an incomplete pre-existing identity and a mismatched pair", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-container-tls-partial-")); - snapshotDirs.push(root); - const identity = join(root, CONTAINER_TLS_IDENTITY_DIR_NAME); - mkdirSync(identity, { mode: 0o700 }); - writeFileSync(join(identity, CONTAINER_TLS_KEY_NAME), "private key", { mode: 0o600 }); - expect(() => ensureContainerTls(root, fakeOpenSsl())).toThrow("bounded regular file"); - writeFileSync(join(identity, CONTAINER_TLS_CERT_NAME), "certificate", { mode: 0o644 }); - expect(() => ensureContainerTls(root, fakeOpenSsl({ mismatch: true }))).toThrow("do not match"); + const hub = compose.services.hub; + expect(hub.environment?.CODEX_HOME).toBe("/home/bun/.codex"); + expect(hub.read_only).toBe(true); + expect(hub.volumes).toContain("ocx-state:/home/bun/.opencodex"); + expect(hub.volumes).toContain("codex-state:/home/bun/.codex"); + expect(Object.hasOwn(compose.volumes, "ocx-state")).toBe(true); + expect(Object.hasOwn(compose.volumes, "codex-state")).toBe(true); + expect(hub.security_opt).toContain("no-new-privileges:true"); + expect(hub.cap_drop).toContain("ALL"); + + const runtime = readFileSync(repoPath("Dockerfile"), "utf8").split(" AS runtime")[1]!; + expect(runtime).toContain("OPENCODEX_HOME=/home/bun/.opencodex"); + expect(runtime).toContain("CODEX_HOME=/home/bun/.codex"); + expect(runtime).toContain("install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex"); + expect(runtime).toContain('VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"]'); + expect(runtime).toContain("USER bun"); }); - test("hardens the state directory and recovers an abandoned private staging directory", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-container-tls-recovery-")); - snapshotDirs.push(root); - const abandoned = join(root, ".container-tls-stage-interrupted"); - mkdirSync(abandoned, { mode: 0o700 }); - writeFileSync(join(abandoned, "key.pem"), "partial", { mode: 0o600 }); - if (process.platform !== "win32") chmodSync(root, 0o777); - expect(ensureContainerTls(root, fakeOpenSsl())).toBe("created"); - expect(existsSync(abandoned)).toBe(false); - if (process.platform !== "win32") expect(lstatSync(root).mode & 0o777).toBe(0o700); - }); - - test.skipIf(process.platform === "win32")("refuses linked state and identity directories", () => { - const target = mkdtempSync(join(tmpdir(), "ocx-container-tls-link-target-")); - const parent = mkdtempSync(join(tmpdir(), "ocx-container-tls-link-parent-")); - snapshotDirs.push(target, parent); - const linkedState = join(parent, "state"); - symlinkSync(target, linkedState, "dir"); - expect(() => ensureContainerTls(linkedState, fakeOpenSsl())).toThrow("state path is not a regular directory"); - - const identityTarget = join(parent, "identity-target"); - mkdirSync(identityTarget, { mode: 0o700 }); - symlinkSync(identityTarget, join(target, CONTAINER_TLS_IDENTITY_DIR_NAME), "dir"); - expect(() => ensureContainerTls(target, fakeOpenSsl())).toThrow("identity directory is not a regular directory"); - }); - - test("migrates retained non-TLS config and derives the public origin from the host port", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-container-tls-migrate-")); - snapshotDirs.push(root); - const configPath = join(root, "config.json"); - writeFileSync(configPath, '{"hostname":"0.0.0.0","providers":{}}\n', { mode: 0o600 }); - const before = process.env["OPENCODEX_HOME"]; - process.env["OPENCODEX_HOME"] = root; - try { - expect(bootstrapContainerTls(root, configPath, { OCX_CONTAINER_PUBLIC_PORT: "10190" }, fakeOpenSsl())).toBe("created"); - expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({ - hostname: "0.0.0.0", - tls: { - certFile: "/home/bun/.opencodex/container-tls/cert.pem", - keyFile: "/home/bun/.opencodex/container-tls/key.pem", - publicOrigin: "https://localhost:10190", - }, - }); - expect(bootstrapContainerTls(root, configPath, { OCX_CONTAINER_PUBLIC_PORT: "443" }, fakeOpenSsl())).toBe("present"); - expect(JSON.parse(readFileSync(configPath, "utf8")).tls.publicOrigin).toBe("https://localhost"); - - const customized = JSON.parse(readFileSync(configPath, "utf8")); - customized.tls.publicOrigin = "https://hub.example.test"; - writeFileSync(configPath, `${JSON.stringify(customized)}\n`, { mode: 0o600 }); - expect(bootstrapContainerTls(root, configPath, { OCX_CONTAINER_PUBLIC_PORT: "10443" }, fakeOpenSsl())).toBe("present"); - expect(JSON.parse(readFileSync(configPath, "utf8")).tls.publicOrigin).toBe("https://hub.example.test"); - expect(bootstrapContainerTls(root, configPath, { - OCX_CONTAINER_PUBLIC_PORT: "10443", - OCX_CONTAINER_PUBLIC_ORIGIN: "https://new-hub.example.test", - }, fakeOpenSsl())).toBe("present"); - expect(JSON.parse(readFileSync(configPath, "utf8")).tls.publicOrigin).toBe("https://new-hub.example.test"); - } finally { - if (before === undefined) delete process.env["OPENCODEX_HOME"]; - else process.env["OPENCODEX_HOME"] = before; - } - }); -}); - -describe("container deployment contract", () => { test("publishes only the data port with loopback and explicit bind overrides", () => { const compose = Bun.YAML.parse(readFileSync(repoPath("compose.yaml"), "utf8")) as { - services: { hub: { ports: string[]; environment: Record } }; + services: { hub: { ports: string[] } }; }; expect(compose.services.hub.ports).toEqual([ "${OPENCODEX_BIND_ADDRESS:-127.0.0.1}:${OPENCODEX_PORT:-10100}:10100", ]); - expect(compose.services.hub.environment).toEqual({ - OCX_CONTAINER_PUBLIC_PORT: "${OPENCODEX_PORT:-10100}", - OCX_CONTAINER_PUBLIC_ORIGIN: "${OPENCODEX_PUBLIC_ORIGIN:-}", - }); }); test("requires the host-generated manifest in the runtime image", () => { const ignored = readFileSync(repoPath(".dockerignore"), "utf8").split(/\r?\n/); expect(ignored[0]).toBe("**"); - expect(ignored).toContain("!.dockerignore"); - expect(ignored).toContain("!Dockerfile"); - expect(ignored).toContain("!compose.yaml"); expect(ignored).toContain("!src/generated/compatibility-version.json"); expect(ignored).not.toContain("src/generated/compatibility-version.json"); expect(ignored.some(line => /^!\/?\.git(?:\/|$)/.test(line))).toBe(false); @@ -203,82 +87,23 @@ describe("container deployment contract", () => { "!scripts/", "scripts/**", "!scripts/model-metadata.source.json", ]); expect(ignored).not.toContain("!scripts/**"); - expect(ignored.slice(ignored.indexOf("!docker/"), ignored.indexOf("!docker/") + 7)).toEqual([ - "!docker/", - "docker/**", - "!docker/bootstrap-tls.ts", - "!docker/bootstrap-token.ts", - "!docker/config.json", - "!docker/healthcheck.ts", - "!docker/verify-compatibility.ts", - ]); - expect(ignored).not.toContain("!docker/**"); - const lastSourceNegation = Math.max( - ignored.indexOf("!src/**"), - ignored.indexOf("!docker/verify-compatibility.ts"), - ignored.indexOf("!gui/**"), - ); - for (const sensitive of [ - "**/.git", - "**/.tmp", - "**/.worktrees", - "**/.codex", - "**/.opencode", - "**/.planning", - "**/.agents", - "**/.claude", - "**/.cursor", - "**/.windsurf", - "**/.ssh", - "**/.gnupg", - "**/.aws", - "**/.docker/config.json", - "**/.config/containers/auth.json", - "**/.config/gh/hosts.yml", - "**/.opencodex", - "**/.env", - "**/.env.*", - "**/.npmrc", - "**/.netrc", - "**/.pypirc", - "**/auth.json", - "**/credentials.json", - "**/*.pem", - "**/*.key", - "**/*.p12", - "**/*.pfx", - "**/*.jks", - "**/*.sqlite", - "**/*.sqlite3", - "**/*.db", - ]) { - expect(ignored).toContain(sensitive); - expect(ignored.indexOf(sensitive)).toBeGreaterThan(lastSourceNegation); - } const dockerfile = readFileSync(repoPath("Dockerfile"), "utf8"); const runtime = dockerfile.split(" AS runtime")[1]; - const containerConfig = JSON.parse(readFileSync(repoPath("docker/config.json"), "utf8")); - expect(containerConfig).toMatchObject({ hostname: "0.0.0.0" }); - expect(containerConfig.tls).toBeUndefined(); expect(dockerfile).toContain("RUN --mount=type=bind,target=/build-context bun /tmp/verify-compatibility.ts /build-context"); expect(dockerfile.indexOf("RUN --mount=type=bind")).toBeLessThan(dockerfile.indexOf("COPY --chown=bun:bun src ./src")); expect(dockerfile).toContain("COPY --chown=bun:bun scripts/model-metadata.source.json ./scripts/model-metadata.source.json"); - expect(dockerfile).toContain("COPY --chown=bun:bun docker/bootstrap-tls.ts docker/bootstrap-token.ts docker/config.json docker/healthcheck.ts docker/verify-compatibility.ts ./docker/"); expect(runtime).toContain("COPY --from=build --chown=bun:bun /home/bun/app/scripts/model-metadata.source.json ./scripts/model-metadata.source.json"); expect(runtime).toContain("COPY --chown=bun:bun src/generated/compatibility-version.json ./src/generated/compatibility-version.json"); - expect(runtime).toContain('RUN ["bun", "docker/verify-compatibility.ts", "--runtime"]'); + expect(runtime).toContain('RUN ["bun", "docker/verify-compatibility.ts"]'); expect(runtime).toContain("readOpenCodexCompatibilityVersion() ?? ''"); expect(runtime).toContain("throw new Error('Missing or invalid generated compatibility manifest')"); - expect(runtime).toContain('RUN ["/usr/bin/openssl", "version"]'); - expect(runtime).toContain("bun run docker/bootstrap-tls.ts && exec bun run src/cli/index.ts start --port 10100"); - expect(runtime).toContain('CMD ["bun", "docker/healthcheck.ts"]'); }); }); const snapshotDirs: string[] = []; const manifestPath = "src/generated/compatibility-version.json"; -const snapshotPaths = [...REQUIRED_COMPATIBILITY_FILES, "src/main.ts"]; +const snapshotPaths = ["package.json", "bun.lock", "scripts/model-metadata.source.json", "src/main.ts"]; // Independent SHA-256 test vector for the bytes "abc", not computed by the verifier. const abcDigest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; @@ -286,6 +111,93 @@ afterEach(() => { for (const dir of snapshotDirs.splice(0)) removeTreeWithRetry(dir); }); +function catalogHomeFixture(codexDirectory = "codex-state") { + const root = mkdtempSync(join(tmpdir(), "ocx-container-catalog-")); + snapshotDirs.push(root); + const ocxHome = join(root, "ocx-state"); + const codexHome = join(root, codexDirectory); + mkdirSync(ocxHome, { mode: 0o700 }); + mkdirSync(codexHome, { mode: 0o700 }); + const ocxAuth = '{"fixture":"ocx-oauth-store"}'; + const codexAuth = '{"fixture":"native-codex-store"}'; + writeFileSync(join(ocxHome, "auth.json"), ocxAuth, { mode: 0o600 }); + writeFileSync(join(codexHome, "auth.json"), codexAuth, { mode: 0o600 }); + const moduleUrl = pathToFileURL(repoPath("src/server/catalog-download.ts")).href; + const script = ` + const { serializePersistedCatalog } = await import(${JSON.stringify(moduleUrl)}); + process.stdout.write(JSON.stringify(await serializePersistedCatalog())); + `; + const read = (): SerializedCatalog => { + // A fresh process keeps import-time home constants out of the parent test runner. + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoPath(), + env: { ...process.env, HOME: root, USERPROFILE: root, + OPENCODEX_HOME: ocxHome, CODEX_HOME: codexHome }, + encoding: "utf8", + timeout: 15000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(readFileSync(join(ocxHome, "auth.json"), "utf8")).toBe(ocxAuth); + expect(readFileSync(join(codexHome, "auth.json"), "utf8")).toBe(codexAuth); + return JSON.parse(result.stdout); + }; + return { root, ocxHome, codexHome, read }; +} + +function fixtureCatalog(slug: string) { + return { models: [{ slug, display_name: "Fixture", description: "fixture", priority: 1, + visibility: "list", base_instructions: "Fixture", input_modalities: ["text"] }] }; +} + +describe("container catalog home selection", () => { + test("reads only the Codex-home catalog across fresh processes without changing auth stores", () => { + const fixture = catalogHomeFixture(); + const catalog = fixtureCatalog("fixture/codex-home"); + expect(fixture.read().body).toBeNull(); + writeFileSync(join(fixture.ocxHome, "opencodex-catalog.json"), JSON.stringify(fixtureCatalog("fixture/ocx-home")), { mode: 0o600 }); + expect(fixture.read().body).toBeNull(); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(catalog), { mode: 0o600 }); + const serialized = fixture.read(); + expect(JSON.parse(serialized.body!)).toEqual(catalog); + expect(serialized.bytes).toBe(Buffer.byteLength(JSON.stringify(catalog), "utf8")); + expect(serialized.etag).toMatch(/^"[0-9a-f]{64}"$/); + // This proves a disk reread, not Docker volume initialization or container recreation. + expect(fixture.read()).toEqual(serialized); + }, 60000); + + test("uses a custom Codex home containing spaces", () => { + const fixture = catalogHomeFixture("custom codex state"); + const catalog = fixtureCatalog("fixture/custom-home"); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(catalog), { mode: 0o600 }); + expect(JSON.parse(fixture.read().body!)).toEqual(catalog); + }, 60000); + + for (const selection of ["relative", "absolute"] as const) { + test(`honors a ${selection} catalog override without falling back when it is absent`, () => { + const fixture = catalogHomeFixture(); + const selectedPath = selection === "relative" + ? join(fixture.codexHome, "catalogs", "custom.json") + : join(fixture.root, "external catalog.json"); + mkdirSync(dirname(selectedPath), { recursive: true, mode: 0o700 }); + const configuredPath = selection === "relative" ? "catalogs/custom.json" : selectedPath; + writeFileSync(join(fixture.codexHome, "config.toml"), `model_catalog_json = ${JSON.stringify(configuredPath)}\n`, { mode: 0o600 }); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(fixtureCatalog("fixture/default")), { mode: 0o600 }); + const catalog = fixtureCatalog(`fixture/${selection}`); + writeFileSync(selectedPath, JSON.stringify(catalog), { mode: 0o600 }); + expect(JSON.parse(fixture.read().body!)).toEqual(catalog); + unlinkSync(selectedPath); + expect(fixture.read().body).toBeNull(); + }, 60000); + } + + test("returns no catalog for malformed selected JSON without modifying auth stores", () => { + const fixture = catalogHomeFixture(); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), "not JSON", { mode: 0o600 }); + expect(fixture.read().body).toBeNull(); + }, 60000); +}); + function compatibilitySnapshot() { const root = mkdtempSync(join(tmpdir(), "ocx-container-identity-")); snapshotDirs.push(root); @@ -313,12 +225,6 @@ describe("container compatibility snapshot validation", () => { expect(() => verifyCompatibilitySnapshot(root)).not.toThrow(); }); - test("runtime verification omits build-only authority bytes after the context verified them", () => { - const { root } = compatibilitySnapshot(); - for (const path of [".dockerignore", "Dockerfile", "compose.yaml"]) unlinkSync(join(root, path)); - expect(() => verifyCompatibilitySnapshot(root, { runtime: true })).not.toThrow(); - }); - for (const path of snapshotPaths) { test(`rejects stale bytes in ${path}`, () => { const { root } = compatibilitySnapshot(); @@ -333,8 +239,8 @@ describe("container compatibility snapshot validation", () => { }); } - for (const path of REQUIRED_COMPATIBILITY_FILES) { - test(`requires the authority manifest entry: ${path}`, () => { + for (const path of snapshotPaths.slice(0, 3)) { + test(`requires the root manifest entry: ${path}`, () => { const { root, manifest, save } = compatibilitySnapshot(); manifest.files = manifest.files.filter(row => row.path !== path); save(); @@ -342,15 +248,11 @@ describe("container compatibility snapshot validation", () => { }); } - for (const [path, message] of [ - ["src/untracked.ts", "Source file absent from compatibility manifest"], - ["src/generated/untracked.json", "Source file absent from compatibility manifest"], - ["docker/extra.ts", "Container authority file absent from compatibility manifest"], - ] as const) { - test(`rejects an extra inventoried file: ${path}`, () => { + for (const path of ["src/untracked.ts", "src/generated/untracked.json"]) { + test(`rejects an extra source file: ${path}`, () => { const { root } = compatibilitySnapshot(); writeFileSync(join(root, path), "abc"); - expect(() => verifyCompatibilitySnapshot(root)).toThrow(message); + expect(() => verifyCompatibilitySnapshot(root)).toThrow("Source file absent from compatibility manifest"); }); } @@ -377,7 +279,7 @@ describe("container compatibility snapshot validation", () => { expect(() => verifyCompatibilitySnapshot(root)).toThrow("Duplicate compatibility manifest entry"); }); - for (const path of ["../outside", "/src/main.ts", "src/../package.json", "src//main.ts", "src/./main.ts", "src\\main.ts", "src/", "src/zero\0.ts", "scripts/unlisted.ts", manifestPath]) { + for (const path of ["../outside", "/src/main.ts", "src/../package.json", "src//main.ts", "src/./main.ts", "src\\main.ts", "src/", "src/zero\0.ts", "docker/bootstrap-token.ts", manifestPath]) { test(`rejects an unsafe or out-of-authority path: ${JSON.stringify(path)}`, () => { const { root, manifest, save } = compatibilitySnapshot(); manifest.files.push({ path, sha256: abcDigest }); @@ -416,7 +318,7 @@ describe("container compatibility snapshot validation", () => { }); } - for (const path of ["src", "src/generated", "docker", "scripts"]) { + for (const path of ["src", "src/generated", "scripts"]) { test(`rejects a linked input directory: ${path}`, () => { const { root } = compatibilitySnapshot(); const target = join(root, "linked-target"); diff --git a/tests/service/init-eof.test.ts b/tests/service/init-eof.test.ts index 2f2de54d5c..e098e5c56c 100644 --- a/tests/service/init-eof.test.ts +++ b/tests/service/init-eof.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { repoPath } from "../helpers/repo-root"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; async function waitForOutput( stream: ReadableStream, @@ -23,23 +24,70 @@ async function waitForOutput( } } +/** Continue reading after prompt inspection; Response rejects an already disturbed stream. */ +async function remainingOutput(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) return output + decoder.decode(); + output += decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } +} + describe("ocx init piped stdin (#754)", () => { const dirs: string[] = []; + const coordinators: string[] = []; + const makeHome = () => { + const home = mkdtempSync(join(tmpdir(), "ocx-init-eof-")); + dirs.push(home); + mkdirSync(join(home, "native"), { mode: 0o700 }); + return home; + }; + const launch = (home: string, command = "init", bootstrap?: string) => Bun.spawn({ + cmd: bootstrap ? [process.execPath, "--eval", bootstrap] : [process.execPath, repoPath("src", "cli", "index.ts"), command], + cwd: repoRoot(), + env: { + ...process.env, OPENCODEX_HOME: home, CODEX_HOME: join(home, "native"), + HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, "xdg"), + APPDATA: join(home, "appdata"), LOCALAPPDATA: join(home, "localappdata"), + }, + stdin: "pipe", stdout: "pipe", stderr: "pipe", + }); + const stop = async (proc: ReturnType) => { + if (proc.exitCode === null) proc.kill(); + await proc.exited.catch(() => {}); + }; + const reachPortPrompt = async (proc: ReturnType) => { + for (const [question, answer] of [ + ["Select default provider (number):", "999"], + ["Provider name:", "init-fixture"], + ["Base URL (e.g. http://localhost:11434/v1):", "https://example.test/v1"], + ["Adapter [openai-chat]:", ""], + ["API key (optional):", "fixture-init-key"], + ["Default model:", "fixture-model"], + ]) { + await waitForOutput(proc.stdout, question!); + proc.stdin.write(answer + "\n"); + await proc.stdin.flush(); + } + await waitForOutput(proc.stdout, "Proxy port [10100]:"); + }; afterEach(() => { + for (const path of coordinators.splice(0)) { + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(path + suffix, { force: true }); + } while (dirs.length) removeTreeWithRetry(dirs.pop()!); }); test("exits cleanly when stdin closes before the first prompt answer", async () => { - const home = mkdtempSync(join(tmpdir(), "ocx-init-eof-")); - dirs.push(home); - const cli = repoPath("src", "cli", "index.ts"); - const proc = Bun.spawn({ - cmd: [process.execPath, cli, "init"], - env: { ...process.env, OPENCODEX_HOME: home }, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }); + const home = makeHome(); + const proc = launch(home); const stderrPromise = new Response(proc.stderr).text(); try { // Synchronize on the behavior under test, not Windows process startup/import time. @@ -52,8 +100,205 @@ describe("ocx init piped stdin (#754)", () => { expect(stderr.toLowerCase()).toMatch(/stdin (closed|reached eof)/); expect(existsSync(join(home, "config.json"))).toBe(false); } finally { - if (proc.exitCode === null) proc.kill(); - await proc.exited.catch(() => {}); + await stop(proc); } }, 30_000); + + test.each(["init", "setup"])("%s preserves existing config before asking for input", async command => { + const home = makeHome(); + const bytes = '\uFEFF{ "port":21002, "providers":{}, "defaultProvider":"openai", "customNote":"keep" }\n'; + writeFileSync(join(home, "config.json"), bytes); + const proc = launch(home, command); + const stdout = remainingOutput(proc.stdout); + const stderr = new Response(proc.stderr).text(); + try { + expect(await proc.exited).toBe(0); + expect(await stdout).toContain("Keeping existing config"); + expect(await stderr).not.toContain("fixture-init-key"); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(bytes); + expect(readdirSync(home).filter(name => name.startsWith("config.json"))).toEqual(["config.json"]); + } finally { await stop(proc); } + }, 30_000); + + test.each(["", "broken config\n", '{"port":"invalid"}'])("invalid existing config is preserved: %j", async bytes => { + const home = makeHome(); + writeFileSync(join(home, "config.json"), bytes); + const proc = launch(home); + const stdout = remainingOutput(proc.stdout); + const stderr = new Response(proc.stderr).text(); + try { + expect(await proc.exited).toBe(1); + expect(await stdout).not.toContain("Select default provider"); + expect(await stderr).toContain("preserved"); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(bytes); + expect(readdirSync(home).filter(name => name.startsWith("config.json"))).toEqual(["config.json"]); + } finally { await stop(proc); } + }, 30_000); + + test("a creator during the wizard wins without backup cleanup or integration prompts", async () => { + const home = makeHome(); + const backup = join(home, "config.json.pre-openai-tiers-v2.bak"); + writeFileSync(backup, "keep even stale backup on refusal"); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + const winner = '{"port":21002,"providers":{},"defaultProvider":"openai","winner":true}\n'; + writeFileSync(join(home, "config.json"), winner, { flag: "wx" }); + proc.stdin.write("21001\n"); + await proc.stdin.flush(); + const stdout = remainingOutput(proc.stdout); + expect(await proc.exited).toBe(1); + expect(await stderr).toContain("keeping it"); + const rest = await stdout; + expect(rest).not.toMatch(/Inject into|autostart shim|Setup complete/); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(winner); + expect(readFileSync(backup, "utf8")).toBe("keep even stale backup on refusal"); + } finally { await stop(proc); } + }, 30_000); + + test("EOF at the final pre-publication prompt preserves backups and creates no config", async () => { + const home = makeHome(); + const backup = join(home, "config.json.pre-openai-tiers-v2.bak"); + writeFileSync(backup, "keep backup on cancellation"); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + proc.stdin.end(); + expect(await proc.exited).toBe(1); + expect(await stderr).toContain("stdin reached EOF"); + expect(existsSync(join(home, "config.json"))).toBe(false); + expect(readFileSync(backup, "utf8")).toBe("keep backup on cancellation"); + } finally { await stop(proc); } + }, 30_000); + + // Windows process.kill does not deliver a POSIX SIGINT to readline. + test.skipIf(process.platform === "win32")("SIGINT settles a pending prompt without creating config", async () => { + const home = makeHome(); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await waitForOutput(proc.stdout, "Select default provider (number):"); + proc.kill("SIGINT"); + expect(await proc.exited).toBe(130); + expect(await stderr).toContain("Setup cancelled"); + expect(existsSync(join(home, "config.json"))).toBe(false); + } finally { await stop(proc); } + }, 30_000); + + // This wraps only observation/error reporting around the REAL lock and injector. + // The holder releases on the signal event, after runInit consumes cancellation. + for (const wrapping of ["throw", "result"] as const) { + test.skipIf(process.platform === "win32")(`SIGINT while injection is queued preserves native bytes (${wrapping})`, async () => { + const home = makeHome(); + const nativeHome = join(home, "native"); + const nativeConfig = join(nativeHome, "config.toml"); + const sentinel = 'model = "gpt-5"\n# queued-init-sentinel\n'; + writeFileSync(nativeConfig, sentinel); + coordinators.push(resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), realpathSync.native(nativeHome))); + const bootstrap = ` + import { mock } from "bun:test"; + import { realpathSync } from "node:fs"; + const configApi = await import("./src/config.ts"); + const transition = await import("./src/codex/transition-state.ts"); + const identity = await import("./src/codex/user-identity.ts"); + configApi.withConfigMutationLockSync(() => {}); + if (transition.readCodexTransitionState().kind !== "ready") throw new Error("native coordinator setup failed"); + const path = identity.resolveCodexCoordinatorDatabasePath(identity.resolveEffectiveUserIdentity(), realpathSync.native(process.env.CODEX_HOME)); + const blocker = transition.openCodexCoordinatorTransaction(path); + const lockApi = { ...await import("./src/codex/codex-write-lock.ts") }; + mock.module("./src/codex/codex-write-lock.ts", () => ({ + ...lockApi, + withCodexWriteLock(options, commit) { + let entered = false; + const pending = lockApi.withCodexWriteLock(options, context => { + entered = true; + console.log("INIT_NATIVE_COMMIT_REACHED"); + return commit(context); + }); + // The real async lock runs synchronously up to its first busy retry. + if (entered) throw new Error("native holder was bypassed"); + console.log("INIT_NATIVE_LOCK_WAITING"); + return pending; + }, + })); + const injectApi = { ...await import("./src/codex/inject.ts") }; + mock.module("./src/codex/inject.ts", () => ({ + ...injectApi, + async injectCodexConfig(...args) { + try { return await injectApi.injectCodexConfig(...args); } + catch { + if (${JSON.stringify(wrapping)} === "throw") throw new Error("WRAPPED_INJECTION_RESULT"); + return { success: false, message: "WRAPPED_INJECTION_RESULT" }; + } + }, + })); + process.once("SIGINT", () => queueMicrotask(() => { + blocker.rollback(); blocker.close(); + console.log("INIT_NATIVE_HOLDER_RELEASED"); + })); + process.argv = [process.execPath, "init-fixture", "init"]; + await import("./src/cli/index.ts"); + `; + const proc = launch(home, "init", bootstrap); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + proc.stdin.write("21001\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "Inject into Codex config.toml? [Y/n]:"); + const created = readFileSync(join(home, "config.json"), "utf8"); + proc.stdin.write("y\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "INIT_NATIVE_LOCK_WAITING"); + expect(readFileSync(nativeConfig, "utf8")).toBe(sentinel); + proc.kill("SIGINT"); + const stdout = remainingOutput(proc.stdout); + expect(await proc.exited).toBe(130); + const rest = await stdout; + expect(rest).toContain("INIT_NATIVE_HOLDER_RELEASED"); + expect(rest).toContain("INIT_NATIVE_COMMIT_REACHED"); + expect(rest).not.toMatch(/WRAPPED_INJECTION_RESULT|Install Codex autostart shim|Setup complete|✅/); + expect(await stderr).toContain("Setup cancelled. The created config has been kept."); + expect(readFileSync(nativeConfig, "utf8")).toBe(sentinel); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(created); + expect(existsSync(join(nativeHome, "opencodex.config.toml"))).toBe(false); + expect(existsSync(join(nativeHome, "opencodex-journal.json"))).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { await stop(proc); } + }, 30_000); + } + + test.each([false, true])("successful creation survives later cancellation=%s", async cancel => { + const home = makeHome(); + const proc = launch(home); + const stderr = new Response(proc.stderr).text(); + try { + await reachPortPrompt(proc); + proc.stdin.write("21001\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "Inject into Codex config.toml? [Y/n]:"); + const created = readFileSync(join(home, "config.json"), "utf8"); + if (cancel) proc.stdin.end(); + else { + proc.stdin.write("n\n"); + await proc.stdin.flush(); + await waitForOutput(proc.stdout, "Install Codex autostart shim? [Y/n]:"); + proc.stdin.write("n\n"); + await proc.stdin.flush(); + } + const stdout = remainingOutput(proc.stdout); + expect(await proc.exited).toBe(cancel ? 1 : 0); + const rest = await stdout; + if (cancel) { + expect(await stderr).toContain("created config has been kept"); + expect(rest).not.toContain("Setup complete"); + } else expect(rest).toContain("Setup complete"); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(created); + expect(JSON.parse(created)).toMatchObject({ port: 21001, defaultProvider: "init-fixture" }); + expect(existsSync(join(home, "native", "config.toml"))).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + } finally { await stop(proc); } + }, 30_000); }); diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 785f7f5709..5ec2308e93 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -551,6 +551,7 @@ describe("Windows service task", () => { expect(xml).toContain("false"); expect(xml).toContain("false"); expect(xml).toContain("PT0S"); + expect(xml).toContain("4"); expect(xml).toContain(""); expect(xml).toContain("PT1M"); expect(xml).toContain("3"); @@ -2667,6 +2668,37 @@ describe("service repair", () => { expect(calls).toEqual(["env", "auth", "stop", "assets", "reregister", "start", "state"]); }); + test.each(["7", "omitted", "4", "1"])("repair migrates only the background scheduler priority (%s)", async priority => { + const calls: string[] = []; + const previousXml = buildWindowsTaskXml().replace(/\d<\/Priority>/, + priority === "omitted" ? "" : `${priority}`); + const shouldUpgrade = priority === "7" || priority === "omitted"; + let attemptNonce = ""; + await repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + resolveExpectedUserId: () => TEST_WINDOWS_TASK_SID, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => attemptNonce + ? buildWindowsTaskXml(undefined, undefined, attemptNonce) + : previousXml, + reregisterScheduler: async (nonce, registeredXml) => { + expect(registeredXml).toBe(previousXml); + expect(buildWindowsTaskXmlDocument()).toContain("4"); + calls.push("reregister"); + attemptNonce = nonce; + }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + }); + expect(calls).toEqual(shouldUpgrade + ? ["stop", "assets", "reregister", "start", "state"] + : ["stop", "assets", "start", "state"]); + }); + test("repair migrates an exact legacy account name to the preferred SID", async () => { const calls: string[] = []; const sid = "S-1-5-21-111-222-333-1001"; diff --git a/tests/storage/storage-cleanup.test.ts b/tests/storage/storage-cleanup.test.ts index 421950368c..31cbcd6d21 100644 --- a/tests/storage/storage-cleanup.test.ts +++ b/tests/storage/storage-cleanup.test.ts @@ -8,6 +8,7 @@ import { readFileSync, renameSync, rmSync, + statSync, unlinkSync, utimesSync, writeFileSync, @@ -20,6 +21,7 @@ import { listArchivedCandidates, listTrashEntries, normalizeArchivedRolloutPath, + pickWireCleanupTestHooks, previewArchivedCleanup, previewExactArchivedCleanup, restoreTrashEntry, @@ -648,6 +650,127 @@ describe("executeArchivedCleanup", () => { expect(ids).toContain("told"); }); + test("initial manifest publication failure preserves originals and removes its private temp", () => { + home = buildHome(); + const observed: Array<{ priorExists: boolean; next: string; mode: number }> = []; + const result = runWithDigest(50, "quarantine", home, { + now: 881, + _test: { + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase !== "staging") return; + observed.push({ + priorExists: existsSync(targetPath), + next: readFileSync(temporaryPath, "utf8"), + mode: statSync(temporaryPath).mode & 0o777, + }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + // Assert outside the production catch: an assertion inside the hook could be swallowed. + expect(observed).toHaveLength(1); + expect(observed[0]!.priorExists).toBe(false); + expect(JSON.parse(observed[0]!.next).staging).toBe(true); + if (process.platform !== "win32") expect(observed[0]!.mode).toBe(0o600); + expect(result.error).toBe("fs_failed"); + expect(existsSync(join(home, ".trash", "881"))).toBe(false); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(db.query("SELECT id FROM threads WHERE id = 'told'").get()).toBeTruthy(); + db.close(); + expect(pickWireCleanupTestHooks({ + beforeManifestReplace: () => {}, failManifestWrite: true, + })).toEqual({ failManifestWrite: true }); + }, STORE_BUDGET_MS); + + test("failed pre-delete replacement leaves the prior manifest intact during publication and restorable", () => { + home = buildHome(); + let stagingBytes = ""; + const observed: Array<{ prior: string; next: string }> = []; + const result = runWithDigest(50, "quarantine", home, { + now: 882, + _test: { + failRollbackBasenames: ["rollout-old.jsonl"], + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase === "staging") stagingBytes = readFileSync(temporaryPath, "utf8"); + if (phase !== "pre-commit") return; + observed.push({ prior: readFileSync(targetPath, "utf8"), next: readFileSync(temporaryPath, "utf8") }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]!.prior).toBe(stagingBytes); + expect(JSON.parse(observed[0]!.prior).staging).toBe(true); + expect(JSON.parse(observed[0]!.next).staging).toBeUndefined(); + expect(result.error).toBe("fs_failed"); + expect(result.trashDir).toBe(".trash/882"); + const stage = join(home, ".trash", "882"); + expect(readFileSync(join(stage, "manifest.json"), "utf8")).toBe(stagingBytes); + expect(readFileSync(join(stage, "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + expect(readdirSync(stage).filter(name => name.endsWith(".tmp"))).toEqual([]); + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(db.query("SELECT id FROM threads WHERE id = 'told'").get()).toBeTruthy(); + db.close(); + const restored = restoreTrashEntry(".trash/882", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(restored.count).toBe(1); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + }, STORE_BUDGET_MS); + + test.each([false, true])("failed post-purge manifest replacement preserves prior bytes (partial=%s)", partial => { + home = buildHome(); + let preCommitBytes = ""; + const observed: Array<{ prior: string; next: string }> = []; + const result = runWithDigest(100, "permanent", home, { + now: 883, + _test: { + failPurgeBasenames: partial + ? ["rollout-mid.jsonl"] + : ["rollout-old.jsonl", "rollout-mid.jsonl", "rollout-new.jsonl"], + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase === "pre-commit") preCommitBytes = readFileSync(temporaryPath, "utf8"); + if (phase !== "purge-incomplete") return; + observed.push({ prior: readFileSync(targetPath, "utf8"), next: readFileSync(temporaryPath, "utf8") }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]!.prior).toBe(preCommitBytes); + expect(JSON.parse(observed[0]!.next).purgeIncomplete).toBe(true); + expect(JSON.parse(observed[0]!.next).entries).toHaveLength(partial ? 1 : 3); + expect(result.error).toBe("fs_failed"); + const stage = join(home, ".trash", "883"); + expect(readFileSync(join(stage, "manifest.json"), "utf8")).toBe(preCommitBytes); + expect(readdirSync(stage).filter(name => name.endsWith(".tmp"))).toEqual([]); + const dbBefore = new Database(join(home, "state_5.sqlite"), { readonly: true }); + const rowsBefore = dbBefore.query("SELECT id FROM threads ORDER BY id").all(); + dbBefore.close(); + expect(rowsBefore).toEqual([{ id: "active" }]); + const stageBefore = readdirSync(stage).sort(); + const restored = restoreTrashEntry(".trash/883", { codexHome: home }); + if (partial) { + // A wholly purged old entry still fails closed; valid JSON is not full recovery. + expect(restored.error).toBe("fs_failed"); + expect(restored.restoredPaths).toEqual([]); + expect(readdirSync(stage).sort()).toEqual(stageBefore); + expect(readFileSync(join(stage, "rollout-mid.jsonl"), "utf8")).toBe("MID".repeat(20)); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + const dbAfter = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(dbAfter.query("SELECT id FROM threads ORDER BY id").all()).toEqual(rowsBefore); + dbAfter.close(); + } else { + expect(restored.ok).toBe(true); + expect(restored.count).toBe(3); + const dbAfter = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(dbAfter.query("SELECT id FROM threads ORDER BY id").all()).toEqual([ + { id: "active" }, { id: "tmid" }, { id: "tnew" }, { id: "told" }, + ]); + dbAfter.close(); + } + }, { timeout: STORE_BUDGET_MS }); + test("rename-back failure keeps staged file and reports relative trashDir", () => { home = buildHome(); const db = new Database(join(home, "state_5.sqlite")); diff --git a/tests/usage/quota-reset-observation.test.ts b/tests/usage/quota-reset-observation.test.ts index 3ce3d04251..39a18025ef 100644 --- a/tests/usage/quota-reset-observation.test.ts +++ b/tests/usage/quota-reset-observation.test.ts @@ -3,12 +3,9 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { - applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, flushQuotaObservationsForTests, - parseUsageQuota, setAccountQuotaFromParsed, } from "../../src/codex/quota"; import type { QuotaResetEvent } from "../../src/quota/reset-detector"; @@ -25,58 +22,36 @@ import { stopQuotaResetPoller, } from "../../src/quota/reset-poller"; import { resetQuotaResetNotifyCacheForTests } from "../../src/quota/reset-notify-config"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; const ACCOUNT = "acct_reset_observation"; const HOUR = 60 * 60_000; let captured: QuotaResetEvent[] = []; -let previousOpenCodexHome: string | undefined; -const scratchHomes = new Set(); -function makeScratchHome(prefix: string): string { - const home = mkdtempSync(join(tmpdir(), prefix)); - scratchHomes.add(home); - return home; -} - -/** Let the seams' lazy import() chains settle. */ +/** Join the writer's ordered observation/forget chain, including cold imports. */ async function settle(): Promise { - for (let index = 0; index < 6; index += 1) await Promise.resolve(); - await new Promise(resolve => setTimeout(resolve, 5)); + await flushQuotaObservationsForTests(); } -beforeEach(() => { - previousOpenCodexHome = process.env["OPENCODEX_HOME"]; - process.env["OPENCODEX_HOME"] = makeScratchHome("ocx-quota-reset-observation-"); +beforeEach(async () => { + await settle(); captured = []; resetQuotaResetStoreForTests(); resetQuotaResetNotifyCacheForTests(); resetQuotaResetPollerForTests(); clearAccountQuota(); + await settle(); setQuotaResetSink(event => { captured.push(event); }); }); afterEach(async () => { + await settle(); setQuotaResetSink(null); resetQuotaResetPollerForTests(); clearAccountQuota(); - try { - await flushQuotaObservationsForTests(); - } finally { - resetQuotaResetStoreForTests(); - resetQuotaResetNotifyCacheForTests(); - try { - await flushConfigDirHardeningForTests(); - } finally { - if (previousOpenCodexHome === undefined) delete process.env["OPENCODEX_HOME"]; - else process.env["OPENCODEX_HOME"] = previousOpenCodexHome; - for (const home of scratchHomes) removeTreeWithRetry(home); - scratchHomes.clear(); - } - } + await settle(); }); describe("codex quota seam", () => { @@ -112,43 +87,6 @@ describe("codex quota seam", () => { expect(captured[0]?.window).toBe("5h"); }); - test.each(["wham", "headers"] as const)("%s epoch-second resets do not turn rolling decay into a scheduled reset", async producer => { - let now = 1_700_000_000_000; - const clock = spyOn(Date, "now").mockImplementation(() => now); - const publish = (percent: number, resetAt: number) => { - if (producer === "headers") { - applyAccountQuotaFromUpstreamHeaders(ACCOUNT, new Headers({ - "x-codex-primary-used-percent": String(percent), - "x-codex-primary-reset-at": String(resetAt), - "x-codex-primary-window-minutes": "300", - })); - return; - } - setAccountQuotaFromParsed(ACCOUNT, parseUsageQuota({ - rate_limit: { - primary_window: { used_percent: percent, reset_at: resetAt, limit_window_seconds: 18_000 }, - }, - additional_rate_limits: [{ - metered_feature: "codex_bengalfox", - rate_limit: { - primary_window: { used_percent: percent, reset_at: resetAt, limit_window_seconds: 604_800 }, - }, - }], - })); - }; - try { - publish(96, 1_700_018_000); - await flushQuotaObservationsForTests(); - captured = []; - now += 60_000; - publish(90, 1_700_018_060); - await flushQuotaObservationsForTests(); - expect(captured).toEqual([]); - } finally { - clock.mockRestore(); - } - }); - test("a credits-only write fires nothing despite a fresh updatedAt", async () => { setAccountQuotaFromParsed(ACCOUNT, { weeklyPercent: 40, weeklyResetAt: Date.now() + 3 * 24 * HOUR }); await settle(); @@ -303,7 +241,7 @@ describe("idle poller", () => { } = await import("../../src/quota/reset-poller"); // A real enabled config is what carries a tick past its early returns; the resolver reads // the config file rather than exposing an injection seam. - const home = makeScratchHome("ocx-poller-"); + const home = mkdtempSync(join(tmpdir(), "ocx-poller-")); writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10100, defaultProvider: "openai", @@ -358,7 +296,7 @@ describe("observation ordering under a burst", () => { const proc = Bun.spawn([process.execPath, child], { // A private OPENCODEX_HOME: the baseline is persisted, so a shared home would let one // run seed the next and turn this into a test of leftover state. - env: { ...process.env, OPENCODEX_HOME: makeScratchHome("ocx-burst-") }, + env: { ...process.env, OPENCODEX_HOME: mkdtempSync(join(tmpdir(), "ocx-burst-")) }, stdout: "pipe", stderr: "pipe", }); diff --git a/tests/usage/request-log.test.ts b/tests/usage/request-log.test.ts index 0d1b5c7592..33fbd13b1f 100644 --- a/tests/usage/request-log.test.ts +++ b/tests/usage/request-log.test.ts @@ -22,6 +22,9 @@ import { recordFirstOutput, requestLogEntryFromPersistedUsage, sealRequestAttemptIdentity, + recordAttemptCredentialSource, + inspectResponseLogSsePayload, + httpStatusForRequestLogTerminal, type RequestLogContext, } from "../../src/server/request-log"; import { handleResponses } from "../../src/server/responses"; @@ -37,6 +40,7 @@ import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { decodeRequestLogCursor, selectRequestLogPoll } from "../../src/server/request-log-cursor"; async function* replayAdapterEvents(events: AdapterEvent[]): AsyncGenerator { for (const event of events) yield event; @@ -56,32 +60,161 @@ function log(overrides: Partial): RequestLogEntry { } describe("request log metadata", () => { - test("projects only bounded Routed V2 bridge metadata", () => { - const base = { - requestId: "ocx-v2-bridge", - timestamp: 1, - provider: "openai", - model: "gpt-test", - status: 200, - durationMs: 1, - usageStatus: "unreported" as const, - }; - expect(requestLogEntryFromPersistedUsage({ - ...base, - v2BridgeScope: "child", - v2BridgeDecision: "active", - v2BridgeStateDurability: "encrypted", - })).toMatchObject({ - v2BridgeScope: "child", - v2BridgeDecision: "active", - v2BridgeStateDurability: "encrypted", - }); - expect(requestLogEntryFromPersistedUsage({ - ...base, - v2BridgeScope: "task text", - v2BridgeDecision: "ciphertext", - v2BridgeStateDurability: "key", - } as unknown as PersistedUsageEntry)).not.toHaveProperty("v2BridgeDecision"); + test("Claude evidence is normalized before direct ring ingress and cannot be mutated afterwards", () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-claude-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + try { + const raw = JSON.parse('{"decision":"shadow","featureCodes":["documents","unknown_beta","private-header"],"reason":"private-reason"}'); + addRequestLog(log({ claudeCompatibility: raw })); + raw.featureCodes.length = 0; + raw.reason = "changed"; + const expected = { decision: "shadow", featureCodes: ["documents", "unknown_beta"], reason: "shadow: would reject: documents" }; + expect(getRequestLogEntries()[0]?.claudeCompatibility).toEqual(expected); + expect(readUsageEntries()[0]?.claudeCompatibility).toEqual(expected); + const finalized: RequestLogEntry[] = []; + addFinalRequestLog("claude-final", 1, { model: "test", provider: "mock", + claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents"],"reason":"private-final"}') }, + 200, { closeReason: "non_stream" }, row => finalized.push(row)); + expect(finalized).toHaveLength(1); + expect(finalized[0].claudeCompatibility).toEqual({ + decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents", + }); + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + + test("custom hydration normalizes Claude evidence and ignores malformed optional rows", () => { + clearRequestLogsForTests(); + const raw: PersistedUsageEntry[] = [ + { ...log({ requestId: "legacy" }) }, + { ...log({ requestId: "shadow" }), claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents","private-header"],"reason":"private-reason"}') }, + { ...log({ requestId: "malformed" }), claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":null,"reason":"private-reason"}') }, + ]; + try { + expect(hydrateRequestLogsFromDisk(() => raw)).toBe(3); + expect(getRequestLogEntries().map(row => row.claudeCompatibility)).toEqual([ + undefined, { decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents" }, undefined, + ]); + raw[1].claudeCompatibility!.featureCodes.length = 0; + expect(getRequestLogEntries()[1]?.claudeCompatibility?.featureCodes).toEqual(["documents"]); + expect(hydrateRequestLogsFromDisk(() => raw)).toBe(0); + expect(JSON.stringify(getRequestLogEntries())).not.toContain("private-"); + } finally { clearRequestLogsForTests(); } + }); + test("incomplete quota evidence preserves an explicit HTTP 402 message", () => { + const log: RequestLogContext = { model: "gpt-test", provider: "openai" }; + inspectResponseLogSsePayload(log, JSON.stringify({ + type: "response.incomplete", + response: { incomplete_details: { message: "402" } }, + })); + expect(log.terminalHttpStatus).toBe(402); + expect(httpStatusForRequestLogTerminal("incomplete", log)).toBe(402); + }); + + test("normal structured incomplete reason wins over quota-like display text", () => { + const log: RequestLogContext = { model: "gpt-test", provider: "openai" }; + inspectResponseLogSsePayload(log, JSON.stringify({ + type: "response.incomplete", + response: { incomplete_details: { reason: "max_output_tokens", message: "Token usage limit reached" } }, + })); + expect(log.terminalHttpStatus).toBeUndefined(); + expect(log.terminalIncompleteReason).toBe("max_output_tokens"); + }); + + for (const error of [ + { type: "authentication_error", message: "Usage limit lookup requires renewed authentication" }, + { code: "invalid_api_key", message: "Usage limit unavailable for this credential" }, + ]) { + test(`structured auth failure wins over quota wording: ${JSON.stringify(error)}`, () => { + const failed: RequestLogContext = { model: "gpt-test", provider: "openai" }; + inspectResponseLogSsePayload(failed, JSON.stringify({ type: "response.failed", response: { error } })); + expect(failed.terminalHttpStatus).toBe(401); + const incomplete: RequestLogContext = { model: "gpt-test", provider: "openai" }; + inspectResponseLogSsePayload(incomplete, JSON.stringify({ type: "response.incomplete", response: { error } })); + expect(incomplete.terminalHttpStatus).toBeUndefined(); + }); + } + + test("upstream credential attribution requires the resolved canonical xAI transport", () => { + const attempt = beginRequestAttempt(1, "xai", "grok-test", "openai-chat"); + const oauth = { adapter: "openai-chat", authMode: "oauth" as const, baseUrl: "https://cli-chat-proxy.grok.com/v1" }; + recordAttemptCredentialSource(attempt, "xai", oauth); + expect(attempt.credentialSource).toBe("grok-oauth"); + for (const baseUrl of ["https://api.x.ai/v1", "https://proxy.example/v1", "http://cli-chat-proxy.grok.com/v1", + "https://cli-chat-proxy.grok.com:8443/v1", Object.assign(new URL(oauth.baseUrl), { username: "test" }).href, + "https://cli-chat-proxy.grok.com/v1?credential=canary", "https://cli-chat-proxy.grok.com/v2", "invalid"]) { + recordAttemptCredentialSource(attempt, "xai", { ...oauth, baseUrl }); + expect(attempt.credentialSource).toBeUndefined(); + } + recordAttemptCredentialSource(attempt, "xai", oauth); + recordAttemptCredentialSource(attempt, "custom", oauth); + expect(attempt.credentialSource).toBeUndefined(); + recordAttemptCredentialSource(attempt, "xai", { ...oauth, authMode: "key", baseUrl: "https://api.x.ai/v1" }); + expect(attempt.credentialSource).toBe("xai-api-key"); + recordAttemptCredentialSource(attempt, "xai", { ...oauth, authMode: "key" }); + expect(attempt.credentialSource).toBeUndefined(); + }); + + test("seal same identity preserves credentialSource; provider or adapter change clears it", () => { + const attempt = beginRequestAttempt(1, "xai", "grok-test", "openai-chat"); + recordAttemptCredentialSource(attempt, "xai", { + adapter: "openai-chat", authMode: "key", baseUrl: "https://api.x.ai/v1", + }); + expect(attempt.credentialSource).toBe("xai-api-key"); + sealRequestAttemptIdentity(attempt, "xai", "openai-chat"); + expect(attempt.credentialSource).toBe("xai-api-key"); + expect(attempt.provider).toBe("xai"); + expect(attempt.adapter).toBe("openai-chat"); + + sealRequestAttemptIdentity(attempt, "custom", "openai-chat"); + expect(attempt.credentialSource).toBeUndefined(); + expect(attempt.provider).toBe("custom"); + + recordAttemptCredentialSource(attempt, "xai", { + adapter: "openai-chat", authMode: "key", baseUrl: "https://api.x.ai/v1", + }); + attempt.provider = "xai"; + expect(attempt.credentialSource).toBe("xai-api-key"); + sealRequestAttemptIdentity(attempt, "xai", "openai-responses"); + expect(attempt.credentialSource).toBeUndefined(); + expect(attempt.adapter).toBe("openai-responses"); + }); + + test("recordAttemptCredentialSource fourth adapterName rejects unsupported even when config adapter is openai-chat", () => { + const attempt = beginRequestAttempt(1, "xai", "grok-test", "openai-chat"); + recordAttemptCredentialSource(attempt, "xai", { + adapter: "openai-chat", authMode: "key", baseUrl: "https://api.x.ai/v1", + }, "anthropic"); + expect(attempt.credentialSource).toBeUndefined(); + }); + + test("combo logging keeps credential provenance on physical attempts only", () => { + const a = beginRequestAttempt(1, "xai", "grok-test", "openai-chat"); + const b = beginRequestAttempt(2, "openai", "gpt-test", "openai-responses"); + recordAttemptCredentialSource(a, "xai", { + adapter: "openai-chat", authMode: "oauth", baseUrl: "https://cli-chat-proxy.grok.com/v1", + }); + noteAttemptSend(a, undefined); + finishRequestAttempt(a, 503, 1, { inputTokens: 4, outputTokens: 1 }); + noteAttemptSend(b, undefined); + const entries: RequestLogEntry[] = []; + addFinalRequestLog("mixed-combo", Date.now(), { + provider: "openai", model: "gpt-test", requestedModel: "combo/test", comboId: "test", + providerAdapter: "openai-responses", attempts: [a, b], activeAttempt: b, + usage: { inputTokens: 10, outputTokens: 2 }, + }, 200, undefined, entry => entries.push(entry)); + expect(entries[0]?.totalTokens).toBe(17); + expect(entries[0]?.attempts?.[0]?.credentialSource).toBe("grok-oauth"); + expect(entries[0]?.attempts?.[0]?.totalTokens).toBe(5); + expect(entries[0]?.attempts?.[1]?.credentialSource).toBeUndefined(); + expect(entries[0]).not.toHaveProperty("credentialSource"); }); test("creates one ordinary attempt after the final adapter is resolved", async () => { @@ -759,15 +892,13 @@ describe("request log metadata", () => { * the positive case for free. */ test("filters logs by model, including the attempt that actually served a failover", () => { - const now = Date.now(); const logs = [ - log({ requestId: "a", model: "gpt-test", resolvedModel: "gpt-test-20260829", provider: "openai", timestamp: now - 3_000 }), - log({ requestId: "b", model: "grok-4.6", provider: "xai", timestamp: now - 2_000 }), + log({ requestId: "a", model: "gpt-test", provider: "openai" }), + log({ requestId: "b", model: "grok-4.6", provider: "xai" }), log({ requestId: "c", model: "sonnet-4.6", provider: "anthropic", - timestamp: now - 1_000, attempts: [ { ordinal: 1, provider: "anthropic", model: "sonnet-4.6", adapter: "anthropic", status: 429, durationMs: 5, sendCount: 1, recoveryKinds: [], usageStatus: "unreported" }, { ordinal: 2, provider: "xai", model: "grok-4.6", adapter: "openai", status: 200, durationMs: 7, sendCount: 1, recoveryKinds: [], usageStatus: "reported" }, @@ -776,15 +907,12 @@ describe("request log metadata", () => { ]; expect(filterRequestLogs(logs, new URLSearchParams("model=gpt-test")).map(entry => entry.requestId)).toEqual(["a"]); - expect(filterRequestLogs(logs, new URLSearchParams("model=gpt-test-20260829")).map(entry => entry.requestId)).toEqual(["a"]); // "c" matches on its second ATTEMPT, mirroring how `provider` already behaves: the request // was ultimately served by grok-4.6, so a grok-4.6 search has to find it. expect(filterRequestLogs(logs, new URLSearchParams("model=grok-4.6")).map(entry => entry.requestId)).toEqual(["b", "c"]); // The assertion an unfiltered implementation cannot pass. expect(filterRequestLogs(logs, new URLSearchParams("model=absent-model"))).toEqual([]); expect(filterRequestLogs(logs, new URLSearchParams("model=grok-4.6&provider=xai")).map(entry => entry.requestId)).toEqual(["b", "c"]); - expect(filterRequestLogs(logs, new URLSearchParams(`since=${now - 2_500}`)).map(entry => entry.requestId)).toEqual(["b", "c"]); - expect(filterRequestLogs(logs, new URLSearchParams(`until=${now - 1_500}`)).map(entry => entry.requestId)).toEqual(["a", "b"]); }); test("filters logs by offset and limit", () => { @@ -1765,3 +1893,97 @@ describe("request log restart hydrate", () => { } }); }); + + +describe("request log snapshot cursor", () => { + const epoch = "a".repeat(32); + const query = new URLSearchParams("limit=2000"); + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + + test("codec bounds and canonical encoding reject malformed or type-confused input", () => { + const poll = selectRequestLogPoll([], query, null, epoch); + const payload = JSON.parse(Buffer.from(poll.cursor, "base64url").toString()); + expect(decodeRequestLogCursor(poll.cursor)).toEqual(payload); + for (const raw of ["", "!", "a".repeat(513), `${poll.cursor}=`, ` ${poll.cursor}`, + encode(null), encode([]), encode({ ...payload, v: 3 }), encode({ ...payload, n: -1 }), + encode({ ...payload, n: 2001 }), encode({ ...payload, n: 0.5 }), encode({ ...payload, n: "0" }), + encode({ ...payload, h: "x".repeat(64) }), encode({ ...payload, q: null }), + encode({ ...payload, e: "short" }), encode({ ...payload, extra: true }), + encode({ v: 1, t: -1, id: "row" }), encode({ v: 1, t: 1, id: "" }), + encode({ v: 1, t: "1", id: "row" }), encode({ v: 1, t: 1, id: "x".repeat(257) })]) { + expect(decodeRequestLogCursor(raw)).toBeNull(); + } + const legacy = decodeRequestLogCursor(encode({ v: 1, t: 1, id: "row" })); + expect(legacy).toEqual({ v: 1, t: 1, id: "row" }); + expect(selectRequestLogPoll([], query, legacy, epoch).reset).toBe(true); + }); + + test("stable empty and populated snapshots produce empty deltas, appends preserve repeated IDs", () => { + const empty = selectRequestLogPoll([], query, null, epoch); + expect(empty.reset).toBe(false); + expect(selectRequestLogPoll([], query, decodeRequestLogCursor(empty.cursor), epoch)).toEqual(empty); + const rows = [log({ requestId: "same" })]; + const first = selectRequestLogPoll(rows, query, decodeRequestLogCursor(empty.cursor), epoch); + expect(first.logs).toEqual(rows); + const cursor = decodeRequestLogCursor(first.cursor); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toEqual({ ...first, logs: [] }); + rows.push(log({ requestId: "same", status: 500 })); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toMatchObject({ logs: [rows[1]], reset: false }); + }); + + test("in-place older/newest/nested changes, field removal, reorder and eviction reset the whole window", () => { + const original = [ + log({ requestId: "older", usage: { inputTokens: 10, outputTokens: 5 }, firstOutputMs: 3 }), + log({ requestId: "newest" }), + ]; + const cursor = decodeRequestLogCursor(selectRequestLogPoll(original, query, null, epoch).cursor); + const mutations: Array<(rows: RequestLogEntry[]) => void> = [ + rows => { rows[0]!.status = 500; }, + rows => { rows[1]!.durationMs = 22; }, + rows => { rows[0]!.usage!.outputTokens = 6; }, + rows => { delete rows[0]!.firstOutputMs; }, + rows => { rows[0] = log({ requestId: "replacement" }); }, + rows => { rows.reverse(); }, + rows => { rows.shift(); }, + rows => { rows.length = 0; }, + ]; + for (const mutate of mutations) { + const rows = structuredClone(original); + mutate(rows); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toMatchObject({ logs: rows, reset: true }); + } + // Same hydrated IDs and values do not make an old process cursor valid. + expect(selectRequestLogPoll(original, query, cursor, "b".repeat(32))) + .toMatchObject({ logs: original, reset: true }); + }); + + test("query identity ignores cursor and parameter ordering but binds filters and pagination", () => { + const rows = [log({ requestId: "private-row", conversationId: "private-conversation" })]; + const first = selectRequestLogPoll(rows, new URLSearchParams("provider=private-provider&limit=1"), null, epoch); + const cursor = decodeRequestLogCursor(first.cursor); + const raw = Buffer.from(first.cursor, "base64url").toString(); + for (const value of ["private-row", "private-conversation", "private-provider"]) expect(raw).not.toContain(value); + expect(selectRequestLogPoll(rows, new URLSearchParams(`limit=1&cursor=${first.cursor}&provider=private-provider`), cursor, epoch).logs) + .toEqual([]); + for (const changed of ["provider=other&limit=1", "provider=private-provider&limit=2", "provider=private-provider&limit=1&offset=1"]) { + expect(selectRequestLogPoll(rows, new URLSearchParams(changed), cursor, epoch).reset).toBe(true); + } + const duplicated = selectRequestLogPoll(rows, new URLSearchParams("provider=a&provider=b"), null, epoch); + expect(selectRequestLogPoll(rows, new URLSearchParams("provider=b&provider=a"), decodeRequestLogCursor(duplicated.cursor), epoch).reset) + .toBe(true); + }); + + test("a full-window rollover resets; a stale fingerprint cannot suppress current rows", () => { + const rows = Array.from({ length: 2000 }, (_, index) => log({ requestId: `row-${index}`, timestamp: 2000 - index })); + const initial = selectRequestLogPoll(rows, query, null, epoch); + const cursor = decodeRequestLogCursor(initial.cursor); + expect(cursor).toMatchObject({ v: 2, n: 2000 }); + rows.shift(); + rows.push(log({ requestId: "new", timestamp: 0 })); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toMatchObject({ logs: rows, reset: true }); + const payload = JSON.parse(Buffer.from(initial.cursor, "base64url").toString()); + const stale = decodeRequestLogCursor(encode({ ...payload, h: "0".repeat(64) })); + expect(stale).not.toBeNull(); + expect(selectRequestLogPoll(rows, query, stale, epoch)).toMatchObject({ logs: rows, reset: true }); + }); +}); diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index c547003808..f39c778d2b 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -7,12 +7,13 @@ import { appendUsageEntry, currentUsageLogRevision, normalizeUsageEntryForTest, + normalizeClaudeCompatibilityUsageLog, + normalizePersistedUsageRow, readRecentUsageEntries, readUsageEntries, readUsageEntriesForManagement, readUsageSnapshotForManagement, resetUsageReadCacheForTests, - setManagementUsageReadOpenedSizeForTests, usageForFinalLog, usageLogPath, usageStatusForFinalLog, @@ -40,51 +41,65 @@ afterEach(() => { }); describe("usage log", () => { - test("normalizes Routed V2 bridge diagnostics as closed enums", () => { - const base = { - requestId: "ocx-v2-bridge", - timestamp: 1, - provider: "openai", - model: "gpt-test", - status: 200, - durationMs: 1, - usageStatus: "unreported" as const, - }; - expect(normalizeUsageEntryForTest({ - ...base, - v2BridgeScope: "root", - v2BridgeDecision: "no_collaboration_catalog", - v2BridgeStateDurability: "memory-only", - })).toMatchObject({ - v2BridgeScope: "root", - v2BridgeDecision: "no_collaboration_catalog", - v2BridgeStateDurability: "memory-only", + test("Claude shadow metadata round trips as closed codes and a regenerated reason", () => { + const evidence = normalizeClaudeCompatibilityUsageLog({ + decision: "shadow", featureCodes: ["unknown_beta", "documents", "documents", "private-header", "__proto__"], + reason: "private-reason", extra: "private-payload", }); - const invalid = normalizeUsageEntryForTest({ - ...base, - v2BridgeScope: "secret", - v2BridgeDecision: "secret", - v2BridgeStateDurability: "secret", - } as unknown as PersistedUsageEntry); - expect(invalid).not.toHaveProperty("v2BridgeScope"); - expect(invalid).not.toHaveProperty("v2BridgeDecision"); - expect(invalid).not.toHaveProperty("v2BridgeStateDurability"); + const expected = { decision: "shadow", featureCodes: ["documents", "unknown_beta"], reason: "shadow: would reject: documents" }; + expect(evidence).toEqual(expected); + appendUsageEntry({ requestId: "claude-shadow", timestamp: 1, provider: "mock", model: "test-model", + status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 3, outputTokens: 2 }, + claudeCompatibility: evidence }); + // Later mutation of the caller's evidence cannot rewrite the serialized record. + evidence!.featureCodes.length = 0; + resetUsageReadCacheForTests(); + expect(readUsageEntries()[0]?.claudeCompatibility).toEqual(expected); + expect(readRecentUsageEntries(1)[0]?.claudeCompatibility).toEqual(expected); + expect(readUsageEntries()[0]?.usage).toMatchObject({ inputTokens: 3, outputTokens: 2 }); + expect(readFileSync(usageLogPath(), "utf8")).not.toContain("private-"); + }); + + test("legacy and malformed persisted Claude metadata does not poison readers", () => { + const base = { requestId: "claude-legacy", timestamp: 1, provider: "mock", model: "test-model", + status: 200, durationMs: 1, usageStatus: "unreported" }; + const invalid: unknown[] = [undefined, null, [], "private-value", 1, + { decision: "reject", featureCodes: ["documents"] }, + { decision: "shadow", featureCodes: "documents" }, + { decision: "shadow", featureCodes: [null, {}, "constructor", "private-header"] }, + { decision: "shadow", featureCodes: ["cache_control"], reason: "private-reason" }, + ]; + for (const claudeCompatibility of invalid) { + const row = normalizePersistedUsageRow({ ...base, claudeCompatibility }); + expect(row).toBeDefined(); + expect(row?.claudeCompatibility).toBeUndefined(); + } + writeFileSync(usageLogPath(), invalid.map(claudeCompatibility => JSON.stringify({ ...base, claudeCompatibility })).join("\n") + "\n"); + resetUsageReadCacheForTests(); + expect(readUsageEntries()).toHaveLength(invalid.length); + expect(readUsageEntries().every(row => row.claudeCompatibility === undefined)).toBe(true); }); - test("round-trips agentKind and drops invalid historical values", () => { - const base = { - requestId: "ocx-agent-kind", - timestamp: 1, - provider: "openai", - model: "gpt-test", - status: 200, - durationMs: 1, - usageStatus: "reported" as const, + test("round trips only recognized per-attempt xAI credential sources", () => { + const attempt = { + ordinal: 1, provider: "xai", model: "grok-test", adapter: "openai-chat", status: 200, + durationMs: 1, sendCount: 1, recoveryKinds: [], usageStatus: "reported" as const, + usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 }, totalTokens: 5, }; - expect(normalizeUsageEntryForTest({ ...base, agentKind: "subagent" })).toMatchObject({ agentKind: "subagent" }); - expect(normalizeUsageEntryForTest({ ...base, agentKind: "corrupt" } as unknown as PersistedUsageEntry)).not.toHaveProperty("agentKind"); - appendUsageEntry({ ...base, agentKind: "internal" }); - expect(readUsageEntries()[0]).toMatchObject({ agentKind: "internal" }); + appendUsageEntry({ + requestId: "credential-source", timestamp: Date.now(), provider: "combo", model: "combo/test", + status: 200, durationMs: 1, usageStatus: "reported", attempts: [ + { ...attempt, credentialSource: "grok-oauth" }, + { ...attempt, ordinal: 2, credentialSource: "xai-api-key" }, + { ...attempt, ordinal: 3, credentialSource: "secret-canary" as never }, + { ...attempt, ordinal: 4, provider: "custom", credentialSource: "grok-oauth" }, + { ...attempt, ordinal: 5 }, + ], + }); + resetUsageReadCacheForTests(); + const sources = readUsageEntries()[0]?.attempts?.map(row => row.credentialSource); + expect(sources).toEqual(["grok-oauth", "xai-api-key", undefined, undefined, undefined]); + expect(readFileSync(usageLogPath(), "utf8")).not.toContain("secret-canary"); }); test("preserves explicitly empty attempts through normalization", () => { @@ -162,32 +177,6 @@ describe("usage log", () => { expect(readUsageEntries()[0]?.attempts?.[0]?.recoveryKinds).toEqual(["rate-limit-429"]); }); - test("persists the Cursor duplicate-tool recovery kind on attempts", () => { - const entry: PersistedUsageEntry = { - requestId: "ocx-cursor-duplicate-tool-kind", - timestamp: 1, - provider: "cursor", - model: "cursor/grok-4.6", - status: 200, - durationMs: 4, - usageStatus: "reported", - attempts: [{ - ordinal: 1, - provider: "cursor", - model: "cursor/grok-4.6", - adapter: "cursor", - status: 200, - durationMs: 4, - sendCount: 2, - recoveryKinds: ["cursor-duplicate-tool-call"], - usageStatus: "reported", - }], - }; - appendUsageEntry(entry); - expect(readUsageEntries()[0]?.attempts?.[0]?.recoveryKinds) - .toEqual(["cursor-duplicate-tool-call"]); - }); - test("persists the key-401 recovery kind on attempts", () => { // ATTEMPT_RECOVERY_KINDS is the deserialization filter: a kind added to the type but not to // the set writes fine and vanishes on read-back, so this must round-trip through the file @@ -361,24 +350,18 @@ describe("usage log", () => { } }); - test("a shrunk ledger does not join its previous in-flight read", async () => { - // Exercise the production same-identity shrink branch without truncating while - // the cooperative reader owns a Windows handle. Bun can deadlock that artificial - // same-file mutation before the cache-replacement behavior is ever reached. + test("a replacement does not join an in-flight read for the previous file revision", async () => { writeFileSync( usageLogPath(), `${Array.from({ length: 2_100 }, (_, index) => persistedLine(`old-${index}`)).join("\n")}\n`, ); const oldRead = readUsageSnapshotForManagement(); await new Promise(resolve => setTimeout(resolve, 0)); - const unchangedSize = statSync(usageLogPath()).size; - setManagementUsageReadOpenedSizeForTests(unchangedSize + 1); + writeFileSync(usageLogPath(), `${persistedLine("replacement")}\n`); const newRead = readUsageSnapshotForManagement(); - await expect(oldRead).rejects.toThrow("management usage read superseded"); const newSnapshot = await newRead; - expect(newSnapshot.entries).toHaveLength(2_100); - expect(newSnapshot.entries.at(-1)?.requestId).toBe("old-2099"); + expect(newSnapshot.entries.map(entry => entry.requestId)).toEqual(["replacement"]); }); test("persists conversationId for Logs session correlation", () => { diff --git a/tests/web-search/web-search-timeout-contract.test.ts b/tests/web-search/web-search-timeout-contract.test.ts index 5a7f9dbceb..a2340b78a1 100644 --- a/tests/web-search/web-search-timeout-contract.test.ts +++ b/tests/web-search/web-search-timeout-contract.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as abortModule from "../../src/lib/abort"; import type { AdapterFetchContext, ProviderAdapter } from "../../src/adapters/base"; import { parseRequest } from "../../src/responses/parser"; import { responseWithDeferredRequestLog, type RequestLogEntry } from "../../src/server"; @@ -20,8 +21,11 @@ function runWithWebSearch( } const originalFetch = globalThis.fetch; +let cleanupDeadlineFixture: (() => void) | undefined; afterEach(() => { + cleanupDeadlineFixture?.(); + cleanupDeadlineFixture = undefined; globalThis.fetch = originalFetch; }); @@ -363,6 +367,47 @@ describe("web-search timeout runtime contracts", () => { let firstSignal: AbortSignal | undefined; let cancelCalls = 0; let rotations = 0; + let rotatedFetches = 0; + let deadlineCreations = 0; + let deadlineClears = 0; + let cancelSettled = false; + let releaseCancel!: () => void; + const cancelGate = new Promise(resolve => { releaseCancel = resolve; }) + .then(() => { cancelSettled = true; }); + const events: string[] = []; + const deadlineController = new AbortController(); + const timeoutReason = new DOMException("Timeout elapsed", "TimeoutError"); + let expiryTimer: ReturnType | undefined; + let deadlineCleared = false; + const originalDeadline = abortModule.clearableDeadline; + const deadlineSpy = spyOn(abortModule, "clearableDeadline").mockImplementation((timeoutMs, parent) => { + if (timeoutMs !== connectTimeoutMs) return originalDeadline(timeoutMs, parent); + deadlineCreations++; + const signal = parent ? AbortSignal.any([parent, deadlineController.signal]) : deadlineController.signal; + return { + signal, + timeoutReason, + didExpire: () => signal.aborted && signal.reason === timeoutReason, + clear: () => { + deadlineClears++; + deadlineCleared = true; + events.push("deadline-cleared"); + if (expiryTimer !== undefined) clearTimeout(expiryTimer); + expiryTimer = undefined; + }, + }; + }); + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + if (expiryTimer !== undefined) clearTimeout(expiryTimer); + expiryTimer = undefined; + releaseCancel(); + deadlineController.abort(timeoutReason); + deadlineSpy.mockRestore(); + }; + cleanupDeadlineFixture = cleanup; const firstAdapter: ProviderAdapter = { name: "rate-limited-never-cancelled", buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), @@ -371,7 +416,15 @@ describe("web-search timeout runtime contracts", () => { return new Response(new ReadableStream({ cancel() { cancelCalls++; - return new Promise(() => {}); + events.push("cancel-requested"); + // Expire on the next timer task, after immediate rotation microtasks. + // An added timer wait or an awaited cancel cannot get a fresh budget. + if (!deadlineCleared) expiryTimer = setTimeout(() => { + expiryTimer = undefined; + events.push("deadline-expired"); + deadlineController.abort(timeoutReason); + }, 0); + return cancelGate; }, }), { status: 429 }); }, @@ -382,33 +435,48 @@ describe("web-search timeout runtime contracts", () => { name: "rotated-header-hang", buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), fetchResponse: (_request, ctx) => { + rotatedFetches++; + expect(firstSignal).toBeDefined(); expect(ctx?.abortSignal).toBe(firstSignal); + expect(ctx?.abortSignal?.aborted).toBe(false); + expect(cancelCalls).toBe(1); + expect(cancelSettled).toBe(false); + events.push("rotated-fetch"); return hangingFetch(ctx); }, async *parseStream() { yield { type: "done" }; }, async parseResponse() { return [{ type: "done" }]; }, }; - const started = performance.now(); - const response = await runWithWebSearch(deps(firstAdapter, { - connectTimeoutMs, - on429: () => { - rotations++; - return rotatedAdapter; - }, - })); - - expect(performance.now() - started).toBeLessThan(500); - expect(cancelCalls).toBe(1); - expect(rotations).toBe(1); - expect(response.status).toBe(504); - expect(await response.json()).toEqual({ - error: { - message: `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`, - type: "upstream_error", - code: null, - }, - }); + try { + const response = await runWithWebSearch(deps(firstAdapter, { + connectTimeoutMs, + on429: () => { + rotations++; + return rotatedAdapter; + }, + })); + + expect(cancelCalls).toBe(1); + expect(cancelSettled).toBe(false); + expect(rotations).toBe(1); + expect(rotatedFetches).toBe(1); + expect(deadlineCreations).toBe(1); + expect(deadlineClears).toBe(1); + expect(firstSignal?.reason).toBe(timeoutReason); + expect(events).toEqual(["cancel-requested", "rotated-fetch", "deadline-expired", "deadline-cleared"]); + expect(response.status).toBe(504); + expect(await response.json()).toEqual({ + error: { + message: `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`, + type: "upstream_error", + code: null, + }, + }); + } finally { + cleanup(); + if (cleanupDeadlineFixture === cleanup) cleanupDeadlineFixture = undefined; + } }, 1_000); test("validates a rotated adapter before the second web-search build", async () => { diff --git a/tests/windows/windows-secret-acl.test.ts b/tests/windows/windows-secret-acl.test.ts index dddbd38084..aa011bb516 100644 --- a/tests/windows/windows-secret-acl.test.ts +++ b/tests/windows/windows-secret-acl.test.ts @@ -376,6 +376,42 @@ describe("opt-in existing ACL proof", () => { expect(calls).toEqual([[target]]); }); + test("async compliance inspection can precede a memo refusal or an existing compliant success", async () => { + const target = join(testDir, "memo-compliance.json"); + writeFileSync(target, "secret"); + let clock = 0; + setNowForTests(() => clock); + setAsyncWindowsPrincipalRunnerForTests(async () => success(`${ownerSid}\n${ownerName}\n`)); + seedIdentity(); + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + setAsyncIcaclsRunnerForTests(async () => { + clock += 100; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + try { + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100 })) + .rejects.toMatchObject({ code: "ETIMEDOUT" }); + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100, retryTimedOutOnce: true })) + .rejects.toMatchObject({ code: "ETIMEDOUT" }); + process.env.OPENCODEX_ACL_VERIFY_EXISTING = "1"; + const calls: string[][] = []; + let compliant = false; + setAsyncIcaclsRunnerForTests(async args => { + calls.push(args); + return success(compliant ? `${target} ${ownerName}:(F)\r\n` : "unverified"); + }); + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100 })) + .rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED", aclFailureOrigin: "timeout_memo_refusal" }); + expect(calls).toEqual([[target]]); // Inspection ran, but no grant was launched. + compliant = true; + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100 })).resolves.toEqual({ ok: true }); + expect(calls).toEqual([[target], [target]]); + expect(timedOutSecretPathCountForTests()).toBe(1); // Existing proof did not clear the memo. + } finally { + setNowForTests(null); + } + }); + test("an inherited owner ACE falls through to the mutation sequence", () => { const target = join(testDir, "inherited.json"); writeFileSync(target, "secret"); @@ -1014,7 +1050,7 @@ describe("async hardenSecretPath (issue #612)", () => { expect(timedOutSecretPathCountForTests()).toBe(0); }); - test("the explicit timeout recovery cannot be consumed more than once", async () => { + test.each(["sync", "async"] as const)("%s timeout origin distinguishes memo refusal without another recovery", async lane => { // Pinned: this asserts recovery CARDINALITY. At the 30s default the first call would // succeed on its internal retry and the cardinality claim would never be exercised. process.env.OPENCODEX_ACL_TIMEOUT_MS = "5000"; @@ -1022,27 +1058,43 @@ describe("async hardenSecretPath (issue #612)", () => { let now = 0; let grantCalls = 0; setNowForTests(() => now); - setAsyncIcaclsRunnerForTests(async args => { + const runner = (args: string[]): IcaclsResult => { if (args.includes("/grant:r")) grantCalls += 1; now += 5_000; return timeout; - }); + }; + setIcaclsRunnerForTests(runner); + setAsyncIcaclsRunnerForTests(async args => runner(args)); + const identity = { ...ok, stdout: "S-1-5-21-1-2-3-1001\nocx-test\n" }; + setWindowsPrincipalRunnerForTests(() => identity); + setAsyncWindowsPrincipalRunnerForTests(async () => identity); + const harden = async (retryTimedOutOnce = false) => lane === "sync" + ? hardenSecretPath(target, { required: true, retryTimedOutOnce }) + : hardenSecretPathAsync(target, { required: true, retryTimedOutOnce }); - await expect(hardenSecretPathAsync(target, { required: true })).rejects.toMatchObject({ - code: "ETIMEDOUT", - }); - await expect(hardenSecretPathAsync(target, { - required: true, - retryTimedOutOnce: true, - })).rejects.toMatchObject({ code: "ETIMEDOUT" }); - const callsAfterRecovery = grantCalls; - await expect(hardenSecretPathAsync(target, { - required: true, - retryTimedOutOnce: true, - })).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); - expect(grantCalls).toBe(callsAfterRecovery); - expect(grantCalls).toBe(2); - expect(timedOutSecretPathCountForTests()).toBe(1); + try { + const first = await harden().catch(error => error); + expect(first).toMatchObject({ code: "ETIMEDOUT" }); + expect(first).not.toHaveProperty("aclFailureOrigin"); + await expect(harden()).rejects.toMatchObject({ + code: "ETIMEDOUT", aclFailureOrigin: "timeout_memo_refusal", + }); + expect(grantCalls).toBe(1); + const recovery = await harden(true).catch(error => error); + expect(recovery).toMatchObject({ code: "ETIMEDOUT" }); + expect(recovery).not.toHaveProperty("aclFailureOrigin"); + const callsAfterRecovery = grantCalls; + await expect(harden(true)).rejects.toMatchObject({ + code: "EACLRETRYEXHAUSTED", aclFailureOrigin: "timeout_memo_refusal", + }); + expect(grantCalls).toBe(callsAfterRecovery); + expect(grantCalls).toBe(2); + expect(timedOutSecretPathCountForTests()).toBe(1); + } finally { + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } }); test("optional timeout memo does not poison a later required harden of the same path", () => {