diff --git a/.bun-version b/.bun-version index 7962dcfdb6..1c99cf0e80 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.3.13 +1.4.4 diff --git a/.env.example b/.env.example index e369f556df..69ddd0dcb9 100644 --- a/.env.example +++ b/.env.example @@ -495,6 +495,10 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # Useful for users who want full transparency over what the model sees # OPENCLAUDE_DISABLE_TOOL_REMINDERS=1 +# Configure todo/task reminder thresholds (default: 10 turns each) +# OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE=15 +# OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS=20 + # Log structured per-request token usage (including cache metrics) to stderr. # Useful for auditing cache hit rate / debugging cost spikes outside the REPL. # Any truthy value enables it ("verbose", "1", "true"). diff --git a/.github/codeql/README.md b/.github/codeql/README.md new file mode 100644 index 0000000000..839124da75 --- /dev/null +++ b/.github/codeql/README.md @@ -0,0 +1,102 @@ +# CodeQL analysis and merge protection + +The [CodeQL workflow](../workflows/codeql.yml) scans pull requests to `main`, +including forks, pushes to `main`, and a weekly schedule. It also supports manual +runs once the workflow exists on the default branch. JavaScript/TypeScript uses +the default security queries plus `code-quality`; GitHub Actions retains its +default security queries. Results are uploaded to **code scanning**. + +GitHub's CodeQL default setup [excludes fork pull requests](https://docs.github.com/en/code-security/concepts/code-scanning/setup-types#about-default-setup). +The separate managed Code Quality workflow has also failed to run on fork PRs in +this repository. Requiring its results leaves those PRs pending indefinitely. +Custom workflows cannot provide a supported replacement for the managed Code +Quality result: the [CodeQL action](https://github.com/github/codeql-action/blob/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee/src/analyses.ts) +directs custom workflows to run quality queries through code scanning instead. + +## Permissions and activation + +Write access permits contributing this workflow. Changing CodeQL default setup +requires repository administration or the relevant security-management access; +editing the ruleset requires administration or a custom role with permission to +edit repository rules. Merging this file does not change either setting. + +An administrator must complete this rollout for `Twigpine/openclaude`: + +1. In **Settings → Advanced Security → CodeQL analysis**, disable default setup + and use advanced setup with this workflow. Default setup otherwise rejects + uploads from custom CodeQL workflows. The equivalent API change is: + + ```sh + gh api --method PATCH repos/Twigpine/openclaude/code-scanning/default-setup \ + -f state=not-configured + ``` + +2. Rerun the new **CodeQL** workflow on the introducing PR after changing the + setup. Verify that both `CodeQL (javascript-typescript)` and `CodeQL (actions)` + complete successfully, including their uploads. A successful analysis with + a failed upload is not sufficient. The `pull_request` trigger supports fork + uploads with the restricted token; no PAT, fork secret, `pull_request_target`, + or privileged artifact-upload workflow is needed. + +3. In **Settings → Rules → Rulesets → main branch protection** (ruleset + `14659193`), remove **Require code quality results** and add both CodeQL job + names as required status checks, selecting **GitHub Actions** as their source. + Keep every other rule, branch target, enforcement setting, bypass actor, and + existing required check. This temporary stage lets the introducing PR merge + with completed scans while the new configuration acquires a `main` baseline. + Continue the normal PR approval and CI process before merging. + +4. After merge, verify the new workflow's push run on `main` uploaded both + language analyses. Then enable **Require code scanning results**, select + **CodeQL**, and set **Alerts: Errors** and **Security alerts: None**. Retain the + two required status checks. The security threshold preserves the previous + ruleset's policy: it required quality errors but had no security-severity + merge threshold. Security findings remain visible for review. + +5. Verify a fork PR has both successful jobs and a completed CodeQL code-scanning + evaluation. Older PRs need a new push or a rerun that includes this workflow; + rerunning a historical workflow definition that lacks it cannot start it. + +The separate managed Code Quality feature may remain enabled for its dashboard. +Its **Require code quality results** rule must stay removed for this migration. +Do not disable the entire branch ruleset. + +## Prepared rule additions + +These are rule fragments for the existing ruleset, not a complete replacement +payload. Remove its `code_quality` rule, add the following `code_scanning` rule +after the baseline upload, and merge these checks into any existing +`required_status_checks` rule. Preserve all unrelated fields and rules from a +fresh administrator-readable export before saving an API update. + +```json +[ + { + "type": "code_scanning", + "parameters": { + "code_scanning_tools": [ + { + "tool": "CodeQL", + "alerts_threshold": "errors", + "security_alerts_threshold": "none" + } + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "CodeQL (javascript-typescript)", "integration_id": 15368 }, + { "context": "CodeQL (actions)", "integration_id": 15368 } + ] + } + } +] +``` + +GitHub's [rules API](https://docs.github.com/en/rest/repos/rules#update-a-repository-ruleset) +documents these fields. Code-scanning merge protection needs results for both the +PR and its target branch, which is why activation is staged around the initial +`main` upload. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..b7c7316a96 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + schedule: + - cron: "23 6 * * 2" + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + queries: +code-quality + - language: actions + queries: "" + + steps: + # Keep the default PR merge checkout and pull_request event: code scanning + # supports uploads from fork PRs without a privileged fork-code workflow. + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: ${{ matrix.queries }} + + - name: Analyze and upload results + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/install-hygiene.yml b/.github/workflows/install-hygiene.yml index 3d90185024..451262e674 100644 --- a/.github/workflows/install-hygiene.yml +++ b/.github/workflows/install-hygiene.yml @@ -13,6 +13,12 @@ name: Install hygiene on: + pull_request: + paths: + - 'package.json' + - 'bun.lock' + - 'src/utils/imageResizer.native.test.ts' + - '.github/workflows/install-hygiene.yml' schedule: - cron: '17 6 * * *' # daily, off the top-of-hour rush workflow_dispatch: @@ -20,6 +26,12 @@ on: permissions: contents: read +# Supersede stale runs for the same pull request. Scheduled and manual runs +# get a per-run group and are never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: verify-published-install: name: ${{ matrix.os }} / Node ${{ matrix.node-version }} @@ -28,15 +40,15 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - node-version: [22, 24] + node-version: ["24.22.1", "26"] steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} @@ -48,5 +60,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Verify native image processing + run: bun test src/utils/imageResizer.native.test.ts + - name: Verify published package installs clean run: bun run scripts/verify-clean-install.ts --published diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 4215c7896f..ba72513f9b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -10,71 +10,261 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + web: ${{ steps.filter.outputs.web }} + steps: + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + if: ${{ github.event_name == 'pull_request' }} + with: + filters: | + code: + - '.github/**' + - '!{*.md,**/*.md,docs/**}' + web: + - 'web/**' + - 'package.json' + - 'bun.lock' + - '.bun-version' + - '.github/workflows/pr-checks.yml' + smoke-and-tests: + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.code == 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 - strategy: - matrix: - node-version: ["22", "24.11.x"] steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: ${{ matrix.node-version }} + node-version-file: .node-version - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-root-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-root- + - name: Install dependencies run: bun install --frozen-lockfile - - name: Smoke and full unit test suite - run: bun run check + - name: Smoke and static checks + run: bun run check:static - - name: Launcher compatibility - run: | - node bin/openclaude --version - NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version + pr-intent-scan: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-root-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-root- + + - name: Install dependencies + run: bun install --frozen-lockfile - name: Suspicious PR intent scan env: PR_SCAN_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || 'origin/main' }} PR_SCAN_HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'HEAD' }} run: bun run security:pr-scan -- --base "$PR_SCAN_BASE" --head "$PR_SCAN_HEAD" - - name: Provider tests - run: bun run test:provider - - name: Provider recommendation tests - run: npm run test:provider-recommendation + unit-tests: + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.code == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8] + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-root-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-root- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Unit test shard + run: bun run scripts/run-tests.ts --shards=8 --shard-index=${{ matrix.shard }} --feature=UNATTENDED_RETRY --max-concurrency=1 + + dedicated-tests: + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.code == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-root-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-root- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Conversation arc tests + run: bun run test:conversation-arc + + - name: Context efficiency tests + run: bun run test:context-efficiency + + launcher-compatibility: + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.code == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + matrix: + node-version: ["24.22.1", "26"] + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ matrix.node-version }} + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-root-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-root- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Launcher compatibility + run: | + node bin/openclaude --version + NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version launcher-node-floor: + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.code == 'true' }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up minimum supported Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: "22.0.0" + node-version: "24.22.1" - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-root-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-root- + - name: Install dependencies run: bun install --frozen-lockfile @@ -85,12 +275,14 @@ jobs: run: node bin/openclaude --version typecheck: + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.code == 'true' }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -99,33 +291,53 @@ jobs: with: bun-version-file: .bun-version + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-root-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-root- + - name: Install dependencies run: bun install --frozen-lockfile - - name: Typecheck - run: bun run typecheck + - name: Typecheck (Bun builtin) + run: bun run typecheck:fast - name: Type tests run: bun run typecheck:type-tests web: + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.web == 'true' }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 22 + node-version-file: .node-version - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version + - name: Cache Bun packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-web-${{ hashFiles('web/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-web- + - name: Install web dependencies run: bun install --cwd web --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 195116dffe..edc6e0c3a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,8 +30,8 @@ jobs: release-type: node # Zero-warning install gate: pack the release tag and install it globally in - # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 → - # npm 10) and current (24 → npm 11), whose warning output differs. Publish + # a sandbox (cold + upgrade scenarios) on the supported Node floor (24.22.1) + # and current (26), both shipping npm 11. Publish # is blocked unless the real `npm install -g` experience is clean. install-verify: name: Verify clean npm install (Node ${{ matrix.node-version }}) @@ -43,16 +43,16 @@ jobs: strategy: fail-fast: false matrix: - node-version: [22, 24] + node-version: ["24.22.1", "26"] steps: - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.release-please.outputs.tag_name }} persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} @@ -81,15 +81,16 @@ jobs: id-token: write steps: - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.release-please.outputs.tag_name }} fetch-depth: 0 + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version-file: .node-version registry-url: https://registry.npmjs.org - name: Set up Bun @@ -110,7 +111,7 @@ jobs: run: bun run smoke # Final install-hygiene gate on the publishing machine itself (the - # install-verify job covered Node 22/24 already); packs with + # install-verify job covered Node 24.22.1/26 already); packs with # --ignore-scripts to reuse the dist/ built above. Supersedes the old # `npm pack --dry-run` (contents are asserted inside the verify). - name: Verify zero-warning global install @@ -195,15 +196,16 @@ jobs: packages: write steps: - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -211,7 +213,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository }} tags: | @@ -220,7 +222,7 @@ jobs: type=raw,value=latest - name: Build and load locally - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . load: true @@ -231,7 +233,7 @@ jobs: run: docker run --rm openclaude:smoke --version - name: Build and push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . push: true diff --git a/.github/workflows/vscode-launcher.yml b/.github/workflows/vscode-launcher.yml new file mode 100644 index 0000000000..c2bed9f2b7 --- /dev/null +++ b/.github/workflows/vscode-launcher.yml @@ -0,0 +1,29 @@ +name: VS Code Windows launcher + +on: + pull_request: + paths: + - 'vscode-extension/openclaude-vscode/**' + - '.github/workflows/vscode-launcher.yml' + push: + branches: [main] + paths: + - 'vscode-extension/openclaude-vscode/**' + - '.github/workflows/vscode-launcher.yml' + +permissions: + contents: read + +jobs: + windows-launcher: + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + - name: Exercise Windows executable and npm batch launcher routes + run: node --test vscode-extension/openclaude-vscode/src/chat/processManager.test.js diff --git a/.node-version b/.node-version index 2bd5a0a98a..6f4247a625 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22 +26 diff --git a/.nvmrc b/.nvmrc index 2bd5a0a98a..6f4247a625 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +26 diff --git a/AGENTS.md b/AGENTS.md index bcdd5aaded..3828c5c774 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This guide is for AI coding agents working in the OpenClaude repository. Read it OpenClaude is a coding-agent CLI for cloud and local model providers. It supports OpenAI-compatible APIs, Anthropic, Gemini, DeepSeek, Ollama, MCP, local backends, slash commands, tools, agents, and a React/Ink terminal UI. -The installed CLI runs on Node.js `>=22.0.0`. Bun is used for source builds, scripts, dependency management, and tests. +The installed CLI runs on Node.js `>=24.22.1` (Node 26 recommended/default). Bun is used for source builds, scripts, dependency management, and tests. ## Work Style @@ -57,10 +57,12 @@ bun install bun run build bun run smoke bun run check -bun run typecheck +bun run typecheck:fast bun run typecheck:type-tests ``` +`bun run typecheck:fast` runs Bun's builtin type checker locally and is the CI typecheck; use the wrapper because the repository's `check` script shadows `bun check`. `bun run typecheck:type-tests` also runs in CI for focused TypeScript assertions. `bun run typecheck` (tsc) is available as an additional local check. Keep the `typescript` devDependency for ESLint, `@astrojs/check`, and the focused type tests. + Focused checks: ```bash diff --git a/ANDROID_INSTALL.md b/ANDROID_INSTALL.md index 2e77fb8cea..f658688016 100644 --- a/ANDROID_INSTALL.md +++ b/ANDROID_INSTALL.md @@ -36,7 +36,7 @@ pkg install nodejs-lts git proot-distro Verify Node.js: ```bash -node --version # should be v20+ +node --version # requires v24.22.1 or newer; Node 26 recommended/default ``` ### Step 3 — Clone OpenClaude @@ -60,9 +60,12 @@ This downloads ~200–400MB. Wait for it to complete. ```bash proot-distro login ubuntu -curl -fsSL https://bun.sh/install | bash +bun_installer="$(mktemp)" +curl -fsSL https://bun.sh/install -o "$bun_installer" +bash "$bun_installer" bun-v1.4.4 +rm -f "$bun_installer" source ~/.bashrc -bun --version # should show 1.3.13+ +bun --version # should show 1.4.4 ``` ### Step 6 — Build OpenClaude diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 815dc4c8f6..eabcdee2b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,13 @@ Dependency changes need a clear project benefit — fixing a bug, addressing a s - Avoid mixing unrelated cleanup into the same change. - Preserve existing repo patterns unless the change is intentionally refactoring them. - Add or update tests when the change affects behavior. +- For vendor/integration catalog tests, cover each vendor with one table-driven test of its registry contract (route resolution, default base URL and model, credential env vars, catalog-to-descriptor linkage, and capability/limit values the runtime uses), plus a small number of behavioral tests such as model-discovery keep/drop parsing or availability windows. Do not copy full curated model lists or cache/refresh settings from vendor definitions into tests. +- Test files share a process within each run or shard; restore `mock.module` registrations, environment variables, globals, and process state in `afterEach`/`afterAll`. +- Git discovery honors `GIT_CEILING_DIRECTORIES` in both filesystem lookups and Git subprocesses. Use an explicit ceiling for temporary fixtures that must exclude enclosing repositories; a ceiling prevents ascent into that directory without excluding the starting working directory. Separate multiple absolute ceiling directories with the platform's path-list delimiter. +- When tests set or delete environment variables, import `snapshotEnv` from `src/test/envSnapshot.ts` using the appropriate relative `.js` path. In `beforeEach`, save the named variables before changing them with `restoreEnv = snapshotEnv(keys)`; pass `{ clear: true }` when setup needs those variables unset. Call `restoreEnv()` in `afterEach`, or use `finally` for changes scoped to one test. Restoration preserves previous values, including empty strings, and deletes variables that were previously unset. List only the variables the test changes or deliberately clears; do not replace `process.env` or blanket-clear unrelated variables. When using the shared mutation lock, snapshot after acquiring it and restore before releasing it. +- In timing-sensitive tests, drive timers with Bun's fake timers (`jest.useFakeTimers()` and `jest.advanceTimersByTime()`) instead of racing real delays, and restore real timers during cleanup, so the tests pass in a single-process whole-suite run as well as in the sharded runner. +- Await completion promises or explicit readiness signals before asserting asynchronous results. For tests that deliberately suppress readiness, use a short, bounded wait and assert the expected failure. +- For tests that launch real subprocesses, set an explicit per-test timeout longer than the child-process deadline, leaving time for assertions and cleanup. The launcher heap tests use an 8-second child deadline and a 10-second test timeout. Document the reason for the timeout beside the test. - Update docs when setup, commands, or user-facing behavior changes. - Website release notes live on GitHub Releases. Do not add manually maintained release-note data to the static site. @@ -174,6 +181,156 @@ Before submitting, run multiple rounds of review on generated code: Self-review up front saves everyone time and reduces back-and-forth during maintainer review. +### Markdown parsing and rendering + +Use the exact `marked` version in `package.json` and its bundled TypeScript types; +import `marked`, `Lexer`, `Token`, or `Tokens` from the package's ESM entry point. +Do not add `@types/marked` or a separate rendering extension. Use +`configureMarked()` before terminal lexing and `formatToken()` / `applyMarkdown()` +for terminal output; strikethrough parsing is deliberately disabled. + +List items can contain `paragraph` children and `checkbox` tokens. Preserve list +markers for paragraph children and omit checkbox glyphs in terminal output. Pass +the previous sibling to `formatToken()` when rendering block sequences so separate +`space` tokens do not duplicate spacing after headings, tables, or indented code. +Indented code has trailing newlines removed for terminal display and `/copy`; +fenced code retains its content. Keep streaming boundary calculations on the +complete lexer token stream, including whitespace and each token's `raw` length. + +CLAUDE.md parsing uses its own `new Lexer({ gfm: false })` to preserve include +paths. When removing a comment-only HTML block, consume its following whitespace +token too; preserve whitespace with visible comment residue and leave code blocks +untouched. When updating `marked`, compare representative terminal, table, list, +code, link, streaming, and memory-file output and run the markdown regression tests. +Regenerate the exact dependency pin and lockfile with `bun add --exact marked@`. + +### Subprocesses and terminal colors + +Development scripts run under Bun and use `Bun.spawn` or `Bun.spawnSync`. +Use argument arrays and set stdin, stdout, stderr, cwd, environment, and deadlines +explicitly to preserve the command contract. The capture helper in +`scripts/subprocess.ts` returns UTF-8 stdout and stderr, a nullable status, and +launch, timeout, or output-limit errors for synchronous probes. Check errors and +status before consuming output. Keep the fixed processor probe's platform shell +invocation when testing command wrappers; never interpolate user input into it. +The clean-install verifier retains its existing command runner for cross-platform +package-manager wrappers. The startup benchmark runs under Node to measure its +startup and compile cache; its process calls and the real Node pipe observer in +the test runner's backpressure fixture use Node's process API. + +Use the exact `execa` and `chalk` pins in `package.json` and their public ESM +exports and bundled types. Regenerate both pins and the lockfile with +`bun add --exact execa@ chalk@`. + +Prefer asynchronous `execa(file, args, options)`; use `shell: true` only for +commands that need shell syntax. With `reject: false`, check `failed`, +`timedOut` and `exitCode` before consuming `stdout` or `stderr`. Use +`SyncOptions` for synchronous options. The subprocess is a promise; access +Node child-process event and lifecycle APIs through `subprocess.nodeChildProcess`. +Use template strings or `parseCommandString()` for command splitting, and +`ipc: true` for IPC. Explicit `input` takes precedence over inherited stdin; +set stdin deliberately, and bound subprocess output with `maxBuffer` and +execution time with `timeout`. + +Use the shared default `chalk` instance for terminal styles and the existing +`colorize()` and theme helpers for theme colors. Keep the VS Code color boost +and tmux clamp in `src/ink/colorize.ts`. Numeric `FORCE_COLOR` selects an exact +level (0 disables color, 1 selects 16 colors, 2 selects 256, 3 selects truecolor); +`ansi256()` and `bgAnsi256()` downsample at level 1. Use `new Chalk({ level })` +only when a separate capability level is intentional, as for Apple Terminal +charts. Tests that change `chalk.level` must restore it, and environment-driven +color detection tests must import in a fresh process. + +### CLI argument parsing + +Keep `commander` and `@commander-js/extra-typings` pinned together at exactly +`15.0.0`; the typings peer requires `commander ~15.0.0`. Regenerate `bun.lock` +with `bun install` after changing both pins. Use their public ESM exports. + +Declare positional arguments explicitly, including variadic arguments for commands +that forward subprocess arguments. Excess arguments are rejected by default. +Short flags must be a single character; use `--debug-to-stderr` in place of the +unsupported `-d2e` spelling. Dual long flags, such as `--yolo` and +`--dangerously-skip-permissions`, are supported. When registering both positive +and negative boolean flags, set an explicit default only if the command needs one; +otherwise the omitted option remains undefined. Keep help and parse-error checks +on the built CLI so command registration is exercised under Node. + +### Dependency pins and overrides + +Terminal syntax highlighting uses the exact `highlight.js` pin directly, with +the shared Chalk instance for ANSI colors. Keep the lazy loader and terminal +palette in `src/utils/cliHighlight.ts` for terminal code and Markdown highlighting +only. Structured diff rendering in `src/native-ts/color-diff/index.ts` loads +`highlight.js` separately. Run the highlighting and Markdown regression tests +when changing this dependency or its ANSI renderer. + +Keep all direct dependencies and devDependencies pinned to exact versions in the +root and web manifests. Regenerate the relevant Bun lockfile +when changing a pin. + +Schema validation uses Zod `4.6.7`, pinned exactly in the root development +dependencies and in the root and website resolution overrides. Import +`zod/v4` for application schemas, use native conversion through the shared +`zodToJsonSchema` helper, and preserve validation messages and generated schema +contracts. Follow [Schema validation](docs/schema-validation.md) for object +strictness, defaults, error customization, dependency compatibility and SDK +peer handling. Regenerate both lockfiles with `bun install` after changing +the shared pin. + +The static website in `web/` uses Astro `7.4.2`, `@astrojs/check` `0.9.12`, +and `@astrojs/sitemap` `3.7.4`, all pinned exactly. Install its dependencies +with `bun install --cwd web`, then run `bun run web:typecheck` and +`bun run web:build` from the root. The build includes `scripts/verify-dist.ts` +to check rendered navigation, release redirects, sprites, and sitemap entries. +Astro's Node.js `>=22.23.4` requirement is covered by the repository's +`>=24.22.1` runtime floor. Keep `compressHTML: true` in the site config to +preserve HTML-aware whitespace compression; Astro 7 defaults to JSX whitespace +rules. The site uses Astro templates and typed data without remark or rehype +plugins, so it does not need the optional `@astrojs/markdown-remark` peer. +If adding those plugins later, install a compatible exact pin and configure +the Markdown processor according to the Astro 7 upgrade guide. + +Linting uses ESLint `10.13.0` with `@typescript-eslint/parser` and +`@typescript-eslint/eslint-plugin` both pinned to `8.72.0`, which support ESLint 10. +Keep these pins compatible and regenerate `bun.lock` with `bun install` after +updating them. Run `bun run lint` from the repository root; ESLint 10 discovers +`eslint.config.js` from each linted file's directory. The JavaScript flat config +does not require `jiti` or extend a recommended preset. Keep its explicit rules +and ignores when upgrading. ESLint's Node.js requirement is covered by the +supported Node.js `>=24.22.1` runtime. + +Keep `chokidar` pinned exactly to `6.0.0` and use its public ESM exports and +bundled types. Pass literal paths rather than globs. Use `backend` to select +watching behavior and `pollingInterval` / `pollingBinaryInterval` for polling; +`awaitWriteFinish.pollInterval` is a separate, unchanged option. Skill watching +uses the polling backend under Bun with explicit `atomic: true` to preserve +atomic-save handling. Await `close()` when teardown must finish, and create a +new watcher for subsequent subscriptions because closing is terminal. Its +Node.js 22.22 minimum is covered by the supported Node.js `>=24.22.1` runtime. +Regenerate `bun.lock` with `bun install` after updating the exact pin. + +Keep `@sentry/node` pinned exactly to `11.9.0` and regenerate `bun.lock` with +`bun install` after updating it. The optional reporter uses a lazy ESM import +after trust is established. Keep default integrations and tracing disabled, +set `attachStacktrace: false`, and explicitly disable `dataCollection` categories +so only vetted messages are reported. Do not add automatic instrumentation or +an OpenTelemetry provider to this path. Run `bun test src/utils/sentry.test.ts` +when updating the SDK or initialization. Its Node.js minimum is covered by the +supported runtime; self-hosted Sentry requires 26.4.2 or later. + +The root `overrides` block also controls transitive resolutions: + +- `ip-address`: `10.7.4` sets the security floor for the MCP SDK's + `express-rate-limit` dependency path. +- `google-auth-library`: `11.3.1` keeps Google authentication on the same version + as the direct dependency. The override accompanies the replacement of the + legacy Vertex SDK to remove its deprecated UUID dependency path. +- `lodash-es`: `4.18.1` keeps transitive copies on the security fixes for template + code injection and prototype pollution, avoiding the deprecated `4.18.0` release. +- `node-domexception`: the existing shim re-exports the platform's native + `DOMException` for `fetch-blob`, replacing the deprecated polyfill. + ## AI Agent Guidelines If you are an AI agent (Copilot, Cursor, Claude, etc.) working on this codebase, refer to [AGENTS.md](AGENTS.md) for project-specific coding guidelines, conventions, and validation commands. Following these guidelines will help your contributions align with the project's patterns and reduce review friction. @@ -187,6 +344,59 @@ In particular: link release-notes navigation to GitHub Releases rather than addi - Do not reformat unrelated files just because they are nearby. - Keep comments useful and concise. +When working on diff generation or rendering, import public APIs and types from +`diff`; the package supplies its own TypeScript declarations, so do not add +`@types/diff` or import internal library paths. Use `diffWordsWithSpace` when +rendering must preserve whitespace. Keep patch filenames separate from hunk +content: serialized headers may quote or escape filenames, and parsed patches +can omit filenames. Check the diff and edit-snippet tests when changing these +paths. + +### Proxy agents + +Use the exact `undici` 8.12.3 pin and its public exports and bundled types. +Regenerate `bun.lock` with `bun install` after changing the manifest pin. +Its Node.js minimum is covered by the supported `>=24.22.1` runtime. +Wrap per-request fetch dispatchers with the public `Dispatcher1Wrapper` so +Node 24's native fetch can use its legacy callbacks; the wrapper also accepts +the current handler API. `setGlobalDispatcher()` handles the global bridge. +Undici 8 enables HTTP/2 by default; the bridge keeps legacy requests on HTTP/1.1. +Set `proxyTunnel: true` to retain CONNECT for plain HTTP targets. Normalize +leading `*.` bypass entries to `.` before constructing the environment proxy +dispatcher so apex and subdomain bypasses continue to match the shared helpers. +Keep custom TLS options on `Agent.connect` for direct requests, including +`NO_PROXY` bypasses, and on `ProxyAgent.requestTls` for CONNECT targets. + +Use the shared helpers in `src/utils/proxy.ts`: `createAxiosInstance()` for +Axios, `getWebSocketProxyAgent(url)` for Node WebSockets, and `getProxyFetchOptions()` +for fetch. The Axios and WebSocket helpers apply `NO_PROXY` bypasses before +selecting an agent. `getWebSocketProxyUrl(url)` supplies Bun's native WebSocket proxy +option. Lowercase proxy environment variables take precedence over uppercase +variants on the Axios, WebSocket, and fetch/undici paths. The shared helpers read +`no_proxy` before `NO_PROXY` using `??`, so an empty lowercase `no_proxy` is kept +and does not fall back to `NO_PROXY`. + +`https-proxy-agent` is pinned exactly to `9.1.0` and remains an external runtime +module in `scripts/externals.ts`. Use its public named ESM exports, such as +`import { HttpsProxyAgent } from 'https-proxy-agent'`, and the constructor +`new HttpsProxyAgent(proxyUrl, options)`. Both it and `agent-base` are ESM +packages requiring Node.js 20 or later; use OpenClaude's supported Node.js +`>=24.22.1` runtime. Keep dependency updates exact and generate `bun.lock` with +`bun add --exact https-proxy-agent@`. + +Keep TLS verification enabled. Configure `NODE_EXTRA_CA_CERTS` before starting +the CLI when a custom CA is needed. For runtime certificate configuration, the +shared CA loader reads that file and the TLS helpers supply explicit options; +clear the CA and mTLS caches after configuration changes. Structured I/O +environment updates to TLS or proxy settings refresh CA, mTLS, and proxy caches +and global agents; effective bypass-list changes do the same. Proxy-agent +constructor options configure the connection to the proxy. Supply target TLS +options on the HTTPS request, using `getWebSocketTLSOptions()` where applicable; +do not assume constructor `ca` options also trust the target inside a CONNECT +tunnel. Run `bun test src/utils/proxy.connect.test.ts src/utils/proxy.test.ts` +when changing these paths to verify HTTPS CONNECT routing and `NO_PROXY` +bypass with certificate validation enabled. + ## Provider Changes OpenClaude supports multiple provider paths. Before contributing provider changes, review the relevant documentation to ensure your implementation follows the expected patterns: @@ -205,6 +415,10 @@ When submitting provider changes: ## Local Setup +Use Node.js `>=24.22.1` (Node 26 recommended/default) and Bun `1.4.4` (tracked in `.bun-version`). + +Keep `@types/node` on the Node 24 major to match the minimum supported runtime, even when developing on Node 26. Pin it to an exact published 24.x version; the type package version does not need to match the Node.js runtime patch version. + Install dependencies: ```bash @@ -244,7 +458,7 @@ bun run dev:profile ## Validation -CI runs a fixed set of checks on every PR (see `.github/workflows/pr-checks.yml`). This section is the **authoritative local pre-push validation contract** — `AGENTS.md` defers to it. **Run every locally applicable check before every push to an open PR, including follow-up pushes during review.** Do not wait for GitHub CI to discover failures you could have caught locally — wasted Actions minutes are a real cost on this repo. +PR Checks skips the static check, unit shard, dedicated test, launcher, and typecheck jobs when a PR changes only Markdown files or files under `docs/`, unless any change is under `.github/`. CI runs the PR intent scan on every PR, including documentation-only PRs. The web job is selected independently by website paths; every job runs on pushes to `main` (see `.github/workflows/pr-checks.yml`). This section is the **authoritative local pre-push validation contract** — `AGENTS.md` defers to it. **Run every locally applicable check before every push to an open PR, including follow-up pushes during review.** Do not wait for GitHub CI to discover failures you could have caught locally — wasted Actions minutes are a real cost on this repo. The security scan requires the commit ancestry needed to find the PR's merge base. Check whether your clone is shallow: @@ -263,11 +477,9 @@ Required local preflight: ```bash bun install --frozen-lockfile bun run check -bun run typecheck +bun run typecheck:fast bun run typecheck:type-tests node bin/openclaude --version -bun run test:provider -npm run test:provider-recommendation git fetch https://github.com/Twigpine/openclaude.git main bun run security:pr-scan -- --base FETCH_HEAD --head HEAD ``` @@ -308,11 +520,13 @@ bun run web:build Notes on the local preflight: -- `bun run check` already builds the CLI and includes smoke, deadcode, and the full unit pass (`test:full`) — do not run those separately, or you execute work twice. -- `bun run test` isolates each test file's globals and requires Bun's completed JUnit report as well as a successful exit. `test:full` runs all eight native Bun shards serially to release memory between groups, then applies the same completion check to the conversation-arc pass. Every shard must produce a complete report; a failed shard does not prevent the remaining shards from running. A test that terminates the runner early fails the command, even if it exits with code zero. Each shard has a ten-minute deadline (override with `--shard-timeout-ms=`), reports its start/end and elapsed time, and kills its process group on timeout or cancellation. A timeout stops the remaining shards and exits nonzero. On Linux, shard output is relayed from temporary regular files to avoid Bun’s isolated-stdio pipe registration bug; diagnostics remain visible during execution. Temporary reports and logs are removed after the check. CI additionally caps each smoke/test job at 15 minutes and the other jobs at five minutes. +- `bun run typecheck:fast` runs Bun's builtin type checker against `tsconfig.json` and is the CI typecheck. Its wrapper resolves the config relative to the script and runs from outside the repository because the package's `check` script shadows `bun check` inside the project. Use `bun run typecheck:fast` from the root or a subdirectory; from elsewhere, use `bun run --cwd /path/to/openclaude typecheck:fast`. `bun run typecheck:type-tests` also runs in CI and uses TypeScript for focused type assertions. Keep the `typescript` devDependency for these assertions, ESLint, and `@astrojs/check`. +- PR Checks caches Bun package downloads using OS-scoped keys derived from `bun.lock` for root installs and `web/bun.lock` for web installs, with separate restore prefixes. Dependency installs still run with `--frozen-lockfile` on cache hits. Keep the relevant lockfile in sync with dependency changes and run the required local preflight regardless of cache status. +- `bun run check` runs `check:static` (the any-usage budget, smoke build, and deadcode checks), then the full unit pass (`test:full`) — do not run those separately, or you execute work twice. +- `bun run test` runs test files in a shared process and requires Bun's completed JUnit report as well as a successful exit. `test:full` runs all eight native Bun shards serially to release memory between groups. Use `bun run scripts/run-tests.ts --shards=N --shard-index=K` with the same test flags to run only shard K of N. `test:full` then applies the same completion check to the conversation-arc pass and the context-efficiency pass with `HISTORY_SNIP` enabled. Every shard must produce a complete report; a failed shard does not prevent the remaining shards from running. A test that terminates the runner early fails the command, even if it exits with code zero. Each shard has a ten-minute deadline (override with `--shard-timeout-ms=`), reports its start/end and elapsed time, and kills its process group on timeout or cancellation. A timeout stops the remaining shards and exits nonzero. On Linux, shard output is relayed from temporary regular files; diagnostics remain visible during execution. Temporary reports and logs are removed after the check. CI runs `check:static` in `smoke-and-tests`, runs the eight unit shards as parallel `unit-tests` jobs, and runs each dedicated feature pass once in `dedicated-tests`. Each unit shard builds the CLI before testing. CI caps these jobs at 15 minutes each and the other jobs at five minutes. - Fetching upstream `main` by URL avoids assuming that a fork checkout's `origin` points at Twigpine/openclaude. `FETCH_HEAD` is the fetched upstream tip. The scan deliberately uses local `HEAD` so it includes commits that have not been pushed yet; CI uses the pushed PR head SHA after the push. -- The web CI job remains unconditional as an integration backstop. Contributors do not need to run the web suite locally for changes that cannot affect the site. -- This preflight covers the same command families as CI, but it does not reproduce CI exactly in one shell. CI runs the main checks under Node 22 and 24.11.x, separately builds and launches under exact Node 22.0.0, and executes every job on a clean runner. +- The web CI job runs on PRs that change `web/**`, root `package.json` or `bun.lock`, `.bun-version`, or `.github/workflows/pr-checks.yml`, and on every push to `main`. Contributors do not need to run the web suite locally for changes that cannot affect the site. +- This preflight covers the same command families as CI, but it does not reproduce CI exactly in one shell. When selected, CI runs static checks with Node 26 available for smoke checks, runs the unit shards and dedicated feature passes under Node 26, separately builds and checks launcher compatibility under Node 24.22.1 and 26, separately builds and launches under exact Node 24.22.1, and executes every job on a clean runner. If a local or GitHub CI check fails, first determine whether the PR owns the failure. Treat a failure as pre-existing only when you can reproduce it on the PR's current base (or link to the same failure on a current `main` CI run) and the PR neither changes the causal surface nor claims to fix that problem. @@ -336,6 +550,12 @@ Focused tests: bun test ./path/to/test-file.test.ts ``` +Additional local TypeScript check against the same `tsconfig.json`: + +```bash +bun run typecheck +``` + Provider/runtime diagnostics: ```bash diff --git a/Dockerfile b/Dockerfile index 11c797eb49..e12ba30b67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ---- build stage ---- -FROM node:22-slim AS build +FROM node:26-slim AS build WORKDIR /app @@ -28,7 +28,7 @@ RUN bun run build RUN rm -rf node_modules && bun install --frozen-lockfile --production # ---- runtime stage ---- -FROM node:22-slim +FROM node:26-slim WORKDIR /app diff --git a/README.md b/README.md index 0df86e51ee..9e6438ab0b 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ OpenClaude is also mirrored to Twigpine: ### Install -OpenClaude requires Node.js `>=22.0.0` for npm installs and runtime. Bun is +OpenClaude requires Node.js `>=24.22.1` for npm installs and runtime (Node 26 recommended/default). Bun is only needed for source builds and local development. ```bash @@ -350,6 +350,7 @@ Advanced and source-build guides: | OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers | | Z.AI GLM Coding Plan | `/provider` or OpenAI-compatible env vars | Uses `OPENAI_API_KEY` at `https://api.z.ai/api/coding/paas/v4`, defaults to `glm-5.2`, and offers the vision-capable `glm-5.3-flash` option | | AI/ML API | `/provider` or `AIMLAPI_API_KEY` ([setup guide](docs/aimlapi-setup.md)) | Uses `https://api.aimlapi.com/v1`, auto-detects the OpenAI-compatible route from `AIMLAPI_API_KEY`, sends OpenClaude attribution headers, and discovers chat-capable models from the public `/models` catalog | +| Merge Gateway | `/provider` or `MERGE_GATEWAY_API_KEY` ([setup guide](docs/merge-gateway-setup.md)) | Multi-provider model router at `https://api-gateway.merge.dev/v1/openai`; supports Chat Completions, Responses, authenticated model discovery, and routing policies | | Concentrate | `/provider` or `CONCENTRATE_API_KEY` | Unified OpenAI-compatible gateway at `https://api.concentrate.ai/v1`; defaults to `deepseek-v4-flash` and auto-discovers the chat model catalog | | LLMTR | `/provider` or OpenAI-compatible env vars | Multi-model gateway at `https://llmtr.com/v1`; `/provider` and `--provider llmtr` default to `deepseek/deepseek-v4-flash`, while raw env setup must set `OPENAI_BASE_URL=https://llmtr.com/v1` and `OPENAI_MODEL`; accepts `LLMTR_API_KEY` or `OPENAI_API_KEY` after the route is selected and discovers tool-capable Chat Completions models from the public catalog | | Command Code | `/provider` or OpenAI-compatible env vars | Hybrid OpenAI-compatible gateway at `https://api.commandcode.ai/provider/v1`; `/provider` and `--provider commandcode` default to `deepseek/deepseek-v4-flash`, while raw env setup must set `OPENAI_BASE_URL=https://api.commandcode.ai/provider/v1` and `OPENAI_MODEL`; requires `CMD_API_KEY`, `COMMANDCODE_API_KEY`, or the official `COMMAND_CODE_API_KEY` after the route is selected and discovers Chat Completions models from the public catalog. Claude models are unsupported by this Chat Completions integration | @@ -361,11 +362,12 @@ Advanced and source-build guides: | LongCat | `/provider` or env vars | Meituan LongCat OpenAI-compatible API at `https://api.longcat.chat/openai/v1`; uses `LONGCAT_API_KEY` and defaults to `LongCat-2.0` | | ClinePass | `/provider` or env vars | AI model gateway with usage limits (5hr, weekly, monthly); uses `CLINE_API_KEY` at `https://api.cline.bot/api/v1` | | Gemini | `/provider` or env vars | Supports API key only | +| Gemini on Vertex AI | `--provider gemini-vertex` or env vars | Native Gemini requests with Google ADC or an access token; [setup](docs/advanced-setup.md#gemini-on-vertex-ai-native) | | GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials | | Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely | | Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials | | Twigpine Opengateway | Startup default, `/provider`, or env vars | Smart gateway at `https://opengateway.gitlawb.com/v1`; requires an API key from https://gitlawb.com/opengateway/keys and routes Xiaomi MiMo and other partner models by `OPENAI_MODEL` | -| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway (48 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go | +| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway; uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go | | OpenCode Go | `/provider` or env vars | $10/mo subscription for open models (13 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/go/v1`; shared key with OpenCode Zen | | Xiaomi MiMo | `/provider` or env vars | OpenAI-compatible API at `https://mimo.mi.com`; uses `MIMO_API_KEY` and defaults to `mimo-v2.5-pro` | | NEAR AI | `/provider` or env vars | Unified gateway (Claude, GPT, Gemini + TEE open models); uses `NEARAI_API_KEY` at `https://cloud-api.near.ai/v1` | @@ -488,7 +490,7 @@ and client generation from `src/proto/openclaude.proto`. ## Development -Use Node.js `>=22.0.0` and Bun `1.3.13` or newer for source builds. +Use Node.js `>=24.22.1` (Node 26 recommended/default) and Bun `1.4.4` for source builds. ```bash bun install @@ -514,9 +516,8 @@ bun run build bun run benchmark:startup ``` -The benchmark requires Node `>=22.8.0`, where the compile-cache API was added; -the built OpenClaude launcher continues to support the declared Node `>=22.0.0` -runtime range. +The benchmark and built OpenClaude launcher require Node `>=24.22.1`; +Node 26 is recommended/default. The benchmark defaults to 30 separate-process warm runs and 10 isolated empty-cache runs. It reports the median, IQR, MAD, first cache-populating run, diff --git a/bin/openclaude b/bin/openclaude index 3026967bff..5a324fdcaf 100755 --- a/bin/openclaude +++ b/bin/openclaude @@ -11,17 +11,217 @@ import { existsSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath, pathToFileURL } from 'url' import { spawnSync } from 'child_process' -import { enableNodeCompileCacheIfAvailable } from './node-compile-cache.mjs' -import { - HEAP_SIZE_ENV, - HEAP_SIZE_FLAG, - formatPercentageUnavailableStderr, - hasHeapLimitFlag, - hasNodeFlag, - nodeOptionArgs, - resolveHeapSizeMb, - stripLauncherHeapArgs, -} from './heap-limit.mjs' +import os from 'node:os' +import * as nodeModule from 'node:module' + +// Sibling helpers (./node-compile-cache.mjs, ./heap-limit.mjs) ship inside +// bin/ for npm installs, but non-npm layouts (e.g. distro packages that copy +// only the launcher file to /usr/lib/openclaude/bin/) may omit them. A static +// import would then fail with ERR_MODULE_NOT_FOUND before any code runs +// (issue #2255). Load them dynamically so a missing sibling degrades to a +// safe inline fallback instead of a hard boot crash. The fallbacks below are +// intentionally self-contained; keep them in sync with the canonical helpers +// in bin/node-compile-cache.mjs and bin/heap-limit.mjs. +const FALLBACK_DEFAULT_HEAP_SIZE_MB = 8192 +const FALLBACK_HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB' +const FALLBACK_HEAP_PERCENTAGE_ENV = + 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE' +const FALLBACK_HEAP_SIZE_FLAG = '--max-old-space-size' +const FALLBACK_HEAP_PERCENTAGE_FLAG = '--max-old-space-size-percentage' +const FALLBACK_MAX_MEMORY_FLAG = '--max-memory' + +function fallbackHasNodeFlag(args, flag) { + return args.some(arg => arg === flag || arg.startsWith(`${flag}=`)) +} + +function fallbackNodeOptionArgs(nodeOptions) { + return (nodeOptions || '').split(/\s+/).filter(Boolean) +} + +function fallbackHasHeapLimitFlag(args) { + return ( + fallbackHasNodeFlag(args, FALLBACK_HEAP_SIZE_FLAG) || + fallbackHasNodeFlag(args, FALLBACK_HEAP_PERCENTAGE_FLAG) + ) +} + +function fallbackParsePositiveIntegerMb(raw) { + if (raw == null || raw === '') return null + const parsed = Number.parseInt(String(raw), 10) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + +function fallbackParsePercentage(raw) { + if (raw == null) return null + const trimmed = String(raw).trim() + if (!trimmed) return null + const withoutPercent = trimmed.endsWith('%') + ? trimmed.slice(0, -1) + : trimmed + if (withoutPercent.trim() === '') return null + const parsed = Number(withoutPercent) + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) return null + return parsed +} + +function fallbackGetAvailableMemoryBytes() { + try { + const constrained = + typeof process.constrainedMemory === 'function' + ? process.constrainedMemory() + : undefined + if ( + typeof constrained === 'number' && + Number.isSafeInteger(constrained) && + constrained > 0 + ) { + return constrained + } + } catch { + // Fall through to host total memory. + } + try { + const total = os.totalmem() + if (typeof total === 'number' && Number.isFinite(total) && total > 0) { + return total + } + } catch { + // Memory size unknown; callers fall back to the default heap. + } + return 0 +} + +function fallbackFindEqualsFlagValue(args, flag) { + const prefix = `${flag}=` + for (const arg of args) { + if (arg.startsWith(prefix)) return arg.slice(prefix.length) + } + return null +} + +function fallbackFindEqualsOrNextFlagValue(args, flag) { + const prefix = `${flag}=` + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith(prefix)) return arg.slice(prefix.length) + if (arg === flag) { + const next = args[i + 1] + if (next && !next.startsWith('-')) return next + return '' + } + } + return null +} + +function fallbackStripLauncherHeapArgs(args) { + const stripped = [] + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if ( + arg === FALLBACK_MAX_MEMORY_FLAG || + arg.startsWith(`${FALLBACK_MAX_MEMORY_FLAG}=`) + ) + continue + if (arg.startsWith(`${FALLBACK_HEAP_PERCENTAGE_FLAG}=`)) continue + if (arg === FALLBACK_HEAP_PERCENTAGE_FLAG) { + const next = args[i + 1] + if (next && !next.startsWith('-') && fallbackParsePercentage(next) != null) + i += 1 + continue + } + stripped.push(arg) + } + return stripped +} + +function fallbackResolveHeapSizeMb({ argv = [], env = {} } = {}) { + const availableBytes = fallbackGetAvailableMemoryBytes() + const maxMem = fallbackParsePositiveIntegerMb( + fallbackFindEqualsFlagValue(argv, FALLBACK_MAX_MEMORY_FLAG), + ) + if (maxMem != null) { + return { mb: maxMem, source: 'max-memory', setMaxMemoryEnv: true } + } + const argvPercentage = fallbackParsePercentage( + fallbackFindEqualsOrNextFlagValue(argv, FALLBACK_HEAP_PERCENTAGE_FLAG), + ) + const envPercentage = fallbackParsePercentage(env[FALLBACK_HEAP_PERCENTAGE_ENV]) + const percentage = argvPercentage ?? envPercentage + if (percentage != null) { + if (availableBytes > 0) { + const mb = Math.max( + 1, + Math.floor((availableBytes * (percentage / 100)) / (1024 * 1024)), + ) + return { + mb, + source: argvPercentage != null ? 'argv-percentage' : 'env-percentage', + percentage, + } + } + return { + mb: FALLBACK_DEFAULT_HEAP_SIZE_MB, + source: 'percentage-unavailable', + percentage, + } + } + const envMb = fallbackParsePositiveIntegerMb(env[FALLBACK_HEAP_SIZE_ENV]) + if (envMb != null) return { mb: envMb, source: 'env-mb' } + return { mb: FALLBACK_DEFAULT_HEAP_SIZE_MB, source: 'default' } +} + +function fallbackFormatPercentageUnavailableStderr( + percentage, + fallbackMb = FALLBACK_DEFAULT_HEAP_SIZE_MB, +) { + return `openclaude: could not convert heap percentage ${percentage} to megabytes because available memory is unknown; using ${fallbackMb}` +} + +function fallbackEnableNodeCompileCacheIfAvailable() { + // Mirrors bin/node-compile-cache.mjs without the sibling file so a missing + // helper still warms the compile cache instead of losing the optimization. + const enable = nodeModule.enableCompileCache + if (typeof enable !== 'function') return + try { + enable() + } catch { + // Compile caching is optional and must never block startup. + } +} + +async function loadSiblingModule(specifier) { + try { + return await import(new URL(specifier, import.meta.url).href) + } catch { + // Any load failure (missing file, unreadable, corrupt) falls back to the + // inline implementations above: booting the CLI outranks surfacing a + // packaging defect, and the fallback keeps stderr silent. + return null + } +} + +const compileCacheModule = await loadSiblingModule('./node-compile-cache.mjs') +const heapLimitModule = await loadSiblingModule('./heap-limit.mjs') + +const enableNodeCompileCacheIfAvailable = + compileCacheModule?.enableNodeCompileCacheIfAvailable ?? + fallbackEnableNodeCompileCacheIfAvailable +const HEAP_SIZE_ENV = + heapLimitModule?.HEAP_SIZE_ENV ?? FALLBACK_HEAP_SIZE_ENV +const HEAP_SIZE_FLAG = + heapLimitModule?.HEAP_SIZE_FLAG ?? FALLBACK_HEAP_SIZE_FLAG +const formatPercentageUnavailableStderr = + heapLimitModule?.formatPercentageUnavailableStderr ?? + fallbackFormatPercentageUnavailableStderr +const hasHeapLimitFlag = + heapLimitModule?.hasHeapLimitFlag ?? fallbackHasHeapLimitFlag +const hasNodeFlag = heapLimitModule?.hasNodeFlag ?? fallbackHasNodeFlag +const nodeOptionArgs = + heapLimitModule?.nodeOptionArgs ?? fallbackNodeOptionArgs +const resolveHeapSizeMb = + heapLimitModule?.resolveHeapSizeMb ?? fallbackResolveHeapSizeMb +const stripLauncherHeapArgs = + heapLimitModule?.stripLauncherHeapArgs ?? fallbackStripLauncherHeapArgs const __dirname = dirname(fileURLToPath(import.meta.url)) const distPath = join(__dirname, '..', 'dist', 'cli.mjs') diff --git a/bun.lock b/bun.lock index 8785f5262d..eaa3ff9d56 100644 --- a/bun.lock +++ b/bun.lock @@ -10,89 +10,89 @@ "@vscode/ripgrep": "1.18.0", }, "devDependencies": { - "@alcalzone/ansi-tokenize": "0.3.0", - "@anthropic-ai/bedrock-sdk": "0.29.1", - "@anthropic-ai/foundry-sdk": "0.2.3", - "@anthropic-ai/sandbox-runtime": "0.0.55", - "@anthropic-ai/sdk": "0.94.0", - "@aws-sdk/client-bedrock": "3.1047.0", - "@aws-sdk/client-sts": "3.1047.0", - "@aws-sdk/credential-provider-node": "3.972.41", - "@azure/identity": "^4.13.1", - "@commander-js/extra-typings": "12.1.0", - "@grpc/grpc-js": "^1.14.3", - "@grpc/proto-loader": "^0.8.0", - "@modelcontextprotocol/sdk": "1.29.0", - "@sentry/node": "^10.70.0", - "@smithy/core": "3.24.3", - "@smithy/node-http-handler": "4.7.3", - "@types/bun": "1.3.11", - "@types/node": "25.5.0", + "@alcalzone/ansi-tokenize": "0.3.1", + "@anthropic-ai/bedrock-sdk": "0.36.0", + "@anthropic-ai/foundry-sdk": "0.6.0", + "@anthropic-ai/sandbox-runtime": "0.0.81", + "@anthropic-ai/sdk": "0.137.0", + "@aws-sdk/client-bedrock": "3.1156.0", + "@aws-sdk/client-sts": "3.1156.0", + "@aws-sdk/credential-provider-node": "3.972.88", + "@azure/identity": "4.14.0", + "@commander-js/extra-typings": "15.0.0", + "@grpc/grpc-js": "1.14.6", + "@grpc/proto-loader": "0.8.1", + "@modelcontextprotocol/sdk": "1.32.1", + "@sentry/node": "11.9.0", + "@smithy/core": "3.36.2", + "@smithy/node-http-handler": "4.12.1", + "@types/bun": "1.4.4", + "@types/node": "24.19.2", "@types/react": "19.2.14", - "@typescript-eslint/eslint-plugin": "^8.69.0", - "@typescript-eslint/parser": "^8.69.0", - "ajv": "8.18.0", + "@typescript-eslint/eslint-plugin": "8.72.0", + "@typescript-eslint/parser": "8.72.0", + "ajv": "8.20.0", "auto-bind": "5.0.1", - "axios": "1.16.0", - "bidi-js": "1.0.3", - "chalk": "5.6.2", - "chokidar": "4.0.3", - "cli-boxes": "3.0.0", - "cli-highlight": "2.1.11", - "commander": "12.1.0", + "axios": "1.20.0", + "bidi-js": "1.1.0", + "chalk": "6.0.1", + "chokidar": "6.0.0", + "cli-boxes": "4.0.1", + "commander": "15.0.0", "cross-spawn": "7.0.6", - "diff": "8.0.3", - "duck-duck-scrape": "^2.2.7", - "emoji-regex": "10.6.0", - "env-paths": "3.0.0", - "eslint": "9", - "execa": "9.6.1", - "fflate": "0.8.2", + "diff": "9.0.0", + "duck-duck-scrape": "2.2.7", + "emoji-regex": "11.0.0", + "env-paths": "4.0.0", + "eslint": "10.13.0", + "execa": "10.2.1", + "fflate": "0.8.3", "figures": "6.1.0", - "fuse.js": "7.1.0", - "get-east-asian-width": "1.5.0", - "google-auth-library": "10.6.2", + "fuse.js": "7.5.0", + "get-east-asian-width": "1.7.0", + "google-auth-library": "11.3.1", "graphology": "0.26.0", - "graphology-metrics": "2.4.0", - "https-proxy-agent": "7.0.6", - "ignore": "7.0.5", + "graphology-metrics": "2.4.2", + "highlight.js": "10.7.3", + "https-proxy-agent": "9.1.0", + "ignore": "7.0.13", "indent-string": "5.0.0", "js-tiktoken": "1.0.21", "jsonc-parser": "3.3.1", - "knip": "^6.16.1", + "knip": "6.42.1", "lodash-es": "4.18.1", - "lru-cache": "11.2.7", - "marked": "15.0.12", - "p-map": "7.0.4", - "picomatch": "4.0.4", + "lru-cache": "11.5.3", + "marked": "18.1.1", + "p-map": "7.1.1", + "picomatch": "4.0.7", "proper-lockfile": "4.1.2", "qrcode": "1.5.4", "react": "19.2.4", "react-compiler-runtime": "1.0.0", "react-reconciler": "0.33.0", - "semver": "7.7.4", - "sharp": "^0.34.5", - "shell-quote": "1.8.4", + "semver": "7.8.5", + "sharp": "0.35.5", + "shell-quote": "1.12.0", "signal-exit": "4.1.0", - "supports-hyperlinks": "3.2.0", + "supports-hyperlinks": "4.6.0", "tree-kill": "1.2.2", "tree-sitter-wasms": "0.1.13", - "turndown": "7.2.2", - "type-fest": "4.41.0", - "typescript": "5.9.3", - "undici": "7.28.0", + "turndown": "7.2.4", + "type-fest": "5.10.0", + "typescript": "6.0.3", + "undici": "8.12.3", "usehooks-ts": "3.1.1", - "vscode-languageserver-protocol": "3.17.5", + "vscode-languageserver-protocol": "3.18.5", "web-tree-sitter": "0.25.10", - "wrap-ansi": "9.0.2", - "ws": "8.21.0", - "xss": "1.0.15", - "yaml": "2.8.3", - "zod": "3.25.76", + "wrap-ansi": "10.0.2", + "ws": "8.22.0", + "xss": "1.0.16", + "yaml": "2.9.1", + "zod": "4.6.7", }, "peerDependencies": { - "@anthropic-ai/sdk": "^0.94.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@anthropic-ai/sdk": "^0.137.0", + "@modelcontextprotocol/sdk": "^1.32.1", "react": "^19.0.0", "react-reconciler": "^0.33.0", }, @@ -105,29 +105,24 @@ }, }, "overrides": { - "google-auth-library": "10.6.2", - "ip-address": "10.2.0", + "google-auth-library": "11.3.1", + "ip-address": "10.7.4", "lodash-es": "4.18.1", "node-domexception": "file:vendor/node-domexception-shim", + "zod": "4.6.7", }, "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.1", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg=="], - "@anthropic-ai/bedrock-sdk": ["@anthropic-ai/bedrock-sdk@0.29.1", "", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "@aws-crypto/sha256-js": "^4.0.0", "@aws-sdk/client-bedrock-runtime": "^3.797.0", "@aws-sdk/credential-providers": "^3.796.0", "@smithy/eventstream-serde-node": "^2.0.10", "@smithy/fetch-http-handler": "^5.0.4", "@smithy/protocol-http": "^3.0.6", "@smithy/signature-v4": "^3.1.1", "@smithy/smithy-client": "^2.1.9", "@smithy/types": "^2.3.4", "@smithy/util-base64": "^2.0.0" } }, "sha512-eV3vqNvMEy9C5nBgZMlKFewgXoBcIuV/PERRQJIlot3Vd5lU8dVUS7YqkkbXLw8p/gkDmam3PPoPNYsEAmqivw=="], + "@anthropic-ai/bedrock-sdk": ["@anthropic-ai/bedrock-sdk@0.36.0", "", { "dependencies": { "@anthropic-ai/sdk": ">=0.115.1 <1", "@aws-crypto/sha256-js": "^4.0.0", "@aws-sdk/client-bedrock-runtime": "^3.797.0", "@aws-sdk/credential-providers": "^3.796.0", "@opentelemetry/api": "^1.4.1", "@smithy/eventstream-serde-node": "^2.0.10", "@smithy/fetch-http-handler": "^5.0.4", "@smithy/protocol-http": "^3.0.6", "@smithy/signature-v4": "^3.1.1", "@smithy/smithy-client": "^2.1.9", "@smithy/types": "^2.3.4", "@smithy/util-base64": "^2.0.0" } }, "sha512-NgodHkNxLiqmP5aDiHg7YrtXp0q0cLUTSD/GmdfBv7LH/y8wD0hdax+eyy67CBZ/inw9ak0r+7I9+lbMZVb1/g=="], - "@anthropic-ai/foundry-sdk": ["@anthropic-ai/foundry-sdk@0.2.3", "", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1" } }, "sha512-pD5yYnAeem5s8wDLbdf8/N8CejF/edRd9TJV+0PrT9tLKv6ggQimnr7d05pQn6FrIYACPmty9hekCo2JgepP0w=="], + "@anthropic-ai/foundry-sdk": ["@anthropic-ai/foundry-sdk@0.6.0", "", { "dependencies": { "@anthropic-ai/sdk": ">=0.115.1 <1", "@opentelemetry/api": "^1.4.1" } }, "sha512-6f3kUuZT9aAaAkdJq4wZRKd2KgVaOcWkbY6KwK0Z86mfn3LWJI86ZVWZhh5CQZyg9VC3T+8jJk4vpXIth6bqUg=="], - "@anthropic-ai/sandbox-runtime": ["@anthropic-ai/sandbox-runtime@0.0.55", "", { "dependencies": { "@pondwader/socks5-server": "^1.0.10", "commander": "^12.1.0", "node-forge": "^1.4.0", "shell-quote": "^1.8.4", "zod": "^3.24.1" }, "bin": { "srt": "dist/cli.js" } }, "sha512-XGTLbzitn0y6OCdmqP5aYrBR0HlNi87ue++m9vMPxcNwaQeOj9i3f9CSWmXua8mv+Q8E55k4ZGlzJmpDHzQFBw=="], + "@anthropic-ai/sandbox-runtime": ["@anthropic-ai/sandbox-runtime@0.0.81", "", { "dependencies": { "@pondwader/socks5-server": "^1.0.10", "commander": "^12.1.0", "node-forge": "^1.4.0", "zod": "^3.25.0" }, "bin": { "srt": "dist/cli.js" } }, "sha512-bjHDu87gjIR7+Oxy9jPiK3v8CvSELCzfpXa3fjqfSKdvlZET2iF51xcXILWPayTpowB3xtZeQVf2JR916nVW/w=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.94.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-OVlCttk5MyeTGtrWX5+F3MJOfEMDuEjK8+rm9aQMDfRPWndVMbhk37QG8WLnVbcc7huyUGngVMjT7iMN2llySA=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.137.0", "", { "dependencies": { "@opentelemetry/api": "^1.4.1", "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-YpEnbPz8kWF1BWthwUCnRhci7OvDLSUO8NFKoevoPkjNkK9CJeibZITgxr9vhqOP8Le61e5tNDHFTxw1dc6Png=="], - "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.18.1", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ=="], - - "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.7.4", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.18.1", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg=="], - - "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.13.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.18.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw=="], - - "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@3.0.0", "", { "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", "tslib": "^1.11.1" } }, "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA=="], "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], @@ -137,33 +132,33 @@ "@aws-crypto/util": ["@aws-crypto/util@4.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2EnmPy2gsFZ6m8bwUQN4jq+IyXV3quHAcwPOS6ZA3k+geujiqI8aRokO2kFJe+idJ/P3v4qWI186rVMo0+zLDQ=="], - "@aws-sdk/client-bedrock": ["@aws-sdk/client-bedrock@3.1047.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.10", "@aws-sdk/credential-provider-node": "^3.972.41", "@aws-sdk/middleware-host-header": "^3.972.11", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.12", "@aws-sdk/middleware-user-agent": "^3.972.40", "@aws-sdk/region-config-resolver": "^3.972.14", "@aws-sdk/token-providers": "3.1047.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.9", "@aws-sdk/util-user-agent-browser": "^3.972.11", "@aws-sdk/util-user-agent-node": "^3.973.26", "@smithy/core": "^3.24.1", "@smithy/fetch-http-handler": "^5.4.1", "@smithy/node-http-handler": "^4.7.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-t1nSLT/FOv9KCKcrq1/KemqrPCwJUQS2gsN/6C3quZjLMAty16rqiDYLaBYfSQ43uciEi3BRC128vggXSn0YLw=="], + "@aws-sdk/client-bedrock": ["@aws-sdk/client-bedrock@3.1156.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/credential-provider-node": "^3.972.88", "@aws-sdk/token-providers": "3.1156.0", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-SJTtm0PfBtvijFlflXBkoGDBdTBeOgIBiF7yUakd9XQq3h8u7haoCVA58W0s5JSSQmYxQuYXREl3ypitwq0ZQw=="], "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1047.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.10", "@aws-sdk/credential-provider-node": "^3.972.41", "@aws-sdk/eventstream-handler-node": "^3.972.15", "@aws-sdk/middleware-eventstream": "^3.972.11", "@aws-sdk/middleware-host-header": "^3.972.11", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.12", "@aws-sdk/middleware-user-agent": "^3.972.40", "@aws-sdk/middleware-websocket": "^3.972.18", "@aws-sdk/region-config-resolver": "^3.972.14", "@aws-sdk/token-providers": "3.1047.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.9", "@aws-sdk/util-user-agent-browser": "^3.972.11", "@aws-sdk/util-user-agent-node": "^3.973.26", "@smithy/core": "^3.24.1", "@smithy/fetch-http-handler": "^5.4.1", "@smithy/node-http-handler": "^4.7.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-b9yUBRqYQ9ADb1ta8nLXKby9pWo0lKozsauPeAT3IemBfReoY7PG7bSqGCoXkljL/H2mjBQXAbMinPaw6xhigA=="], "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1047.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.10", "@aws-sdk/credential-provider-node": "^3.972.41", "@aws-sdk/middleware-host-header": "^3.972.11", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.12", "@aws-sdk/middleware-user-agent": "^3.972.40", "@aws-sdk/region-config-resolver": "^3.972.14", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.9", "@aws-sdk/util-user-agent-browser": "^3.972.11", "@aws-sdk/util-user-agent-node": "^3.973.26", "@smithy/core": "^3.24.1", "@smithy/fetch-http-handler": "^5.4.1", "@smithy/node-http-handler": "^4.7.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Uu8cmia5ePWyimT5vXbViF2fP8/RT8261zlfz0dzgAELjCpdyT71B6JObY4IZ5tcKa/IiuKrC5ntRVfNbJFh9w=="], - "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1047.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.10", "@aws-sdk/credential-provider-node": "^3.972.41", "@aws-sdk/middleware-host-header": "^3.972.11", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.12", "@aws-sdk/middleware-user-agent": "^3.972.40", "@aws-sdk/region-config-resolver": "^3.972.14", "@aws-sdk/signature-v4-multi-region": "^3.996.26", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.9", "@aws-sdk/util-user-agent-browser": "^3.972.11", "@aws-sdk/util-user-agent-node": "^3.973.26", "@smithy/core": "^3.24.1", "@smithy/fetch-http-handler": "^5.4.1", "@smithy/node-http-handler": "^4.7.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-LLeyHPEYlo16wfteaAkYKZx8BrvF+ZqSkYSWodLmtk8xxCAONLOkeHyAofOswQrSetz3BWiu+sd9WNXEvucoPg=="], + "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1156.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/credential-provider-node": "^3.972.88", "@aws-sdk/signature-v4-multi-region": "^3.996.49", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-emJli57W8F9wPykDPhYRvXTpiI13/IsZOqXnCOoODhBpY/El/HoZDWIoQNzN9N9UOMILVh3T86EOjfI/6bqumA=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@smithy/core": "^3.24.1", "@smithy/signature-v4": "^5.4.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ZGFFlYynBR78Y/F8b/7y4i4sgW/iGwJSjoM7AZo5Et6vyr4/L0bunN+uzKMsvecCZyqcPp4RRK7Rs17l0kMujg=="], + "@aws-sdk/core": ["@aws-sdk/core@3.978.5", "", { "dependencies": { "@aws-sdk/types": "^3.975.0", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.36.1", "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA=="], "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.33", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-yPrIm0tgMN44utkOdKe1Bkvt/NRxcWJFdhbC98tTWxWGEBAzB3lksPhGuB8TtKC0/XyfXcGrBPzx/OKXXexa4w=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gE+CGuPZD1eqUWGSrM8CXDjlwuPujIuwI+IlorD1wE2RcANKKT4jscB9GY1nTJbjmXzD18sycsYbgCG5m3n4/g=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/fetch-http-handler": "^5.4.1", "@smithy/node-http-handler": "^4.7.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-cHZo3bV6zN9joDQ2AYVctfzHTKStxWKwnGu0z7GwCUC+DAtB3qL/+26l+a63RbmFbVvb1JK+0vJKodN3hRMwyw=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/credential-provider-env": "^3.972.36", "@aws-sdk/credential-provider-http": "^3.972.38", "@aws-sdk/credential-provider-login": "^3.972.40", "@aws-sdk/credential-provider-process": "^3.972.36", "@aws-sdk/credential-provider-sso": "^3.972.40", "@aws-sdk/credential-provider-web-identity": "^3.972.40", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/credential-provider-imds": "^4.3.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0NFGS9I3PD2yMveQqqpwpRdyZVStzgk0Yr2rZHh80kV/QNqQCK5lSrksvU3nBcRNSUF5Uk8rL3Xk0EVR+UVAnA=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.21", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/credential-provider-env": "^3.972.76", "@aws-sdk/credential-provider-http": "^3.972.78", "@aws-sdk/credential-provider-login": "^3.972.83", "@aws-sdk/credential-provider-process": "^3.972.76", "@aws-sdk/credential-provider-sso": "^3.973.20", "@aws-sdk/credential-provider-web-identity": "^3.972.82", "@aws-sdk/nested-clients": "^3.997.50", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg=="], "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IEIl+UQnrEjZP53TSl91e8LBephi4i1Mt9WZrMgN8pOg6xPOLZdkN1GhsEzjkMD1TQy4Fp2dwWA/9ToTQFOlLA=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.41", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.36", "@aws-sdk/credential-provider-http": "^3.972.38", "@aws-sdk/credential-provider-ini": "^3.972.40", "@aws-sdk/credential-provider-process": "^3.972.36", "@aws-sdk/credential-provider-sso": "^3.972.40", "@aws-sdk/credential-provider-web-identity": "^3.972.40", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/credential-provider-imds": "^4.3.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-h6BlclpsPGkx7Pv7ukr8oKVqN3jvxnH5n9ZIUQa8focr1ZkKd2MYiPJ2Nv9GI97dohJVJBfZAsTp/qoZL5R1pw=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.88", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.76", "@aws-sdk/credential-provider-http": "^3.972.78", "@aws-sdk/credential-provider-ini": "^3.973.21", "@aws-sdk/credential-provider-process": "^3.972.76", "@aws-sdk/credential-provider-sso": "^3.973.20", "@aws-sdk/credential-provider-web-identity": "^3.972.82", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-eDQ6X7clTAOxXegOx4rGT1hyfusGEYdJGCGo0Ym2+CKeMQBjk+SJSxSVev11NJew5xJHJ/c3hryl2awKaxuSEA=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/token-providers": "3.1047.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-jaABbsoOkGlKg5kaHetYmUV6mWM57H89ia0Yksom1XxC847mfjmEVb4p7VijS1sjPbXjUii4cftJuwsl4MXkRg=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.20", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/nested-clients": "^3.997.50", "@aws-sdk/token-providers": "3.1155.0", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-bfIrM8IIzbRtXRQWx/vNEUBLTImLZyX5uKk8uSdeSAZ4Mj3Yi4UnRJLK4FkQLWErbM3McpVLQ1DaM6XO66Ed5g=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.82", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/nested-clients": "^3.997.50", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA=="], "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1047.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1047.0", "@aws-sdk/core": "^3.974.10", "@aws-sdk/credential-provider-cognito-identity": "^3.972.33", "@aws-sdk/credential-provider-env": "^3.972.36", "@aws-sdk/credential-provider-http": "^3.972.38", "@aws-sdk/credential-provider-ini": "^3.972.40", "@aws-sdk/credential-provider-login": "^3.972.40", "@aws-sdk/credential-provider-node": "^3.972.41", "@aws-sdk/credential-provider-process": "^3.972.36", "@aws-sdk/credential-provider-sso": "^3.972.40", "@aws-sdk/credential-provider-web-identity": "^3.972.40", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/credential-provider-imds": "^4.3.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Fgx+rIstqocZSgHG2zqE/bjVr0TXBvhof0LEf6eQI+mJK5S6Qehkv0tpA0ln2w9nIa08tlaoXq3qXdsNOU8qmw=="], @@ -185,11 +180,11 @@ "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-VuLXVmm7+lKVxqFcOItPkXhjbJ02iUfxkxheRu41SfWf6/xrZup2A2SwHZos/LeQGu3SBHeqTQht80Uo3ienPA=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.26", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/signature-v4": "^5.4.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2N62veqdMZBCwQUHsbhtnaovOFjOa5Dn3dAD1nRqFTUXR4QmirT3HZnfus/L1DS08Vm5CkoKmL0iMVt6YbqEag=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.49", "", { "dependencies": { "@aws-sdk/types": "^3.975.0", "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1047.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-GwJUeMijpeO2SOGGLRg4q2Nj9foBUBd7hTALYVId+m8fQmA4P2hITp5dmrZFd4AjEkSVmt2eFqmk3TttF7HZeQ=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1156.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/nested-clients": "^3.997.50", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-/dS+LLiTM+63ZE1gEOmlXwQ0yrkyT+Bk1xUalfCXPo8w5RUvJXFhXUTAoe7vI83h1YjhFQz1CQtB6ZP6rn8g6g=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-sdk/types": ["@aws-sdk/types@3.975.0", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw=="], "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ibx8Vd73rCTHekNGeXX8cpGWoBKbNAlwKHL3yjSxxttu5QnNDaSAM7/0MFYDjU31/F4lyrPoQcGirT0ew61xcg=="], @@ -201,9 +196,9 @@ "@aws-sdk/util-utf8-browser": ["@aws-sdk/util-utf8-browser@3.259.0", "", { "dependencies": { "tslib": "^2.3.1" } }, "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.41", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="], - "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], @@ -211,13 +206,15 @@ "@azure/core-client": ["@azure/core-client@1.10.2", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ=="], + "@azure/core-process": ["@azure/core-process@1.0.0", "", {}, "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg=="], + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.24.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg=="], "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], - "@azure/identity": ["@azure/identity@4.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw=="], + "@azure/identity": ["@azure/identity@4.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^6.0.0", "open": "^11.0.0", "tslib": "^2.2.0" } }, "sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw=="], "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], @@ -225,37 +222,37 @@ "@azure/msal-common": ["@azure/msal-common@16.11.0", "", {}, "sha512-UikJOtMwkFpZNzTH6Dqk8UTUPbow15zH3e0UjGYZy69lYENW/S05gMLhbxI2eonz66uALhIljvhsSMEb6+O30g=="], - "@azure/msal-node": ["@azure/msal-node@5.3.1", "", { "dependencies": { "@azure/msal-common": "16.11.0", "jsonwebtoken": "^9.0.0" } }, "sha512-sqqv3L1UOI4KDXonNtbxPYUgbSWVXqxvmmb6BUw9n4P/UXgG+cVur3dLWQN4Cz7qQ+UJROCCxMXlksm7gIq0Sw=="], + "@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="], "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@commander-js/extra-typings": ["@commander-js/extra-typings@12.1.0", "", { "peerDependencies": { "commander": "~12.1.0" } }, "sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg=="], + "@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@commander-js/extra-typings": ["@commander-js/extra-typings@15.0.0", "", { "peerDependencies": { "commander": "~15.0.0" } }, "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], - "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.6", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], @@ -273,167 +270,157 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm", "libc": "glibc" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm", "libc": "glibc" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], + + "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw=="], "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - - "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], - - "@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], - - "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="], - - "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], - - "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], - - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="], - - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], "@orama/plugin-data-persistence": ["@orama/plugin-data-persistence@3.1.18", "", { "dependencies": { "@msgpack/msgpack": "^3.1.2", "@orama/orama": "3.1.18", "dpack": "^0.6.22", "seqproto": "^0.2.3" } }, "sha512-pfBbpK96VRW/7IkdMHn2HaW3/+4k2C9Uwyup0IONNuz5bG3L1orCNFZPBmu+zcokOU2YH+IAVuQz6MlvqOe3iw=="], - "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.133.0", "", { "os": "android", "cpu": "arm" }, "sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA=="], - - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.133.0", "", { "os": "android", "cpu": "arm64" }, "sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.154.0", "", { "os": "android", "cpu": "arm" }, "sha512-sDdEtDl7vKdCvf54j72RJbowE3jCmyRz/+oOB6IAwQxAHZ0eI+Dcj4hvVnm0b9Dy31plUkdmfds9C+/Zt895Tg=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.133.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.154.0", "", { "os": "android", "cpu": "arm64" }, "sha512-UHrDPELzvWZoVkIDdSycj4bzcTMCJG3IbN++mSUgVBaUtNtWHjQ98U6Vz5bpF/1Ic3FYJyYhMr74huO5tVvpZw=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.133.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.154.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mokYzajOSXxtSRDWCdHARGdntrKH+rZZ24Jzo8AtafyNzz0NARmpMm26Tc5B6qiGrr6UliWibHCOif1qRN2vaA=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.133.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.154.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WbtUBrclOVItWfLRSvs+Z/WLtXxJ2Ctv50PyuX894HzDN5/e0qzxVpTNPIVMzOerBhgKtFsSMcfxlYIAbvdVgg=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.133.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.154.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YooHZSLawOe2fqm+lDdG8oAaQh2NYV/RMYTYzWRLy72stm2BgE9O/8Al6kFENjkmzyyBccDLLBirGxVtpzQfrw=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.133.0", "", { "os": "linux", "cpu": "arm" }, "sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.154.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hKOwhZbrXdTwc4bGVfbLCXl9f2swl6zWdISocLQSNX9MgPmwHE3qGzOUkCrkuGpwdCVHPnkfOwsqTuYsrP5uNg=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.133.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.154.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tnjiooXEU013349O/yxKbF5Cusp1CfHWQ8kYR8tGeRzR/P4sOpQGQo21fsS71M0fMD6ESLq+lbvXFWi43PuKeQ=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.133.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.154.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-Cao2kj+8d7BT6XJU1gV9eVQjV+Cr+e8vQm3tddDRZiM2hHrMDxwy3j6t+ofaSl0idyuPPCZ4AlpmQoj8yOT1QA=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.133.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.154.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-uiwA0V3q8llWA7/8r+a9BBjk4j5YMDmi6wfOsyQfGOJMQSLHEiZ3HI8ulea766ykUa9wJFWk9XWEl4uJrZLQqw=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.133.0", "", { "os": "linux", "cpu": "none" }, "sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.154.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-LICfN0Xs4Zdko+bJy6eOAEQojDuIR26suF2JtOsMaXjgbCrpRSH7/NCvJhY13YvYBx12V6hnwrL8n0lk0rbNJQ=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.133.0", "", { "os": "linux", "cpu": "none" }, "sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.154.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-gbbSYxLF8ssRbQ2qQPy+2EhGNwNH1bAoRdkk6cnWaKHqmCikpSIPtpXjFbCCtjTi0uojqOM3ghFQwQPbDQsU0Q=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.133.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.154.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-nSnRYYDLCcRVjSQzF3g6rKqCNYwo5o+aQgnn8BBXfe+ofCgMGlGqkfRyvXm6ptZvoxNkkWtLlqM8W7Xc3j6ZnQ=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.133.0", "", { "os": "linux", "cpu": "x64" }, "sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.154.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-BxBQoJKtzRQgZEKQIt6CDUm8RGKC5T8Qo4576yDa5/xgc10X3fBDItMbRabzLtbUBfTqKLl+8AVr/H6CF7W22g=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.133.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.154.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-/whkust2vYy+GjRUVL5cWaJmEu6jkdFfo5mkxgckyeOX61eMwo4iBoiwzeJZeMWAdzb/XOtQyLApHhFht3sOWA=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.133.0", "", { "os": "none", "cpu": "arm64" }, "sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.154.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-EXURTyW9riw4B/kZnnE+58emAtw6+3VEszQfXUZkRqGaKIcmWDYkZtKB+xY/blHvuZ+Qy/fDclc31nmUCgtkSA=="], - "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.133.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.154.0", "", { "os": "none", "cpu": "arm64" }, "sha512-ZRFooyH3qsR6vuR4jFGlG3XM36490SvvXtNaUahBK4zHpooMdTHNaOMtGGJI0EW38m5hi4rOco9kw7ZwCIp28w=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.133.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.154.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-U0Vp+4lvwUa/PnOCJTZOjydj1rX2+VLHz4Y2vSFqlN8/7LTiURgD7z4uQ9yNv9O/NlU3mELBnPDlu7aaSYG1zQ=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.133.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.154.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Hc8QgsFY5eUUP25we8TISUruibG2cDt0H4IcPG9RKdabb6odkCzIqjV6Gk9beGWGuOwMVCr81H/Su1q363LCCg=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.133.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.154.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hD7judkUMA/hZPoamvKADs6+an1smp8UTK0aMYRijkvi00K/1vl7vEtmyn2JyQ9S+vibtnfcNzxqTLGyJUZCRQ=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="], - "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], - "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.20.0", "", { "os": "android", "cpu": "arm64" }, "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q=="], + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg=="], - "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.20.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ=="], + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w=="], - "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.20.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg=="], + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q=="], - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.20.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ=="], + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw=="], - "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg=="], + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg=="], - "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg=="], + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA=="], - "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg=="], + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.24.2", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA=="], - "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw=="], + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.24.2", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw=="], - "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.20.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ=="], + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.24.2", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA=="], - "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw=="], + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.24.2", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw=="], - "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg=="], + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.24.2", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w=="], - "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.20.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g=="], + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.24.2", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ=="], - "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g=="], + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.24.2", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg=="], - "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ=="], + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.24.2", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw=="], - "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.20.0", "", { "os": "none", "cpu": "arm64" }, "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ=="], + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg=="], - "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.20.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg=="], + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.24.2", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q=="], - "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.20.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA=="], + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg=="], - "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.20.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw=="], + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], @@ -459,25 +446,27 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - "@sentry/conventions": ["@sentry/conventions@0.16.0", "", {}, "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ=="], + "@sentry/bundler-plugins": ["@sentry/bundler-plugins@11.9.0", "", { "dependencies": { "@sentry/core": "11.9.0", "dotenv": "^17.4.2", "glob": "^13.0.6", "magic-string": "~0.30.8 || ^1.0.0", "oxc-parser": "^0.152.0", "sentry": "^0.45.0", "supports-color": "^8.1.1" }, "peerDependencies": { "rollup": ">=3.2.0", "webpack": ">=5.1.0" }, "optionalPeers": ["rollup", "webpack"] }, "sha512-wpz/gcM/6rld/wb4Mhf8kbDN9ngG5myKQSZC+45gzqR1w+GcdfVnYkp3sHL9AV4mrwL8skhTItdUghMwMTknKg=="], + + "@sentry/conventions": ["@sentry/conventions@0.27.0", "", {}, "sha512-Mrqd/o7n/z7qWSYfQQA7x4fFTA0IqOG6JNsbBpnC97khPwZk7n6DPtOO7YgaYplSl8R8QLRbBz9Xv2lVhToSAg=="], - "@sentry/core": ["@sentry/core@10.70.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-ozhCTDqg89oB4XmWfAwuHshABpvT7AkRpaPnogopPfMAaI61G1t8EKCJ4W7aum8JSBonlfyjPCyW5oYZFm0KvA=="], + "@sentry/core": ["@sentry/core@11.9.0", "", { "dependencies": { "@sentry/conventions": "^0.27.0" } }, "sha512-t0dZyHILt2XVC4Fe9UmAty6YRfOuQv95cvWeNl+HlW4LphjF5/jkALV3cJCUMdhpblYELgIJC4U/S/GoYuI2bA=="], - "@sentry/node": ["@sentry/node@10.70.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0", "@sentry/node-core": "10.70.0", "@sentry/opentelemetry": "10.70.0", "@sentry/server-utils": "10.70.0", "import-in-the-middle": "^3.0.0" } }, "sha512-SPOOVxmKTVIEtqvOKkQT163e/pOwucjS7OPsCHyRs8sFR4nfBNu0EThplyqnvqd5BWBMTPH6WTBQfo+QWHV+HA=="], + "@sentry/node": ["@sentry/node@11.9.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/bundler-plugins": "11.9.0", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.9.0", "@sentry/opentelemetry": "11.9.0", "@sentry/server-runtime-injection": "11.9.0", "@sentry/server-utils": "11.9.0" } }, "sha512-cz219PzVaAPTlQ4bRBTRBCOvxbZkXK0KkqyqWPV6OmSn6vWn935CTfOs+MKUc1A+locvB8i9OfBIbO6v03BBuQ=="], - "@sentry/node-core": ["@sentry/node-core@10.70.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0", "@sentry/opentelemetry": "10.70.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-oPOEVVNxv5WHtckx2i06Wi9FLWyvOg/1DUeX732jZ4iqT2nupINaMH4nF4f4kSvUThFnxkFSRQxwqOxgzMKhKA=="], + "@sentry/opentelemetry": ["@sentry/opentelemetry@11.9.0", "", { "dependencies": { "@sentry/conventions": "^0.27.0", "@sentry/core": "11.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-FMCkAbEfBpFterrkR7k4P8LEabUAEHjZMbu+ycDjbnI1U4pT22tpwcFJOt06orFkacoWNsYT9+EdfmkvaCKtSA=="], - "@sentry/opentelemetry": ["@sentry/opentelemetry@10.70.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-UNV/2tqypcUK6FDzerAsFJn1Km/c4VZCYkUZDNbnV5S0cwAq2BYKMo4M5vovaLDBQlxA+Wk9ovbxi5wYjjl9fw=="], + "@sentry/server-runtime-injection": ["@sentry/server-runtime-injection@11.9.0", "", { "dependencies": { "@sentry/core": "11.9.0", "@sentry/server-utils": "11.9.0" } }, "sha512-Q2G5xaeoiYwGheCZyeqZ/v//SZYjRLlITlanTsZM/fox1qk7cmbDX7+o4q+JkApVV9VuXQZVxppaVCsBglfDKA=="], - "@sentry/server-utils": ["@sentry/server-utils@10.70.0", "", { "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0", "meriyah": "^6.1.4" } }, "sha512-rzegZjMFFgCp3o+N8+XU13rfSvz4B+f8rU0ijBGrQcHdMNyfsFDTu1UTm262JofmrV2u+s+D0u0vFTnqtOGkbA=="], + "@sentry/server-utils": ["@sentry/server-utils@11.9.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.9.0" } }, "sha512-xHETix5jAjs/4tY+b9RCejEU7O49pNBiGgCWX0KFRMSpWbw9moqQ8i8N62bS7L+/BJnBcZ5SZ1nweXh7w787Qg=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@smithy/abort-controller": ["@smithy/abort-controller@2.2.0", "", { "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw=="], - "@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="], + "@smithy/core": ["@smithy/core@3.36.2", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], "@smithy/eventstream-codec": ["@smithy/eventstream-codec@2.2.0", "", { "dependencies": { "@aws-crypto/crc32": "3.0.0", "@smithy/types": "^2.12.0", "@smithy/util-hex-encoding": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-8janZoJw85nJmQZc4L8TuePp2pk1nxLgkxIR0TUjKJ5Dkj5oelB9WtiSSGXCQvNsJl0VSTvK/2ueMXxvpa9GVw=="], @@ -485,7 +474,7 @@ "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@2.2.0", "", { "dependencies": { "@smithy/eventstream-codec": "^2.2.0", "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-pvoe/vvJY0mOpuF84BEtyZoYfbehiFj8KKWk1ds2AT0mTLYFVs+7sBJZmioOFdBXKd48lfrx1vumdPdmGlCLxA=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], "@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], @@ -497,7 +486,7 @@ "@smithy/node-config-provider": ["@smithy/node-config-provider@2.3.0", "", { "dependencies": { "@smithy/property-provider": "^2.2.0", "@smithy/shared-ini-file-loader": "^2.4.0", "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], "@smithy/property-provider": ["@smithy/property-provider@2.2.0", "", { "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg=="], @@ -531,37 +520,41 @@ "@smithy/util-utf8": ["@smithy/util-utf8@3.0.0", "", { "dependencies": { "@smithy/util-buffer-from": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.69.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/type-utils": "8.69.0", "@typescript-eslint/utils": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.69.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.72.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/type-utils": "8.72.0", "@typescript-eslint/utils": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.72.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.69.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.72.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.69.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.69.0", "@typescript-eslint/types": "^8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.72.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.72.0", "@typescript-eslint/types": "^8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0" } }, "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0" } }, "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.69.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.72.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/utils": "8.69.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/utils": "8.72.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.69.0", "", {}, "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.72.0", "", {}, "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.69.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.69.0", "@typescript-eslint/tsconfig-utils": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.72.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.72.0", "@typescript-eslint/tsconfig-utils": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.69.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.72.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.69.0", "", { "dependencies": { "@typescript-eslint/types": "8.69.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA=="], "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="], @@ -599,9 +592,9 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -609,23 +602,17 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], - "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], + "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -633,33 +620,29 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@6.0.1", "", {}, "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chokidar": ["chokidar@6.0.0", "", { "dependencies": { "readdirp": "^5.1.1" } }, "sha512-Ec2Z7wpcKBiMpR7DxteoqS/sq5G5d2A4fpxzPv6+fD7xNNdrTghn7L11YwV5EN1B3i72Erc3XVT5FkWIDx90+A=="], - "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], - - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], - - "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -669,9 +652,7 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -697,7 +678,7 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -709,10 +690,12 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dpack": ["dpack@0.6.22", "", {}, "sha512-WGPNlW2OAE7Bj0eODMpAHUcEqxrlg01e9OFZDxQodminIgC194/cRHT7K04Z1j7AUEWTeeplYGrIv/xRdwU9Hg=="], "duck-duck-scrape": ["duck-duck-scrape@2.2.7", "", { "dependencies": { "html-entities": "^2.3.3", "needle": "^3.2.0" } }, "sha512-BEcglwnfx5puJl90KQfX+Q2q5vCguqyMpZcSRPBWk8OY55qWwV93+E+7DbIkrGDW4qkqPfUvtOUdi0lXz6lEMQ=="], @@ -723,18 +706,16 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@11.0.0", "", {}, "sha512-2qQtc7syPcJmvF7BfKxBvwgvhzDth6u3q23+Jr4JOQUcmMGgmj7B4/ePZ2DmOhrrb77S8kMNHq2kilVASysfqA=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "env-paths": ["env-paths@4.0.0", "", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -745,13 +726,13 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + "eslint": ["eslint@10.13.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -769,7 +750,7 @@ "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "execa": ["execa@10.2.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "figures": "^6.1.0", "get-stream": "^9.0.1", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.3.1", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "which-command": "^0.1.0", "yoctocolors": "^2.2.0" } }, "sha512-/Bofl3ZBh2Q4/PG0W63qFzP5vDAypi8Zc90UWTBzaDNIYiX7apPOd0N6L8hG8CojUdq4V51adu8BObP+iVsSTw=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -783,11 +764,9 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], @@ -795,25 +774,25 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + "flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="], "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], - "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + "formatly": ["formatly@0.11.4", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-TeCC10lP8KCgoX+Fe5lryILIT0/Dy+nP0OSVnsuB5CwXoLuOdqxCYlPFF1inR8S/zTy1ec7TjhS2TeRKWzzImA=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], @@ -823,15 +802,15 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], + "fuse.js": ["fuse.js@7.5.0", "", {}, "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow=="], - "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + "gaxios": ["gaxios@8.3.2", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-7VsaTExxjbDv2ZK1YuC4jbgfN33NI/6/7zdYWUHB6mcg51v4tCBZe+xzjqPzOpVzI+C5k1Kk9OQ80WVrXAHOqw=="], - "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "gcp-metadata": ["gcp-metadata@9.2.2", "", { "dependencies": { "gaxios": "^8.3.0", "google-logging-utils": "^2.0.0", "json-bigint": "^1.0.0" } }, "sha512-hlaHpCBoNCzoUjMnBZq0LbUiYqPAhXFaAgyNwQqwWU44TZIYJK3RD3o2wkNdvPs8nnUXqJjTudnodLJkLMBjow=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -839,15 +818,15 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + "google-auth-library": ["google-auth-library@11.3.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^8.3.0", "gcp-metadata": "^9.0.0", "google-logging-utils": "^2.0.0", "jws": "^4.0.0" } }, "sha512-mQS0R2Ev/DKkzuWSYtDeq15uhDeaWuT2qzLYqJ41LmooxVKHmn5ut3pc6dlHY6wfHW/D7FBmOYjAtkjaD1A7qA=="], - "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "google-logging-utils": ["google-logging-utils@2.1.0", "", {}, "sha512-1R6uiq6XhXW2ZrcGtZpGgVDsYGzSmveVOn8Pp37qe1fbThHZ34e3t3eXj5mVu6PFSVx9fvB1KNVnYgVtq/BRcA=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -857,7 +836,7 @@ "graphology-indices": ["graphology-indices@0.17.0", "", { "dependencies": { "graphology-utils": "^2.4.2", "mnemonist": "^0.39.0" }, "peerDependencies": { "graphology-types": ">=0.20.0" } }, "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ=="], - "graphology-metrics": ["graphology-metrics@2.4.0", "", { "dependencies": { "graphology-indices": "^0.17.0", "graphology-shortest-path": "^2.0.0", "graphology-utils": "^2.4.4", "mnemonist": "^0.39.0", "pandemonium": "2.4.1" }, "peerDependencies": { "graphology-types": ">=0.20.0" } }, "sha512-7WOfOP+mFLCaTJx55Qg4eY+211vr1/b3D/R3biz3SXGhAaCVcWYkfabnmO4O4WBNWANEHtVnFrGgJ0kj6MM6xw=="], + "graphology-metrics": ["graphology-metrics@2.4.2", "", { "dependencies": { "graphology-indices": "^0.17.0", "graphology-shortest-path": "^2.0.0", "graphology-utils": "^2.4.4", "mnemonist": "^0.39.0", "pandemonium": "2.4.1" }, "peerDependencies": { "graphology-types": ">=0.20.0" } }, "sha512-6/ykELkmU2RuicM5zzUaIIvHPwfE94uyhpPkpQm+a9UvHU/2hkJHHIOkj4CFYKyuXNfzMw/xHZiuQfOr3nWLUw=="], "graphology-shortest-path": ["graphology-shortest-path@2.1.0", "", { "dependencies": { "@yomguithereal/helpers": "^1.1.1", "graphology-indices": "^0.17.0", "graphology-utils": "^2.4.3", "mnemonist": "^0.39.0" }, "peerDependencies": { "graphology-types": ">=0.20.0" } }, "sha512-KbT9CTkP/u72vGEJzyRr24xFC7usI9Es3LMmCPHGwQ1KTsoZjxwA9lMKxfU0syvT/w+7fZUdB/Hu2wWYcJBm6Q=="], @@ -865,35 +844,35 @@ "graphology-utils": ["graphology-utils@2.5.2", "", { "peerDependencies": { "graphology-types": ">=0.23.0" } }, "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], + "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="], + "ignore": ["ignore@7.0.13", "", {}, "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -901,7 +880,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + "ip-address": ["ip-address@10.7.4", "", {}, "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -913,12 +892,16 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-safe-filename": ["is-safe-filename@0.1.1", "", {}, "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -933,12 +916,8 @@ "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], - "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], - "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -955,9 +934,9 @@ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "knip": ["knip@6.16.1", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.133.0", "oxc-resolver": "^11.20.0", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.16", "unbash": "^3.0.0", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w=="], + "knip": ["knip@6.42.1", "", { "dependencies": { "formatly": "^0.11.4", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.154.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.9.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^6.0.0", "yaml": "^2.9.1", "zod": "^4.6.5" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-gKJVsiT4LJQ0lfc8uSHOCzkOY8adAXNFGarIeODZbJOkBnQRVsG3e0UZFnO4LMwjA82A5tBWX/VgoeTYcjdKiQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -981,17 +960,15 @@ "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "marked": ["marked@18.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -999,22 +976,18 @@ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "mnemonist": ["mnemonist@0.39.8", "", { "dependencies": { "obliterator": "^2.0.1" } }, "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "mnemonist": ["mnemonist@0.39.8", "", { "dependencies": { "obliterator": "^2.0.1" } }, "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "needle": ["needle@3.5.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, "bin": { "needle": "bin/needle" } }, "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w=="], @@ -1037,51 +1010,49 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "oxc-parser": ["oxc-parser@0.133.0", "", { "dependencies": { "@oxc-project/types": "^0.133.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.133.0", "@oxc-parser/binding-android-arm64": "0.133.0", "@oxc-parser/binding-darwin-arm64": "0.133.0", "@oxc-parser/binding-darwin-x64": "0.133.0", "@oxc-parser/binding-freebsd-x64": "0.133.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.133.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.133.0", "@oxc-parser/binding-linux-arm64-gnu": "0.133.0", "@oxc-parser/binding-linux-arm64-musl": "0.133.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.133.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.133.0", "@oxc-parser/binding-linux-riscv64-musl": "0.133.0", "@oxc-parser/binding-linux-s390x-gnu": "0.133.0", "@oxc-parser/binding-linux-x64-gnu": "0.133.0", "@oxc-parser/binding-linux-x64-musl": "0.133.0", "@oxc-parser/binding-openharmony-arm64": "0.133.0", "@oxc-parser/binding-wasm32-wasi": "0.133.0", "@oxc-parser/binding-win32-arm64-msvc": "0.133.0", "@oxc-parser/binding-win32-ia32-msvc": "0.133.0", "@oxc-parser/binding-win32-x64-msvc": "0.133.0" } }, "sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw=="], + "oxc-parser": ["oxc-parser@0.154.0", "", { "dependencies": { "@oxc-project/types": "^0.154.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.154.0", "@oxc-parser/binding-android-arm64": "0.154.0", "@oxc-parser/binding-darwin-arm64": "0.154.0", "@oxc-parser/binding-darwin-x64": "0.154.0", "@oxc-parser/binding-freebsd-x64": "0.154.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.154.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.154.0", "@oxc-parser/binding-linux-arm64-gnu": "0.154.0", "@oxc-parser/binding-linux-arm64-musl": "0.154.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.154.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.154.0", "@oxc-parser/binding-linux-riscv64-musl": "0.154.0", "@oxc-parser/binding-linux-s390x-gnu": "0.154.0", "@oxc-parser/binding-linux-x64-gnu": "0.154.0", "@oxc-parser/binding-linux-x64-musl": "0.154.0", "@oxc-parser/binding-openharmony-arm64": "0.154.0", "@oxc-parser/binding-win32-arm64-msvc": "0.154.0", "@oxc-parser/binding-win32-ia32-msvc": "0.154.0", "@oxc-parser/binding-win32-x64-msvc": "0.154.0" } }, "sha512-Gr/6fkjZwi937c4Tsz8y0iO4U0FLPkZI6JSVucLAIvQyqh379px7RS8ORIc0HOORbFjXZ6zyqWLl53qxhSeofw=="], - "oxc-resolver": ["oxc-resolver@11.20.0", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.20.0", "@oxc-resolver/binding-android-arm64": "11.20.0", "@oxc-resolver/binding-darwin-arm64": "11.20.0", "@oxc-resolver/binding-darwin-x64": "11.20.0", "@oxc-resolver/binding-freebsd-x64": "11.20.0", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-musl": "11.20.0", "@oxc-resolver/binding-openharmony-arm64": "11.20.0", "@oxc-resolver/binding-wasm32-wasi": "11.20.0", "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" } }, "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g=="], + "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "p-map": ["p-map@7.1.1", "", {}, "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - "pandemonium": ["pandemonium@2.4.1", "", { "dependencies": { "mnemonist": "^0.39.2" } }, "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g=="], + "package-manager-detector": ["package-manager-detector@1.9.0", "", {}, "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "pandemonium": ["pandemonium@2.4.1", "", { "dependencies": { "mnemonist": "^0.39.2" } }, "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], - - "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], @@ -1089,10 +1060,14 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], @@ -1107,18 +1082,14 @@ "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], - "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -1135,12 +1106,12 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "sentry": ["sentry@0.45.0", "", { "bin": { "sentry": "dist/bin.cjs" } }, "sha512-kgmEbLlC22lL7uneeUm9Sjz65rp5cvwYOMTxXbZCmBum4sbVre/lLT3zTV3kwXHJ9/BM8ZS0/GhZe9Cl1t6WyA=="], + "seqproto": ["seqproto@0.2.3", "", {}, "sha512-HpNyPYl7DJa2a6XQJ+MJAc6ft6Y9ZU+zRiuvTHFpLPeqvapcTAGFJyhMEIN7Y7VXhWT6NEdNVhbXFHwtaCOfCw=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -1149,13 +1120,13 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], + "shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -1167,13 +1138,13 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1181,15 +1152,11 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], + "supports-hyperlinks": ["supports-hyperlinks@4.6.0", "", { "dependencies": { "has-flag": "^5.0.1", "supports-color": "^10.2.2" } }, "sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], - - "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1205,21 +1172,21 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="], + "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "unbash": ["unbash@3.0.0", "", {}, "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA=="], + "unbash": ["unbash@6.0.1", "", {}, "sha512-0db9eS5iqybux41zLkX69nk90KTci7F7eZ+gXzEG/Q7E0tAqEBhRh5fkZvk1N7jT7QkGWqq34i8SFGPFEe1V8A=="], - "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici": ["undici@8.12.3", "", {}, "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1231,11 +1198,11 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "vscode-jsonrpc": ["vscode-jsonrpc@10.0.0", "", {}, "sha512-USSfxg0vDIpTQqlQBGxF3mBARyoS8cU7LHLpbYNh6dOJt32dlskxPWB0Irrh5TllTmyf7oNLAvqDqzklbwwnpA=="], - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.5", "", { "dependencies": { "vscode-jsonrpc": "10.0.0", "vscode-languageserver-types": "3.18.5" } }, "sha512-FW4fBMzZ99N8xeob4py0l050wgCoRgTpEq5Xcsb9lM1bc29UKGR/wSV+4knaqFxssFHajy7OzNiKP/m3U1ZEuA=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.5", "", {}, "sha512-kPd6+JQNz3HMJvjKlqUxERSlZHAntl/aGpsk7ImwIh0ewDKDcjgAEyVSYciPiOJD3ftbK9IrfiqUeTZLSbCizw=="], "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], @@ -1245,25 +1212,25 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-command": ["which-command@0.1.0", "", { "bin": { "which-command": "cli.js" } }, "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrap-ansi": ["wrap-ansi@10.0.2", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0" } }, "sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], - "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], - "xss": ["xss@1.0.15", "", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": { "xss": "bin/xss" } }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="], + "xss": ["xss@1.0.16", "", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": { "xss": "bin/xss" } }, "sha512-CsEvZ0QNH9vVgf3qldg+mJmmIwreZZEu/JS1SgldJrJqZgwyMrmIfENt1BJlkj/cPxO0c+R80kB5OMRtUPgqpQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1271,125 +1238,195 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@anthropic-ai/sandbox-runtime/shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "@anthropic-ai/sandbox-runtime/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="], + + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-crypto/crc32/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@aws-crypto/sha256-browser/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], "@aws-crypto/sha256-browser/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-crypto/sha256-js/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "@aws-crypto/util/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/client-bedrock/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + "@aws-crypto/util/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "@aws-sdk/client-bedrock/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/client-bedrock/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], "@aws-sdk/client-bedrock-runtime/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], - "@aws-sdk/client-bedrock-runtime/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1047.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-GwJUeMijpeO2SOGGLRg4q2Nj9foBUBd7hTALYVId+m8fQmA4P2hITp5dmrZFd4AjEkSVmt2eFqmk3TttF7HZeQ=="], + + "@aws-sdk/client-bedrock-runtime/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/client-bedrock-runtime/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], "@aws-sdk/client-cognito-identity/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], - "@aws-sdk/client-cognito-identity/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/client-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/client-cognito-identity/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/client-sts/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], + + "@aws-sdk/core/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-cognito-identity/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-env/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-http/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.83", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/nested-clients": "^3.997.50", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.50", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/signature-v4-multi-region": "^3.996.49", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA=="], + + "@aws-sdk/credential-provider-ini/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-login/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-node/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-process/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.50", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/signature-v4-multi-region": "^3.996.49", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1155.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/nested-clients": "^3.997.50", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA=="], - "@aws-sdk/client-sts/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + "@aws-sdk/credential-provider-sso/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/client-sts/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.50", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/signature-v4-multi-region": "^3.996.49", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA=="], - "@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g=="], + "@aws-sdk/credential-provider-web-identity/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/core/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gE+CGuPZD1eqUWGSrM8CXDjlwuPujIuwI+IlorD1wE2RcANKKT4jscB9GY1nTJbjmXzD18sycsYbgCG5m3n4/g=="], - "@aws-sdk/credential-provider-cognito-identity/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/fetch-http-handler": "^5.4.1", "@smithy/node-http-handler": "^4.7.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-cHZo3bV6zN9joDQ2AYVctfzHTKStxWKwnGu0z7GwCUC+DAtB3qL/+26l+a63RbmFbVvb1JK+0vJKodN3hRMwyw=="], - "@aws-sdk/credential-provider-env/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/credential-provider-env": "^3.972.36", "@aws-sdk/credential-provider-http": "^3.972.38", "@aws-sdk/credential-provider-login": "^3.972.40", "@aws-sdk/credential-provider-process": "^3.972.36", "@aws-sdk/credential-provider-sso": "^3.972.40", "@aws-sdk/credential-provider-web-identity": "^3.972.40", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/credential-provider-imds": "^4.3.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0NFGS9I3PD2yMveQqqpwpRdyZVStzgk0Yr2rZHh80kV/QNqQCK5lSrksvU3nBcRNSUF5Uk8rL3Xk0EVR+UVAnA=="], - "@aws-sdk/credential-provider-http/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-eDQ6X7clTAOxXegOx4rGT1hyfusGEYdJGCGo0Ym2+CKeMQBjk+SJSxSVev11NJew5xJHJ/c3hryl2awKaxuSEA=="], - "@aws-sdk/credential-provider-ini/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/token-providers": "3.1047.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-jaABbsoOkGlKg5kaHetYmUV6mWM57H89ia0Yksom1XxC847mfjmEVb4p7VijS1sjPbXjUii4cftJuwsl4MXkRg=="], - "@aws-sdk/credential-provider-login/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-bfIrM8IIzbRtXRQWx/vNEUBLTImLZyX5uKk8uSdeSAZ4Mj3Yi4UnRJLK4FkQLWErbM3McpVLQ1DaM6XO66Ed5g=="], - "@aws-sdk/credential-provider-node/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/credential-provider-process/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/credential-providers/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/credential-provider-sso/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/eventstream-handler-node/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/credential-provider-web-identity/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/eventstream-handler-node/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/credential-providers/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-eventstream/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/eventstream-handler-node/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-eventstream/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/middleware-eventstream/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-host-header/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/middleware-host-header/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-host-header/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/middleware-logger/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-logger/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/middleware-recursion-detection/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-logger/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/middleware-user-agent/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-recursion-detection/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/middleware-websocket/@smithy/signature-v4": ["@smithy/signature-v4@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g=="], + "@aws-sdk/middleware-recursion-detection/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@aws-sdk/middleware-websocket/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/middleware-recursion-detection/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/middleware-user-agent/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-user-agent/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/middleware-websocket/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-websocket/@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], + + "@aws-sdk/middleware-websocket/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], "@aws-sdk/nested-clients/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], - "@aws-sdk/nested-clients/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.26", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/signature-v4": "^5.4.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2N62veqdMZBCwQUHsbhtnaovOFjOa5Dn3dAD1nRqFTUXR4QmirT3HZnfus/L1DS08Vm5CkoKmL0iMVt6YbqEag=="], - "@aws-sdk/region-config-resolver/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/nested-clients/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g=="], + "@aws-sdk/nested-clients/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/signature-v4-multi-region/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/region-config-resolver/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/token-providers/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/region-config-resolver/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/types/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], - "@aws-sdk/util-endpoints/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/signature-v4-multi-region/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/util-user-agent-browser/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.50", "", { "dependencies": { "@aws-sdk/core": "^3.978.5", "@aws-sdk/signature-v4-multi-region": "^3.996.49", "@aws-sdk/types": "^3.975.0", "@smithy/core": "^3.36.1", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA=="], - "@aws-sdk/util-user-agent-node/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/token-providers/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/types/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@aws-sdk/util-endpoints/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/util-endpoints/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-sdk/util-user-agent-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@eslint/eslintrc/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "@aws-sdk/util-user-agent-browser/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@aws-sdk/util-user-agent-node/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "@aws-sdk/util-user-agent-node/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@smithy/core/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.14.1", "", {}, "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@smithy/eventstream-codec/@aws-crypto/crc32": ["@aws-crypto/crc32@3.0.0", "", { "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", "tslib": "^1.11.1" } }, "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@sentry/bundler-plugins/oxc-parser": ["oxc-parser@0.152.0", "", { "dependencies": { "@oxc-project/types": "^0.152.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.152.0", "@oxc-parser/binding-android-arm64": "0.152.0", "@oxc-parser/binding-darwin-arm64": "0.152.0", "@oxc-parser/binding-darwin-x64": "0.152.0", "@oxc-parser/binding-freebsd-x64": "0.152.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.152.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.152.0", "@oxc-parser/binding-linux-arm64-gnu": "0.152.0", "@oxc-parser/binding-linux-arm64-musl": "0.152.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.152.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.152.0", "@oxc-parser/binding-linux-riscv64-musl": "0.152.0", "@oxc-parser/binding-linux-s390x-gnu": "0.152.0", "@oxc-parser/binding-linux-x64-gnu": "0.152.0", "@oxc-parser/binding-linux-x64-musl": "0.152.0", "@oxc-parser/binding-openharmony-arm64": "0.152.0", "@oxc-parser/binding-win32-arm64-msvc": "0.152.0", "@oxc-parser/binding-win32-ia32-msvc": "0.152.0", "@oxc-parser/binding-win32-x64-msvc": "0.152.0" } }, "sha512-ZztsZgaCWmPeKYMdoTT5e0oV/oxD1NeLWNR3kAtr/G707cUPYaaozRlVDHLCGnszHJRszN9mJwmPNpqgwloC0A=="], + + "@sentry/bundler-plugins/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "@smithy/core/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], "@smithy/eventstream-codec/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ=="], - "@smithy/fetch-http-handler/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@smithy/fetch-http-handler/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], "@smithy/middleware-endpoint/@smithy/util-middleware": ["@smithy/util-middleware@2.2.0", "", { "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw=="], - "@smithy/node-http-handler/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@smithy/node-http-handler/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], "@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA=="], @@ -1411,13 +1448,11 @@ "@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + "bun-types/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1425,9 +1460,9 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "es-set-tostringtag/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -1435,18 +1470,20 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "knip/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "knip/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "get-intrinsic/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "needle/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], - "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -1455,33 +1492,75 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@aws-crypto/crc32/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@aws-sdk/client-bedrock-runtime/@aws-crypto/sha256-js/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-crypto/crc32/@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-crypto/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-crypto/sha256-js/@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@aws-sdk/client-bedrock/@aws-crypto/sha256-js/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@aws-crypto/util/@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@aws-sdk/client-bedrock-runtime/@aws-crypto/sha256-js/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], "@aws-sdk/client-cognito-identity/@aws-crypto/sha256-js/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-sts/@aws-crypto/sha256-js/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1047.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.10", "@aws-sdk/nested-clients": "^3.997.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-GwJUeMijpeO2SOGGLRg4q2Nj9foBUBd7hTALYVId+m8fQmA4P2hITp5dmrZFd4AjEkSVmt2eFqmk3TttF7HZeQ=="], "@aws-sdk/nested-clients/@aws-crypto/sha256-js/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.152.0", "", { "os": "android", "cpu": "arm" }, "sha512-393VtirINkae4XHBTkq/yQxhOwEr4V+r0et37P2fp+sYZSdYgZTRDPo5Um8vBh0QjMZ+RmaMy4PP0qwr4cTMwA=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.152.0", "", { "os": "android", "cpu": "arm64" }, "sha512-iYFcExATJNZ0rZj0o02THbD1Nc9jAW0nVgIQPee6ejbSASpbZXlIMw8jxtpzaG14JQTWwGSZ6U5dEtRJXE6V3Q=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.152.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fnU+DfUzi5/rFy0LzmipYRz06PAUUkOS8kCJQ3Eg6oqlunC1XZXoffFqxmdL51RmcbYtUG3P8DVG1U5fckoa2A=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.152.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y3U2okryWTs7hDld6UhjJwLNf8/do+x4g4C+1U5SiGwrTLwbO83WJYJA8+l9iFSilWBgnfpd/+U76tfeNxUHzA=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.152.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XkhkRrrPffEw+ZLv/d/5pEni0buxinm/E/N6fLbJCunD1DBbeCTtVP23ESV+4CWCWdSz397asks1ViGslpmWiQ=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.152.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MLzsRRbdfmdzo6JEhbC1yKU9ItfvM8tEf18HM32jCiNYZEz6J3bRBRl3cTkKR0vBdrk9ZVlcARXtwHccs+epDA=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.152.0", "", { "os": "linux", "cpu": "arm" }, "sha512-HHerTEJz7Iy10Ji6BGsk4gqG6PU0UzFuBu91jIEOX89FnrI5ECWSEr7BLpkYQhp7U0QFtMnBhNfQKI0hYZK78w=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.152.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-9Bb8GyzZ2uSLzRDZ27usaKokr07mxiBe2dDF5b6bd2janmaJTT1gL51UfP7AU30yFiXrJvcRyFKREAgKwZYs0w=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.152.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-om7iDMUroI74A/lfRRWY9/Ev3VWrVsrUZ4tyvyh8raBey/sdcfyAD7tYWc+iykAiotnmftyKTpUKFr1QnaBrAQ=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.152.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-Lf1OL2lMTqwDMFC6jnRb30B+WYHKV0XT+bPHcFM6xHtNVwxpobiTzUIvKH6a0LWpvfwyru8Yu6zCGHqqFHH+gg=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.152.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-BYWSMQK+YJ9ZILStPcmmiSVq8KEG9Qdq/c4BHPC3rUEq/fEDCVjUIwdj5ZzjgZ9P9is9PKu0tcAXzq1ZXg4urQ=="], - "@smithy/eventstream-codec/@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="], + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.152.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-d2LqHNNNqdH185sRK5dPKWZu201JrNu9/CRFuVEuT+ht7n/QaVblEMWT9MALQPrDWYvhG10P+NSKJFquDWZ09w=="], - "@smithy/eventstream-codec/@aws-crypto/crc32/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.152.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-8gBeHt5rA/8+pKhHl+zLggfa3L/V/L2at96p7ty6pBE7id6lFO19nFUc15u2PcjC4OYeYK465MKy1fhhbpFIGw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.152.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-ltgrSF6L+yPTVsIdaAyqKq3zTqzj/1VlNbxWZuxAe8flR3sWdyNsKvdXukoO8/ucJbLickhmieRBge+o/e8Ugw=="], - "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.152.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-LcMp6BvrBnewSfdkLHoSqidhoAq4syKOTBV5TYHWd+2VnGkfD/nPRRVIzTBt/Lkbt7pfOFDGtkiTntFS0p6sQQ=="], - "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.152.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jbhoMCslVxzRCRNQ8TSHxjYdSKRvk8O6ciLVDkahJvmyHDzBqba/bMQHM6ULKj/rgHNNbSzlIiakdx8SEfkJzA=="], - "cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.152.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JZYVlhv36mr/nertnRB7vchJECOBb1Lz6lltalILsB+o9M/is+H6BEDRLQKdqA4JKs6NjJszo46+iM5sfwj6jQ=="], - "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.152.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-1X9oganvVsunRH0lH10UU05N+H9atpX6WsOybByUjwnfcd3h/0tYwvuv16XuHglrIUOnxV2TodbLVEwwafd64g=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.152.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw=="], + + "@sentry/bundler-plugins/oxc-parser/@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], + + "@sentry/bundler-plugins/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "@typespec/ts-http-runtime/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1493,10 +1572,10 @@ "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -1515,26 +1594,10 @@ "@aws-sdk/client-bedrock-runtime/@aws-crypto/sha256-js/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/client-bedrock/@aws-crypto/sha256-js/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/client-cognito-identity/@aws-crypto/sha256-js/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/client-sts/@aws-crypto/sha256-js/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/nested-clients/@aws-crypto/sha256-js/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "cli-highlight/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "cli-highlight/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "cli-highlight/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -1549,12 +1612,6 @@ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "cli-highlight/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 6d23c8622d..8cb3af4104 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -4,7 +4,7 @@ This guide is for users who want source builds, Bun workflows, provider profiles ## Install Options -OpenClaude requires Node.js `>=22.0.0` for npm installs and runtime. Bun is +OpenClaude requires Node.js `>=24.22.1` for npm installs and runtime (Node 26 recommended/default). Bun is only required when building or running from source. ### Option A: npm @@ -15,7 +15,7 @@ npm install -g @twigpine/openclaude@latest ### Option B: From source with Bun -Use Bun `1.3.13` or newer for source builds. Older Bun versions can fail during `bun run build`. +Use Bun `1.4.4` for source builds. Older Bun versions can fail during `bun run build`. ```bash git clone https://github.com/Twigpine/openclaude.git @@ -51,6 +51,11 @@ export OPENAI_MODEL=gpt-4o `codexplan` maps to GPT-5.6 Sol on the Codex backend with high reasoning. `codexspark` maps to GPT-5.3 Codex Spark for faster loops. +Full Codex aliases such as `gpt-5.6-sol` also use the Codex backend when no +base URL is configured. To use the direct OpenAI API with those model names, +set `OPENAI_BASE_URL=https://api.openai.com/v1` explicitly. The `[1m]` context +tag preserves the same route, including when combined with a reasoning query. + If you use the in-app provider wizard, choose `Codex OAuth` to open ChatGPT sign-in in your browser and let OpenClaude store Codex credentials securely. If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`. @@ -89,11 +94,45 @@ export GEMINI_API_KEY=... export GEMINI_MODEL=gemini-3-flash-preview ``` +### Gemini on Vertex AI (native) + +Use the native Gemini endpoint with an explicit CLI or environment selection: + +```bash +# For global npm installs, install the optional ADC package: +npm i -g google-auth-library +gcloud auth application-default login +export GEMINI_VERTEX_PROJECT=my-gcp-project +export GEMINI_VERTEX_LOCATION=global +openclaude --provider gemini-vertex --model gemini-2.5-flash +``` + +Alternatively set `CLAUDE_CODE_USE_GEMINI_VERTEX=1` and +`GEMINI_VERTEX_MODEL=gemini-2.5-flash`. Project aliases are checked in order: +`GEMINI_VERTEX_PROJECT`, `GOOGLE_CLOUD_PROJECT`, `GCLOUD_PROJECT`, +`GOOGLE_PROJECT_ID`. ADC may supply the project when none is set. ADC supports +`GOOGLE_APPLICATION_CREDENTIALS` and the ambient Google credential chain. + +For bearer authentication, set `GEMINI_ACCESS_TOKEN` to a Google OAuth access +token and supply a project. `GEMINI_VERTEX_AUTH_MODE=adc` or `access-token` +selects the mode explicitly; otherwise a nonblank token selects access-token +mode and the default is ADC. An explicit `--model` or session `/model` +overrides `GEMINI_VERTEX_MODEL`. Location defaults to `global`; +`gemini-3.5-flash` requires the global endpoint. + +This routing stage supports env/CLI selection. Saved native Vertex profiles, +the `/provider` setup preset, and profile launcher/bootstrap integration are +deferred to the profile/UI follow-up. Existing saved profiles for other providers +remain available. Native requests support text, tools, and base64 images/PDFs; +URL media sources must be supplied as base64. Streaming emits the completed +response as Anthropic-shaped events. + ### Claude on Vertex AI The Vertex route uses Anthropic's Claude-on-Vertex API. It is not a general Vertex AI Model Garden adapter for Gemini or arbitrary partner models; use the -Gemini provider for Gemini models and OpenAI-compatible routes for compatible +native Gemini-on-Vertex route above for Gemini on Vertex, the direct Gemini +provider for Google AI Studio, and OpenAI-compatible routes for compatible third-party gateways. Authentication uses Google Application Default Credentials through @@ -256,8 +295,8 @@ export OPENAI_MODEL=gpt-5.4 openclaude ``` -OpenCode Zen is a pay-as-you-go AI gateway with 48 models (GPT, Claude, Gemini, -Qwen, MiniMax, GLM, Kimi, Grok, Big Pickle, DeepSeek, Nemotron). Uses the same +OpenCode Zen is a pay-as-you-go AI gateway with models including GPT, Claude, Gemini, +Qwen, MiniMax, GLM, Kimi, Grok, Big Pickle, DeepSeek, and Nemotron. Uses the same `OPENCODE_API_KEY` as OpenCode Go. Get your key from https://opencode.ai. ### OpenCode Go (subscription) @@ -405,9 +444,9 @@ missing. Install only what you need: | --- | --- | --- | | AWS Bedrock | `CLAUDE_CODE_USE_BEDROCK=1` | `npm i -g @anthropic-ai/bedrock-sdk`. Profile-based auth (`~/.aws/credentials`) additionally needs `@aws-sdk/credential-providers` and `@aws-sdk/client-sts`; model listing needs `@aws-sdk/client-bedrock`. Proxy and skip-auth setups may also need `@aws-sdk/credential-provider-node`, `@smithy/node-http-handler`, or `@smithy/core`. The CLI prints the exact missing package if you hit one. | | Azure Foundry | `CLAUDE_CODE_USE_FOUNDRY=1` | `npm i -g @anthropic-ai/foundry-sdk @azure/identity` | -| Claude on Vertex AI / Gemini ADC | `CLAUDE_CODE_USE_VERTEX=1` / Gemini ADC auth | `npm i -g google-auth-library` | +| Claude on Vertex AI / Gemini ADC / Gemini on Vertex AI | `CLAUDE_CODE_USE_VERTEX=1` / Gemini ADC auth / `CLAUDE_CODE_USE_GEMINI_VERTEX=1` | `npm i -g google-auth-library` | | Reading/processing images (clipboard paste, drag-and-drop paths, FileReadTool local decode) | local image decode — **not** pre-encoded URL/base64 passthrough | `npm i -g sharp` (intentionally opt-in; prebuilt releases load it externally only when processing requires it) | -| Optional error reporting | `SENTRY_DSN` is set | `npm i -g @sentry/node`. Without this package installed, setting `SENTRY_DSN` has no effect and reporting is silently disabled. | +| Optional error reporting | `SENTRY_DSN` is set | `npm i -g @sentry/node@11.9.0`. Without this package installed, setting `SENTRY_DSN` has no effect and reporting is silently disabled. | When installing OpenClaude from source (`bun install`), all of these are already present as dev dependencies, so source/dev builds need no extra steps. @@ -424,7 +463,7 @@ the percentage through the OpenClaude launcher. OpenClaude converts `--max-old-space-size-percentage` and `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE` to an explicit `--max-old-space-size` in megabytes so the same command works on every supported -Node version, including `22.0.0`: +Node version, including `24.22.1`: ```bash openclaude --max-old-space-size-percentage=50 @@ -441,7 +480,7 @@ Heap size precedence, earlier wins: Putting the native flag in `NODE_OPTIONS` or `node --max-old-space-size-percentage=…` only works on Node versions that accept -that option; Node 22.0.0 rejects it before OpenClaude starts. When a supporting +that option. When a supporting Node already applied that native flag, OpenClaude leaves it in place and does not append `8192`. Launcher-only flags (`--max-old-space-size-percentage`, `--max-memory=`) are still stripped so Commander can start. That native-flag @@ -507,7 +546,7 @@ host. Without this variable the behavior is unchanged. | `OLLAMA_BASE_URL` | Ollama only | Optional Ollama server base URL used for model discovery and the signed-in local Web Search proxy. The Ollama provider profile normally supplies the equivalent `OPENAI_BASE_URL`. | | `OLLAMA_API_KEY` | Ollama Web Search only | Enables hosted Ollama search at `https://ollama.com/api/web_search` and acts as a fallback when the configured local Ollama search endpoint is unavailable. | | `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB` | No | Explicit V8 old-space heap cap in megabytes for the Node launcher. Used only when `--max-memory`, `--max-old-space-size-percentage`, and `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE` are all unset. Default `8192`. | -| `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE` | No | Size the V8 old-space heap as a percentage (greater than 0, up to 100) of constrained/container memory when Node reports it, otherwise total system RAM. Converted to `--max-old-space-size` so Node 22.0.0 can start. Precedence, earlier wins: `--max-memory` → `--max-old-space-size-percentage` → this variable → `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB` → default `8192`. | +| `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE` | No | Size the V8 old-space heap as a percentage (greater than 0, up to 100) of constrained/container memory when Node reports it, otherwise total system RAM. Converted to `--max-old-space-size` for compatibility across supported Node versions. Precedence, earlier wins: `--max-memory` → `--max-old-space-size-percentage` → this variable → `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB` → default `8192`. | | `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. | | `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` | No | JSON map of OpenAI-compatible model names to max output tokens, such as `{"custom-model":32768}`. Use this when a custom provider does not expose output-limit metadata from `/v1/models`. | | `OPENCODE_API_KEY` | OpenCode Zen / Go | Shared API key for OpenCode Zen (pay-as-you-go) and OpenCode Go (subscription); get yours from https://opencode.ai | @@ -536,7 +575,8 @@ host. Without this variable the behavior is unchanged. Model env vars are provider-scoped: first-party Anthropic sessions read `ANTHROPIC_MODEL`, OpenAI-compatible sessions read `OPENAI_MODEL`, Gemini reads -`GEMINI_MODEL`, and Mistral reads `MISTRAL_MODEL`. For manual Bedrock, Vertex, +`GEMINI_MODEL`, Gemini on Vertex reads `GEMINI_VERTEX_MODEL`, and Mistral reads +`MISTRAL_MODEL`. For manual Bedrock, Claude on Vertex, or Foundry launches, select the model with `--model`. An OpenAI-compatible provider profile's maximum context length applies to every @@ -693,6 +733,8 @@ Notes: - Reporting is skipped even when `SENTRY_DSN` is set if `DISABLE_TELEMETRY` or `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is set, matching the same privacy levels used for existing telemetry (see [Runtime Hardening](#runtime-hardening)). +- Automatic integrations, tracing, message stack traces, and SDK data collection + are disabled. Self-hosted Sentry requires version 26.4.2 or later. - Only sanitized, telemetry-safe error messages are sent — never raw error messages, which may contain file paths or other identifying information. - `@sentry/node` is an optional dev dependency and is **not included** in the @@ -702,7 +744,7 @@ Notes: disabled (no error, no crash). Install it explicitly with: ```bash - npm i -g @sentry/node + npm i -g @sentry/node@11.9.0 ``` Source builds (`bun install`) already include it as a dev dependency, so no @@ -732,6 +774,8 @@ export OPENCLAUDE_SAFETY_LEVEL=permissive # relax benign-task false positives ## Runtime Hardening +Run the development diagnostics under Bun. Their subprocess probes capture output and +check exit status before reporting results; the installed CLI still runs under Node. Use these commands to validate your setup and catch mistakes early: ```bash @@ -833,10 +877,28 @@ bun run dev:atomic-chat If no profile exists yet, `dev:profile` uses the same goal-aware defaults when picking the initial model. +Profiles activated through `/provider` for canonical MiniMax, Venice, and MiMo +endpoints retain a provider-specific key, including profiles configured as generic +OpenAI-compatible providers. On the same route, that saved key takes precedence over an ambient +`OPENAI_API_KEY` or `OPENAI_API_KEYS` pool. To override it for a launch, set +`MINIMAX_API_KEY`, `VENICE_API_KEY`, or `MIMO_API_KEY` in your shell; the live +provider-specific value takes precedence over the saved value. This applies to +both MiMo API and token-plan routes. + +Launch reconstruction retains these dedicated keys only for their matching +routes. A shell override to another provider's URL does not carry the saved +source provider's dedicated key into the new launch. + ### Provider Profile Model Picker Mode When a saved provider profile is active, `/model` can either show the provider's catalog/discovered models or only the models explicitly listed in the profile. +MiniMax text-model retirement applies to the direct MiniMax route; other +providers retain the MiniMax IDs their own catalogs or discovery advertise. +OpenCode Zen excludes its obsolete `minimax-m3-free` and +`minimax-m3.1-flash-free` suggestions. Saved or explicitly configured selections +remain available. + Configure this in `~/.openclaude.json`: ```json diff --git a/docs/aimlapi-setup.md b/docs/aimlapi-setup.md index 54b77504b9..baadeed97f 100644 --- a/docs/aimlapi-setup.md +++ b/docs/aimlapi-setup.md @@ -73,10 +73,52 @@ export OPENAI_MODEL="gpt-4o" - `/model` lists chat-capable models discovered from the catalog. - Send any prompt to confirm responses come back from the selected model. +## AI/ML API MCP server (Toolbox and media generation) + +AI/ML API also runs a remote MCP server at `https://mcp.aimlapi.com/mcp`. It adds 19 tools to a session: the Toolbox (`tools_search`, `tools_inspect`, `tools_run`, `tools_run_status`), `image_generate`, `video_generate`, `audio_generate`, `llm_chat`, `embeddings_create`, `job_status`, `job_result`, model lookup (`models_list`, `models_search`, `models_find_best`, `models_compare`, `model_get`), `account_balance`, `account_usage`, and `ping`. + +> **Billing:** Toolbox runs (`tools_run`), generations, `llm_chat` and embeddings are charged to your AI/ML API balance. `ping`, `tools_search` and `tools_inspect` are free. The server is never connected unless you turn it on. + +### One step: the built-in `aimlapi` plugin + +If AI/ML API is already your provider, turn the server on with: + +```bash +openclaude plugin enable aimlapi@builtin +``` + +You can also open `/plugin` in a session, find the built-in **aimlapi** plugin and enable it there. To turn it off again: + +```bash +openclaude plugin disable aimlapi@builtin +``` + +- The plugin signs in with the AI/ML API key you already configured. It reads the key from `AIMLAPI_API_KEY`, which an active aimlapi.com provider profile sets for the session. You can also export the variable yourself. The settings file only stores `"aimlapi@builtin": true`. The key is never written there. +- The plugin is off by default, so having a key does not connect the server. It is also hidden when no `AIMLAPI_API_KEY` is set. It stays hidden when your aimlapi profile points at a proxy base URL, so a proxy credential is never sent to aimlapi.com. +- The server appears as `plugin:aimlapi:aimlapi`, and its tools appear as `mcp__plugin_aimlapi_aimlapi__`, for example `mcp__plugin_aimlapi_aimlapi__tools_search`. +- To check it, run `openclaude mcp list`. You should see `plugin:aimlapi:aimlapi: https://mcp.aimlapi.com/mcp (HTTP) - ✓ Connected`. In a session, `/mcp` shows the same server. + +### Without the plugin: `openclaude mcp add` + +The standard MCP commands work too. To use your API key, keep the single quotes so that your shell does not expand the variable. OpenClaude expands `${AIMLAPI_API_KEY}` when it connects, so the config file stores the reference and not the key: + +```bash +openclaude mcp add --transport http -s user aimlapi https://mcp.aimlapi.com/mcp \ + --header 'Authorization: Bearer ${AIMLAPI_API_KEY}' +``` + +To sign in with your aimlapi.com account (OAuth) instead, leave out the header. `openclaude mcp list` reports **Needs authentication**. In a session, open `/mcp`, select the server and authenticate in the browser: + +```bash +openclaude mcp add --transport http -s user aimlapi https://mcp.aimlapi.com/mcp +``` + +If you configure the server both ways, the hand-added entry wins and the plugin copy is skipped as a duplicate. Note that `openclaude mcp get aimlapi` prints header values after expansion, which includes your key. + ## Notes - Model discovery uses the public, unauthenticated `GET /models` endpoint and surfaces only chat-completions models; image, audio, embeddings, and other modalities are intentionally not routed through the coding workflow. -- Every request to AI/ML API (inference, catalog, sign-in/account, and checkout) carries the two mandatory attribution headers `X-AIMLAPI-Source: agent/openclaude` and `X-AIMLAPI-Partner-ID`. On the canonical `api.aimlapi.com` endpoint the inference/catalog requests additionally send `X-AIMLAPI-Integration-*` (owner/repo/version) plus `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude`; all attribution headers are stripped when a non-canonical (proxy) base URL is configured, so a third-party host never receives OpenClaude's partner identity. +- Every request to AI/ML API (inference, catalog, sign-in/account, and checkout) carries the two mandatory attribution headers `X-AIMLAPI-Source: agent/openclaude` and `X-AIMLAPI-Partner-ID`. On the canonical `api.aimlapi.com` endpoint the inference/catalog requests additionally send `X-AIMLAPI-Integration-*` (owner/repo/version) plus `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude`; all attribution headers are stripped when a non-canonical (proxy) base URL is configured, so a third-party host never receives OpenClaude's partner identity. MCP requests to `mcp.aimlapi.com` (matched by exact hostname, from the plugin or a hand-added server) carry `X-AIMLAPI-Source` and `X-AIMLAPI-Partner-ID`. Other MCP servers never receive them. - `AIMLAPI_AUTH_URL`, `AIMLAPI_APP_URL`, `AIMLAPI_PAY_URL`, and `AIMLAPI_VERIFICATION_BASE_URL` can point the complete flow at another environment. Guided **new-account** onboarding and key provisioning require the canonical `api.aimlapi.com` inference endpoint, so `AIMLAPI_INFERENCE_URL` must be unset (or left at its default) to create a new account and mint its key — a non-canonical value is rejected there. The existing-key top-up path (paste an aimlapi.com key) instead runs against whatever `AIMLAPI_INFERENCE_URL` is configured. `AIMLAPI_RETURN_URL` overrides only the browser landing page. - Checkout success is detected by polling. The browser return target is an HTTPS page; OpenClaude does not install a custom URL-scheme handler. - Usage (`/usage`) reporting is not supported for this provider. diff --git a/docs/integrations/overview.md b/docs/integrations/overview.md index df2cc12c96..a799fffdd5 100644 --- a/docs/integrations/overview.md +++ b/docs/integrations/overview.md @@ -128,6 +128,15 @@ their `transportConfig.openaiShim` so the first request is compatible. Native transports such as direct Ollama build their own request payloads and are unaffected. +OpenAI-compatible HTTP 403 responses retry with the existing backoff and retry +budget only when the body explicitly reports request throttling, such as +`Too many requests` or `rate_limit_exceeded`. Authentication, permission, and +billing/quota signals take precedence; empty bodies and generic `try again` +messages remain terminal. An explicit `x-should-retry: false` header still +prevents the outer retry. When the primary request returns a recognized +throttle, credential pools cool the key instead of permanently discarding it. +The GitHub fallback from chat completions to Responses does not update pool cooldowns. + ### Reasoning support is per model and per route `capabilities.supportsReasoning` is descriptive. It says the model is known to diff --git a/docs/integrations/reasoning-effort.md b/docs/integrations/reasoning-effort.md index 0f95f84e5c..e05c62a01c 100644 --- a/docs/integrations/reasoning-effort.md +++ b/docs/integrations/reasoning-effort.md @@ -50,6 +50,24 @@ For `deepseek_compatible`, metadata levels must be limited to `high` and/or `xhi For `zai_compatible`, exact route/model entries may expose verified `low`, `high`, and `xhigh` levels. These serialize as provider `low`, `high`, and `max`, respectively. `medium` is not a distinct GLM-5.3-Flash level, and standard OpenClaude `max` is not added as another picker level. Disabling thinking must be verified separately; low effort is not equivalent to no reasoning. +## Explicit native Ollama thinking + +For a local Ollama endpoint using native `/api/chat`, the model options +`?thinking=enabled` and `?thinking=disabled` send `think: true` and +`think: false`, respectively. For example, with an Ollama provider configured: + +```bash +openclaude --model 'qwen3:8b?thinking=disabled' +``` + +Without a `thinking` option, OpenClaude omits `think` and preserves Ollama's +model-specific default. An explicit thinking option takes precedence over a +`?reasoning=` option. Generic reasoning effort is not translated into Ollama +thinking levels; `/effort` support still requires verified per-model controls. +This explicit override does not mark a model as reasoning-capable. Whether +thinking can be enabled or disabled depends on the selected model; see +[Ollama's thinking controls](https://docs.ollama.com/capabilities/thinking). + ## Adding Support Before adding `reasoning` metadata for a model: diff --git a/docs/merge-gateway-setup.md b/docs/merge-gateway-setup.md new file mode 100644 index 0000000000..91612b7846 --- /dev/null +++ b/docs/merge-gateway-setup.md @@ -0,0 +1,47 @@ +# Merge Gateway setup + +OpenClaude connects to [Merge Gateway](https://gateway.merge.dev) through its OpenAI-compatible endpoint at `https://api-gateway.merge.dev/v1/openai`. The route supports Merge's multi-provider model catalog and routing policies with a dedicated `MERGE_GATEWAY_API_KEY`. + +## Guided setup + +1. Create an API key in the [Merge Gateway dashboard](https://gateway.merge.dev/api-keys). +2. Start OpenClaude and run `/provider`. +3. Choose **Add provider**, then **Merge Gateway**. +4. Paste the API key when prompted. +5. Choose a discovered model, or select **Default routing policy** to let the applicable Merge routing policy choose the provider and model. +6. Choose **Chat Completions** or **Responses** for the API format supported by the selected model and route. + +The model picker keeps a cached catalog for one day and can be refreshed manually. If discovery is temporarily unavailable, the routing-policy and GPT-5.5 fallback entries remain selectable. + +## CLI setup + +```bash +export MERGE_GATEWAY_API_KEY="your-api-key" +openclaude --provider merge-gateway --model openai/gpt-5.5 +``` + +To delegate model selection to a Merge Gateway routing policy: + +```bash +openclaude --provider merge-gateway --model default_routing +``` + +The dedicated Merge credential is not replaced by `OPENAI_API_KEY`. Saved profiles retain it across restarts. It is used only with the canonical inference base above and the native catalog endpoint below; changing a Merge profile to a proxy or another path withholds the credential. Configure a custom OpenAI-compatible profile with its own credentials for a proxy. + +## Verify + +Inside OpenClaude: + +1. Run `/status` and confirm the active provider is **Merge Gateway** and the base URL is `https://api-gateway.merge.dev/v1/openai`. +2. Run `/model`, refresh the catalog, and confirm Merge Gateway models appear. +3. Send a short prompt and confirm the request appears in the [Gateway dashboard](https://gateway.merge.dev). + +For direct catalog troubleshooting, use the authenticated public model endpoint: + +```bash +curl --fail --silent --show-error \ + -H "Authorization: Bearer $MERGE_GATEWAY_API_KEY" \ + "https://api-gateway.merge.dev/v1/models?limit=5" +``` + +Never commit the API key or paste it into issue or pull-request text. diff --git a/docs/non-technical-setup.md b/docs/non-technical-setup.md index a5eff255ea..ae55f3c4b4 100644 --- a/docs/non-technical-setup.md +++ b/docs/non-technical-setup.md @@ -22,7 +22,7 @@ For most first-time users, OpenAI is the easiest option. You need: -1. Node.js 22 LTS or newer installed +1. Node.js 24.22.1 or newer (Node 26 recommended/default) installed 2. A terminal window 3. An API key from your provider, unless you are using a local model like Ollama diff --git a/docs/quick-start-mac-linux.md b/docs/quick-start-mac-linux.md index 03f80264ed..bd065322ac 100644 --- a/docs/quick-start-mac-linux.md +++ b/docs/quick-start-mac-linux.md @@ -4,7 +4,7 @@ This guide uses a standard shell such as Terminal, iTerm, bash, or zsh. ## 1. Install Node.js -Install Node.js 22 LTS or newer from: +Install Node.js 24.22.1 or newer (Node 26 recommended/default) from: - `https://nodejs.org/` diff --git a/docs/quick-start-windows.md b/docs/quick-start-windows.md index ca5bd84901..545d81a6b9 100644 --- a/docs/quick-start-windows.md +++ b/docs/quick-start-windows.md @@ -4,7 +4,7 @@ This guide uses Windows PowerShell. ## 1. Install Node.js -Install Node.js 22 LTS or newer from: +Install Node.js 24.22.1 or newer (Node 26 recommended/default) from: - `https://nodejs.org/` diff --git a/docs/schema-validation.md b/docs/schema-validation.md new file mode 100644 index 0000000000..0af070a491 --- /dev/null +++ b/docs/schema-validation.md @@ -0,0 +1,40 @@ +# Schema validation + +Use Zod 4 through `zod/v4`. The root development dependency and root and website +resolution overrides pin the package to exactly `4.6.7`. Regenerate both lockfiles +with Bun after changing the pin. + +Use `z.strictObject` when unknown keys must fail, `z.looseObject` when they must +survive, and `z.object` when unknown keys should be stripped. Records require +explicit key and value schemas, for example `z.record(z.string(), z.unknown())`. +Prefer top-level format schemas such as `z.email`, `z.url`, and `z.iso.datetime`. +Use `.check` to apply a format schema after existing length checks. Customize +messages with the `error` option and read validation failures from `.issues`. + +A default short-circuits parsing and must match the output type. Use `.prefault` +when a fallback must pass through preprocessing or transforms. Defaults inside +optional object fields are still applied. Preserve these choices when editing +schemas, especially around numeric and boolean input preprocessing. + +Generate API schemas through the shared `zodToJsonSchema` helper. It retains +native conversion, caches by schema identity, and preserves the established +`anyOf` representation for primitive and discriminated unions. This keeps the +published JSON documents stable across the package upgrade. It visits schema +keywords only, leaving defaults and other user data intact. + +The schema compatibility tests capture fingerprints of complete generated and +advertised JSON documents for every exported input and output schema. They run +the enumeration in a fresh process to avoid shared mocks hiding schemas. These +snapshots were captured before the dependency upgrade; do not regenerate them +to hide unintended changes. The fixture's `--full` option prints the documents +for investigating a changed fingerprint. + +The sandbox dependency still uses `zod/v3`. The exact root override resolves it +to the v3 compatibility export shipped in the pinned v4 package; the dependency +is unmodified. Dependency compatibility tests exercise that configuration +parser, both protocol conversion branches, and structured-output parsing. + +Zod is bundled into the distributed JavaScript. The published SDK declarations +do not refer to it, and it is not exposed as a peer dependency. If that type +contract changes, declare a compatible peer range and document the SDK breaking +change before publishing. diff --git a/package.json b/package.json index ac6c2434b1..f2c1b98a2b 100644 --- a/package.json +++ b/package.json @@ -55,19 +55,23 @@ "web:preview": "bun run --cwd web preview", "web:typecheck": "bun run --cwd web typecheck", "test": "bun run scripts/run-tests.ts --feature=UNATTENDED_RETRY --max-concurrency=1", - "test:full": "bun run scripts/run-tests.ts --shards=8 --feature=UNATTENDED_RETRY --max-concurrency=1 && bun run test:conversation-arc", + "test:full": "bun run scripts/run-tests.ts --shards=8 --feature=UNATTENDED_RETRY --max-concurrency=1 && bun run test:conversation-arc && bun run test:context-efficiency", "test:conversation-arc": "bun run scripts/run-tests.ts --feature=CONVERSATION_ARC --feature=MULTI_TURN_CONTEXT --max-concurrency=1 src/query.conversationArc.test.ts", + "test:context-efficiency": "bun run scripts/run-tests.ts --feature=UNATTENDED_RETRY --feature=HISTORY_SNIP --max-concurrency=1 src/utils/attachments.contextEfficiency.test.ts", "test:coverage": "bun test --feature=UNATTENDED_RETRY --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts", "test:coverage:ui": "bun run scripts/render-coverage-heatmap.ts", "security:pr-scan": "bun run scripts/pr-intent-scan.ts", "test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts", + "test:node-options-parity": "bun run scripts/check-node-options-parity.ts", "typecheck": "tsc --noEmit", + "typecheck:fast": "bun run scripts/typecheck-fast.ts", "typecheck:type-tests": "bun run scripts/typecheck-type-tests.ts", "smoke": "bun run build && node dist/cli.mjs --version", "deadcode": "knip --include files,dependencies", "lint": "eslint .", "lint:any-budget": "bun run scripts/any-usage-report.ts --check", - "check": "bun run lint:any-budget && bun run smoke && bun run deadcode && bun run test:full", + "check:static": "bun run lint:any-budget && bun run smoke && bun run deadcode", + "check": "bun run check:static && bun run test:full", "check:strict": "bun run typecheck && bun run lint && bun run lint:any-budget && bun run smoke && bun run deadcode && bun run test:full", "verify:privacy": "bun run scripts/verify-no-phone-home.ts", "install:verify": "bun run scripts/verify-clean-install.ts", @@ -87,89 +91,89 @@ "@vscode/ripgrep": "1.18.0" }, "devDependencies": { - "@alcalzone/ansi-tokenize": "0.3.0", - "@anthropic-ai/bedrock-sdk": "0.29.1", - "@anthropic-ai/foundry-sdk": "0.2.3", - "@anthropic-ai/sandbox-runtime": "0.0.55", - "@anthropic-ai/sdk": "0.94.0", - "@aws-sdk/client-bedrock": "3.1047.0", - "@aws-sdk/client-sts": "3.1047.0", - "@aws-sdk/credential-provider-node": "3.972.41", - "@azure/identity": "^4.13.1", - "@commander-js/extra-typings": "12.1.0", - "@grpc/grpc-js": "^1.14.3", - "@grpc/proto-loader": "^0.8.0", - "@modelcontextprotocol/sdk": "1.29.0", - "@sentry/node": "^10.70.0", - "@smithy/core": "3.24.3", - "@smithy/node-http-handler": "4.7.3", - "@types/bun": "1.3.11", - "@types/node": "25.5.0", + "@alcalzone/ansi-tokenize": "0.3.1", + "@anthropic-ai/bedrock-sdk": "0.36.0", + "@anthropic-ai/foundry-sdk": "0.6.0", + "@anthropic-ai/sandbox-runtime": "0.0.81", + "@anthropic-ai/sdk": "0.137.0", + "@aws-sdk/client-bedrock": "3.1156.0", + "@aws-sdk/client-sts": "3.1156.0", + "@aws-sdk/credential-provider-node": "3.972.88", + "@azure/identity": "4.14.0", + "@commander-js/extra-typings": "15.0.0", + "@grpc/grpc-js": "1.14.6", + "@grpc/proto-loader": "0.8.1", + "@modelcontextprotocol/sdk": "1.32.1", + "@sentry/node": "11.9.0", + "@smithy/core": "3.36.2", + "@smithy/node-http-handler": "4.12.1", + "@types/bun": "1.4.4", + "@types/node": "24.19.2", "@types/react": "19.2.14", - "@typescript-eslint/eslint-plugin": "^8.69.0", - "@typescript-eslint/parser": "^8.69.0", - "ajv": "8.18.0", + "@typescript-eslint/eslint-plugin": "8.72.0", + "@typescript-eslint/parser": "8.72.0", + "ajv": "8.20.0", "auto-bind": "5.0.1", - "axios": "1.16.0", - "bidi-js": "1.0.3", - "chalk": "5.6.2", - "chokidar": "4.0.3", - "cli-boxes": "3.0.0", - "cli-highlight": "2.1.11", - "commander": "12.1.0", + "axios": "1.20.0", + "bidi-js": "1.1.0", + "chalk": "6.0.1", + "chokidar": "6.0.0", + "cli-boxes": "4.0.1", + "commander": "15.0.0", "cross-spawn": "7.0.6", - "diff": "8.0.3", - "duck-duck-scrape": "^2.2.7", - "emoji-regex": "10.6.0", - "env-paths": "3.0.0", - "eslint": "9.39.5", - "execa": "9.6.1", - "fflate": "0.8.2", + "diff": "9.0.0", + "duck-duck-scrape": "2.2.7", + "emoji-regex": "11.0.0", + "env-paths": "4.0.0", + "eslint": "10.13.0", + "execa": "10.2.1", + "fflate": "0.8.3", "figures": "6.1.0", - "fuse.js": "7.1.0", - "get-east-asian-width": "1.5.0", - "google-auth-library": "10.6.2", + "fuse.js": "7.5.0", + "get-east-asian-width": "1.7.0", + "google-auth-library": "11.3.1", "graphology": "0.26.0", - "graphology-metrics": "2.4.0", - "https-proxy-agent": "7.0.6", - "ignore": "7.0.5", + "graphology-metrics": "2.4.2", + "highlight.js": "10.7.3", + "https-proxy-agent": "9.1.0", + "ignore": "7.0.13", "indent-string": "5.0.0", "js-tiktoken": "1.0.21", "jsonc-parser": "3.3.1", - "knip": "^6.16.1", + "knip": "6.42.1", "lodash-es": "4.18.1", - "lru-cache": "11.2.7", - "marked": "15.0.12", - "p-map": "7.0.4", - "picomatch": "4.0.4", + "lru-cache": "11.5.3", + "marked": "18.1.1", + "p-map": "7.1.1", + "picomatch": "4.0.7", "proper-lockfile": "4.1.2", "qrcode": "1.5.4", "react": "19.2.4", "react-compiler-runtime": "1.0.0", "react-reconciler": "0.33.0", - "semver": "7.7.4", - "sharp": "^0.34.5", - "shell-quote": "1.8.4", + "semver": "7.8.5", + "sharp": "0.35.5", + "shell-quote": "1.12.0", "signal-exit": "4.1.0", - "supports-hyperlinks": "3.2.0", + "supports-hyperlinks": "4.6.0", "tree-kill": "1.2.2", "tree-sitter-wasms": "0.1.13", - "turndown": "7.2.2", - "type-fest": "4.41.0", - "typescript": "5.9.3", - "undici": "7.28.0", + "turndown": "7.2.4", + "type-fest": "5.10.0", + "typescript": "6.0.3", + "undici": "8.12.3", "usehooks-ts": "3.1.1", - "vscode-languageserver-protocol": "3.17.5", + "vscode-languageserver-protocol": "3.18.5", "web-tree-sitter": "0.25.10", - "wrap-ansi": "9.0.2", - "ws": "8.21.0", - "xss": "1.0.15", - "yaml": "2.8.3", - "zod": "3.25.76" + "wrap-ansi": "10.0.2", + "ws": "8.22.0", + "xss": "1.0.16", + "yaml": "2.9.1", + "zod": "4.6.7" }, "peerDependencies": { - "@anthropic-ai/sdk": "^0.94.0", - "@modelcontextprotocol/sdk": "^1.29.0", + "@anthropic-ai/sdk": "^0.137.0", + "@modelcontextprotocol/sdk": "^1.32.1", "react": "^19.0.0", "react-reconciler": "^0.33.0" }, @@ -188,7 +192,7 @@ } }, "engines": { - "node": ">=22.0.0" + "node": ">=24.22.1" }, "repository": { "type": "git", @@ -209,13 +213,13 @@ "access": "public" }, "overrides": { - "ip-address": "10.2.0", - "google-auth-library": "10.6.2", + "ip-address": "10.7.4", + "google-auth-library": "11.3.1", "lodash-es": "4.18.1", - "node-domexception": "file:vendor/node-domexception-shim" + "node-domexception": "file:vendor/node-domexception-shim", + "zod": "4.6.7" }, "allowScripts": { - "protobufjs@7.6.4": true, - "sharp@0.34.5": true + "protobufjs@7.6.4": true } } diff --git a/scripts/benchmark-openclaude-startup.mjs b/scripts/benchmark-openclaude-startup.mjs index e6ac2d05b0..a1ea9357d9 100644 --- a/scripts/benchmark-openclaude-startup.mjs +++ b/scripts/benchmark-openclaude-startup.mjs @@ -34,7 +34,7 @@ if (warmRuns < MIN_WARM_RUNS) { throw new Error(`--warm-runs must be at least ${MIN_WARM_RUNS}`) } if (typeof nodeModule.enableCompileCache !== 'function') { - throw new Error(`Node ${process.version} does not expose module.enableCompileCache; the benchmark requires Node >=22.8.0`) + throw new Error(`Node ${process.version} does not expose module.enableCompileCache; the benchmark requires Node >=24.22.1`) } if (!existsSync(BUNDLE_PATH) || !statSync(BUNDLE_PATH).isFile()) { throw new Error('dist/cli.mjs is missing; run `bun run build` first') diff --git a/scripts/check-node-options-parity.ts b/scripts/check-node-options-parity.ts new file mode 100644 index 0000000000..d131da6523 --- /dev/null +++ b/scripts/check-node-options-parity.ts @@ -0,0 +1,310 @@ +#!/usr/bin/env bun +/** + * Parity check for `hasNodeOption()` against the Node runtime. + * + * `hasNodeOption()` models scoped NODE_OPTIONS syntax and flag state. + * This script compares that answer with the Node runtime + * itself instead of from the implementation's own assumptions: it runs a + * matrix of NODE_OPTIONS values through `node` and compares the observed + * CA-store selection with the helper's result. + * + * Usage: + * bun run scripts/check-node-options-parity.ts [--node ] + * + * The CA store is observed through `tls.getCACertificates('default')`: with + * `--use-openssl-ca` the default store is empty, without it the bundled roots + * are returned. A stream Node rejects leaves no option active, so the helper + * must fail closed and report the flag as disabled. + * + * `tls.getCACertificates()` and `--use-system-ca` both landed in Node + * v22.15.0, so that is the minimum runtime this script can measure against; + * it exits with an error on anything older. + * + * Known divergence: Node rejects options it does not know ("--bogus is not + * allowed in NODE_OPTIONS"). This helper models a curated set of options and + * treats every other option as a boolean, so a stream containing an unknown + * option is reported as accepted. Validating unknown or disallowed options + * is out of scope for the helper. + */ +import { spawnSync } from 'child_process' +import { hasNodeOption } from '../src/utils/envUtils.js' + +const args = process.argv.slice(2) + +/** + * Read an operand for an option. An absent option returns undefined; a + * present option without a usable operand is a usage error, so it must not + * silently fall back to a default. + */ +function argValue(name: string): string | undefined { + const index = args.indexOf(name) + if (index === -1) return undefined + const value = args[index + 1] + if (value === undefined || value.startsWith('-')) { + console.error(`${name} requires a value`) + process.exit(2) + } + return value +} + +// The Node probe and the matrix both observe `--use-openssl-ca`, so the flag +// is fixed: making it configurable would compare the runtime's answer for one +// flag against the helper's answer for another. +const flag = '--use-openssl-ca' + +// The binary is passed as argv[0] with an argument array, never through a +// shell, so the path cannot be interpreted as a command. +const nodePath = argValue('--node') ?? process.env.NODE_BIN ?? 'node' + +const nodeVersion = spawnSync(nodePath, ['--version'], { encoding: 'utf8' }) +if (nodeVersion.status !== 0) { + console.error(`Cannot run ${nodePath}. Pass --node to a Node binary.`) + process.exit(2) +} +const version = (nodeVersion.stdout ?? '').trim() +if (Number(version.match(/^v(\d+)/)?.[1] ?? 0) < 24) { + console.warn( + `Warning: ${nodePath} is ${version}; OpenClaude requires Node >= 24.22.1.`, + ) +} + +// The observation method needs `tls.getCACertificates()`, which landed in +// Node v22.15.0 together with `--use-system-ca`. Without it every probe +// throws and the run would check nothing while still exiting successfully. +const probeSupport = spawnSync( + nodePath, + ['-p', "typeof require('tls').getCACertificates"], + { encoding: 'utf8' }, +) +if ((probeSupport.stdout ?? '').trim() !== 'function') { + console.error( + `${nodePath} (${version}) has no tls.getCACertificates(); the parity check needs Node >= 22.15.`, + ) + process.exit(2) +} + +type NodeOutcome = + | 'rejected' + | 'enabled' + | 'disabled' + | 'timeout' + | 'runtime-error' + +/** + * Node rejects the NODE_OPTIONS *stream* for these diagnostics. Other + * failures (a missing --loader module, an invalid enum value) happen after + * the options were accepted, so they say nothing about the parser contract. + */ +const PARSE_ERROR_PATTERNS = [ + 'requires an argument', + 'is not allowed in NODE_OPTIONS', + 'invalid value for NODE_OPTIONS', + 'bad option', + 'illegal value for flag', + 'out of bounds', + 'not allowed', +] + +/** Ask Node what the CA flag would be for a given NODE_OPTIONS value. */ +function nodeOutcome(nodeOptions: string): NodeOutcome { + // NODE_EXTRA_CA_CERTS and NODE_USE_SYSTEM_CA add certificates to the + // 'default' store, which would make the "empty store" signal unusable. + const env = { ...process.env, NODE_OPTIONS: nodeOptions } + delete env.NODE_EXTRA_CA_CERTS + delete env.NODE_USE_SYSTEM_CA + + const result = spawnSync( + nodePath, + [ + '-p', + "require('tls').getCACertificates('default').length === 0 ? 'enabled' : 'disabled'", + ], + { + env, + timeout: 10_000, + encoding: 'utf8', + }, + ) + if (result.error && (result.error as { code?: string }).code === 'ETIMEDOUT') + return 'timeout' + if (result.status !== 0) { + const stderr = result.stderr ?? '' + return PARSE_ERROR_PATTERNS.some((pattern) => stderr.includes(pattern)) + ? 'rejected' + : 'runtime-error' + } + const stdout = (result.stdout ?? '').trim() + if (stdout === 'enabled') return 'enabled' + if (stdout === 'disabled') return 'disabled' + return 'rejected' +} + +/** What the helper reports for the same value. */ +function helperOutcome(nodeOptions: string, target: string): NodeOutcome { + const previous = process.env.NODE_OPTIONS + process.env.NODE_OPTIONS = nodeOptions + try { + return hasNodeOption(target) ? 'enabled' : 'disabled' + } finally { + if (previous === undefined) delete process.env.NODE_OPTIONS + else process.env.NODE_OPTIONS = previous + } +} + +const CA_FORMS = [ + '--use-openssl-ca', + '--use-openssl-ca=1', + '--use-openssl-ca=0', + '--no-use-openssl-ca', + '--use-openssl-ca --no-use-openssl-ca', + '--no-use-openssl-ca --use-openssl-ca', + '--use-openssl-ca=1 --no-use-openssl-ca=1', + '--no-use-openssl-ca=1 --use-openssl-ca=1', +] + +const CONSTRUCTS = [ + '--title', + '--title=', + '--title=foo', + '--title --use-openssl-ca', + '--title=--use-openssl-ca', + '--conditions', + '--conditions=', + '--conditions=foo', + '--conditions foo', + '--conditions -x', + '--test-isolation none', + '--test-isolation=', + '--test-isolation', + '--test-global-setup ./setup.js', + '--test-global-setup=', + '--test-global-setup', + '--test-rerun-failures ./failures.json', + '--test-rerun-failures=', + '--test-rerun-failures', + '-C foo', + '-C', + '--loader', + '--loader=', + '--loader=foo', + '--inspect-port', + '--inspect-port=', + '--inspect-port=9229', + '--debug-port', + '--debug-port=', + '--debug-port=9229', + '--inspect=', + '--inspect=9229', + '--inspect-brk=', + '--inspect-brk=9229', + '--inspect-wait=', + '--inspect-wait=9229', + '--inspect-brk-node=', + '--inspect-publish-uid', + '--inspect-publish-uid=stderr', + '--max-old-space-size', + '--max-old-space-size=', + '--max-old-space-size=4096', + '--max-old-space-size 4096', + '--max-semi-space-size', + '--max-semi-space-size=', + '--max-semi-space-size=16', + '--max-semi-space-size 16', + '--max-old-space-size-percentage', + '--max-old-space-size-percentage=', + '--max-old-space-size-percentage=50', + '--max-old-space-size-percentage 50', + '--stack-trace-limit', + '--stack-trace-limit=', + '--stack-trace-limit=10', + '--stack-trace-limit 10', + '--experimental-import-meta-resolve', + 'foo', + '-', + '--', + '"foo --use-openssl-ca bar"', + '"--use-openssl-ca"', + "'--use-openssl-ca'", + '--use-openssl-ca\t', + '\t--use-openssl-ca', + '--title="foo --use-openssl-ca bar"', + '--conditions="foo --use-openssl-ca bar"', + '--conditions "foo \\" --use-openssl-ca bar"', + '--conditions=--use-openssl-ca', + '--title openclaude', + '--experimental-vm-modules', + '--enable-source-maps', + '--enable-source-maps=', + '--expose-gc', + '--no-expose-gc', + '--throw-deprecation', + '--trace-warnings', + '--no-warnings', + '--max-http-header-size=16384', + '--dns-result-order=verbatim', + '--dns-result-order=bogus', + '--unhandled-rejections=strict', + '--unhandled-rejections=bogus', + '--report-directory=out', +] + +const matrix = new Set(CA_FORMS) +for (const construct of CONSTRUCTS) { + matrix.add(`${construct} --use-openssl-ca`) + matrix.add(`--use-openssl-ca ${construct}`) + matrix.add(`--use-openssl-ca=1 ${construct}`) + matrix.add(`${construct} --use-openssl-ca=1`) +} + +let failures = 0 +let checked = 0 +let runtimeErrors = 0 +let timeouts = 0 +for (const nodeOptions of matrix) { + const expected = nodeOutcome(nodeOptions) + if (expected === 'timeout') { + // Node started and is waiting for a debugger (`--inspect-brk`, + // `--inspect-wait`), so the option stream was accepted but the CA state + // cannot be observed. + timeouts++ + continue + } + if (expected === 'runtime-error') { + runtimeErrors++ + continue + } + checked++ + // A stream Node rejects leaves no option active, so the helper must fail + // closed and report the flag as disabled. + const expectedState = expected === 'rejected' ? 'disabled' : expected + const actual = helperOutcome(nodeOptions, flag) + if (expectedState !== actual) { + failures++ + console.log( + `MISMATCH node=${expected.padEnd(9)} helper=${actual.padEnd(9)} ${JSON.stringify(nodeOptions)}`, + ) + } + + // The sibling CA flag shares the parser, so a rejected stream must make it + // fail closed as well. + if (expected === 'rejected') { + const sibling = '--use-system-ca' + if (helperOutcome(nodeOptions, sibling) !== 'disabled') { + failures++ + console.log( + `MISMATCH sibling ${sibling} did not fail closed for ${JSON.stringify(nodeOptions)}`, + ) + } + } +} + +console.log( + `\nchecked=${checked} mismatches=${failures} runtime-errors=${runtimeErrors} timeouts=${timeouts} (node=${version}, flag=${flag})`, +) + +// A run that checked nothing must not report success. +if (checked === 0) { + console.error('No case was checked; the parity result is meaningless.') + process.exit(1) +} +process.exit(failures === 0 ? 0 : 1) diff --git a/scripts/externals.ts b/scripts/externals.ts index 75309749da..bd398d2792 100644 --- a/scripts/externals.ts +++ b/scripts/externals.ts @@ -141,7 +141,7 @@ export const INTENTIONALLY_BUNDLED: string[] = [ 'bidi-js', 'chalk', 'cli-boxes', - 'cli-highlight', + 'highlight.js', 'commander', 'emoji-regex', 'env-paths', diff --git a/scripts/externalsValidation.test.ts b/scripts/externalsValidation.test.ts index 685d8f2ab4..70eb136243 100644 --- a/scripts/externalsValidation.test.ts +++ b/scripts/externalsValidation.test.ts @@ -304,7 +304,7 @@ describe('validateRuntimeDependencyContract', () => { }) describe('validateInstallHygieneFields', () => { - const CLEAN = { engines: { node: '>=22.0.0' } } + const CLEAN = { engines: { node: '>=24.22.1' } } test('passes for a clean manifest', () => { expect(validateInstallHygieneFields(CLEAN).ok).toBe(true) diff --git a/scripts/externalsValidation.ts b/scripts/externalsValidation.ts index 6a969704a1..0ebc0fa794 100644 --- a/scripts/externalsValidation.ts +++ b/scripts/externalsValidation.ts @@ -40,7 +40,7 @@ export const RUNTIME_DEPENDENCY_CONTRACT: Readonly> = { } /** Node range advertised to installers; changing it changes who gets EBADENGINE. */ -export const ENGINES_NODE_CONTRACT = '>=22.0.0' +export const ENGINES_NODE_CONTRACT = '>=24.22.1' const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/ diff --git a/scripts/openclaude-bin-compile-cache.test.ts b/scripts/openclaude-bin-compile-cache.test.ts index 0588290071..620fb0b502 100644 --- a/scripts/openclaude-bin-compile-cache.test.ts +++ b/scripts/openclaude-bin-compile-cache.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { captureSync } from './subprocess.ts' import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -43,9 +43,8 @@ function runLauncher( env: NodeJS.ProcessEnv, nodeArgs: string[] = [], ): LauncherResult { - const result = spawnSync('node', [...nodeArgs, BIN_PATH, '--version'], { + const result = captureSync('node', [...nodeArgs, BIN_PATH, '--version'], { cwd: REPO_ROOT, - encoding: 'utf8', env, timeout: 30_000, }) @@ -63,7 +62,7 @@ function expectNormalVersion(result: LauncherResult): void { } function nodeSupportsCompileCache(): boolean { - return spawnSync( + return captureSync( 'node', ['-e', "process.exit(typeof require('node:module').enableCompileCache === 'function' ? 0 : 1)"], ).status === 0 diff --git a/scripts/openclaude-bin-heap.test.ts b/scripts/openclaude-bin-heap.test.ts index 51a0084553..9f2b5cacf3 100644 --- a/scripts/openclaude-bin-heap.test.ts +++ b/scripts/openclaude-bin-heap.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { captureSync } from './subprocess.ts' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, test } from 'bun:test' @@ -24,7 +24,7 @@ const HEAP_LIMIT_PATH = join(import.meta.dir, '..', 'bin', 'heap-limit.mjs') const FOUR_GIB = 4 * 1024 * 1024 * 1024 function expectSuccessfulLauncherSpawn( - result: ReturnType, + result: ReturnType, ): void { expect(result.error).toBeUndefined() expect(result.status).toBe(0) @@ -41,7 +41,16 @@ describe('openclaude launcher heap guard', () => { expect(source).toContain("resolved.source === 'percentage-unavailable'") expect(source).toContain('--expose-gc') expect(source).toContain('spawnSync(process.execPath') - expect(source).toContain("from './heap-limit.mjs'") + // The launcher must not statically import its bin/*.mjs siblings: distro + // layouts that copy only the launcher file (issue #2255) would otherwise + // crash with ERR_MODULE_NOT_FOUND before any code runs. Siblings load + // dynamically with inline fallbacks instead. + expect(source).not.toContain("from './heap-limit.mjs'") + expect(source).not.toContain("from './node-compile-cache.mjs'") + expect(source).toContain("'./heap-limit.mjs'") + expect(source).toContain("'./node-compile-cache.mjs'") + expect(source).toContain('fallbackResolveHeapSizeMb') + expect(source).toContain('fallbackEnableNodeCompileCacheIfAvailable') const importingBranch = source.slice(source.indexOf('if (existsSync(distPath))')) const relaunchIndex = importingBranch.indexOf('relaunchWithLongSessionHeapIfNeeded()') const compileCacheIndex = importingBranch.indexOf('enableNodeCompileCacheIfAvailable()') @@ -72,6 +81,7 @@ describe('openclaude launcher heap guard', () => { expect(source).not.toMatch(/import\s*\{[^}]*enableCompileCache[^}]*\}\s*from\s*['"]node:module['"]/s) }) + // Real CLI startup has an 8s child deadline; allow time to assert its result. test('strips launcher-only percentage before Commander when native heap and expose-gc are present', () => { const env = { ...process.env, @@ -79,7 +89,7 @@ describe('openclaude launcher heap guard', () => { } delete env.OPENCLAUDE_HEAP_RELAUNCHED delete env.OPENCLAUDE_DISABLE_HEAP_RELAUNCH - const result = spawnSync( + const result = captureSync( process.execPath, [ '--expose-gc', @@ -88,7 +98,6 @@ describe('openclaude launcher heap guard', () => { '--version', ], { - encoding: 'utf8', timeout: 8000, env, }, @@ -98,19 +107,19 @@ describe('openclaude launcher heap guard', () => { expect(output).not.toContain( "unknown option '--max-old-space-size-percentage=50'", ) - }) + }, 10_000) + // Real CLI startup has an 8s child deadline; allow time to assert its result. test('strips launcher-only percentage before Commander when heap relaunch is disabled', () => { const env = { ...process.env, OPENCLAUDE_DISABLE_HEAP_RELAUNCH: '1', } delete env.OPENCLAUDE_HEAP_RELAUNCHED - const result = spawnSync( + const result = captureSync( process.execPath, [BIN_PATH, '--max-old-space-size-percentage=50', '--version'], { - encoding: 'utf8', timeout: 8000, env, }, @@ -120,19 +129,19 @@ describe('openclaude launcher heap guard', () => { expect(output).not.toContain( "unknown option '--max-old-space-size-percentage=50'", ) - }) + }, 10_000) + // Real CLI startup has an 8s child deadline; allow time to assert its result. test('strips launcher-only percentage when OPENCLAUDE_HEAP_RELAUNCHED is already set', () => { const env = { ...process.env, OPENCLAUDE_HEAP_RELAUNCHED: '1', } delete env.OPENCLAUDE_DISABLE_HEAP_RELAUNCH - const result = spawnSync( + const result = captureSync( process.execPath, [BIN_PATH, '--max-old-space-size-percentage=50', '--version'], { - encoding: 'utf8', timeout: 8000, env, }, @@ -142,7 +151,7 @@ describe('openclaude launcher heap guard', () => { expect(output).not.toContain( "unknown option '--max-old-space-size-percentage=50'", ) - }) + }, 10_000) }) describe('heap-limit percentage resolution', () => { diff --git a/scripts/openclaude-bin-missing-helpers.test.ts b/scripts/openclaude-bin-missing-helpers.test.ts new file mode 100644 index 0000000000..c6155179b1 --- /dev/null +++ b/scripts/openclaude-bin-missing-helpers.test.ts @@ -0,0 +1,321 @@ +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' + +const REPO_ROOT = join(import.meta.dir, '..') +const BIN_PATH = join(REPO_ROOT, 'bin', 'openclaude') +const PACKAGE_VERSION = JSON.parse( + readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'), +).version +const EXPECTED_VERSION_OUTPUT = `${PACKAGE_VERSION} (OpenClaude)\n` + +type LauncherResult = { + status: number | null + stdout: string + stderr: string +} + +// Simulates distro layouts (e.g. Arch AUR /usr/lib/openclaude) that install +// only the launcher file without its bin/*.mjs siblings (issue #2255). The +// launcher must still boot via its inline fallbacks instead of crashing with +// ERR_MODULE_NOT_FOUND before any code runs. +function assertBuiltCli(): void { + const builtCli = join(REPO_ROOT, 'dist', 'cli.mjs') + if (!existsSync(builtCli)) { + throw new Error( + `dist/cli.mjs not found at ${builtCli} — run \`bun run build\` before these tests.`, + ) + } +} + +function makeSiblinglessLayout(): string { + assertBuiltCli() + const root = mkdtempSync(join(tmpdir(), 'openclaude-siblingless-')) + const binDir = join(root, 'bin') + mkdirSync(binDir, { recursive: true }) + copyFileSync(BIN_PATH, join(binDir, 'openclaude')) + // 'junction' is ignored on POSIX and lets Windows create directory links + // without elevated privileges. + symlinkSync(join(REPO_ROOT, 'dist'), join(root, 'dist'), 'junction') + symlinkSync( + join(REPO_ROOT, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ) + return root +} + +function runLauncher( + root: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): LauncherResult { + const env: NodeJS.ProcessEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + OPENCLAUDE_CONFIG_DIR: join(root, 'config'), + } + // Heap-related env must not leak in from the outer environment: the parity + // cases assert exact resolved values, and an inherited override (or a + // relaunch toggle) would silently change what the launcher resolves. Each + // case's env is applied after this cleanup, so explicit overrides still win. + for (const key of [ + 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB', + 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE', + 'OPENCLAUDE_MAX_MEMORY_MB', + 'OPENCLAUDE_HEAP_RELAUNCHED', + 'OPENCLAUDE_DISABLE_HEAP_RELAUNCH', + ]) { + delete env[key] + } + if (!Object.hasOwn(extraEnv, 'NODE_OPTIONS')) delete env.NODE_OPTIONS + Object.assign(env, extraEnv) + const result = spawnSync('node', [join(root, 'bin', 'openclaude'), ...args], { + cwd: root, + encoding: 'utf8', + env, + timeout: 30_000, + }) + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + } +} + +function runSiblingless( + root: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): LauncherResult { + return runLauncher(root, args, extraEnv) +} + +function withSiblinglessLayout(fn: (root: string) => void): void { + const root = makeSiblinglessLayout() + try { + fn(root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +// The inline fallbacks in bin/openclaude duplicate bin/heap-limit.mjs, so the +// same inputs must resolve to the same heap through both paths. The stub +// bundle below reports what the launcher resolved instead of starting the CLI, +// letting a full-sibling layout and a siblingless layout be compared +// observably without changing resolver behavior. +const HEAP_OBSERVER_STUB = `console.log(JSON.stringify({ heapMb: process.env.OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB ?? null, maxMemoryMb: process.env.OPENCLAUDE_MAX_MEMORY_MB ?? null, heapFlag: process.execArgv.find(a => a.startsWith('--max-old-space-size=')) ?? null }))\n` + +function makeHeapObserverLayout(withSiblings: boolean): string { + assertBuiltCli() + const tag = withSiblings ? 'full' : 'siblingless' + const root = mkdtempSync(join(tmpdir(), `openclaude-heap-parity-${tag}-`)) + const binDir = join(root, 'bin') + const distDir = join(root, 'dist') + mkdirSync(binDir, { recursive: true }) + mkdirSync(distDir, { recursive: true }) + copyFileSync(BIN_PATH, join(binDir, 'openclaude')) + if (withSiblings) { + for (const helper of ['heap-limit.mjs', 'node-compile-cache.mjs']) { + copyFileSync(join(REPO_ROOT, 'bin', helper), join(binDir, helper)) + } + } + writeFileSync(join(distDir, 'cli.mjs'), HEAP_OBSERVER_STUB) + return root +} + +type HeapParityCase = { + name: string + args: string[] + env: NodeJS.ProcessEnv + expectedMb: string | null + expectedMaxMemoryMb?: string +} + +const HEAP_PARITY_CASES: HeapParityCase[] = [ + { name: 'default heap', args: [], env: {}, expectedMb: '8192' }, + { + name: 'env MB override', + args: [], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' }, + expectedMb: '4096', + }, + { + name: '--max-memory wins over percentage', + args: ['--max-memory=1536'], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '90' }, + expectedMb: '1536', + expectedMaxMemoryMb: '1536', + }, + // Percentage outcomes depend on host RAM, so these cases assert fallback / + // canonical equality rather than an exact value. + { + name: 'argv percentage', + args: ['--max-old-space-size-percentage=50'], + env: {}, + expectedMb: null, + }, + { + name: 'spaced argv percentage', + args: ['--max-old-space-size-percentage', '25'], + env: {}, + expectedMb: null, + }, + { + name: 'env percentage', + args: [], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '50' }, + expectedMb: null, + }, + { + name: 'invalid percentage falls back to env MB', + args: ['--max-old-space-size-percentage=0'], + env: { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' }, + expectedMb: '4096', + }, +] + +describe('openclaude launcher without bin/*.mjs siblings', () => { + test('boots --version with silent stderr', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, ['--version']) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(result.stderr).toBe('') + expect(`${result.stdout}${result.stderr}`).not.toContain( + 'ERR_MODULE_NOT_FOUND', + ) + }) + }) + + test('boots --help with silent stderr', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, ['--help']) + expect(result.status).toBe(0) + expect(result.stdout).toMatch(/usage/i) + expect(result.stderr).toBe('') + }) + }) + + test('strips launcher-only percentage flags without siblings', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, [ + '--max-old-space-size-percentage=50', + '--version', + ]) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(`${result.stdout}${result.stderr}`).not.toContain( + "unknown option '--max-old-space-size-percentage=50'", + ) + }) + }) + + test('strips launcher-only flags without siblings when relaunch is disabled', () => { + withSiblinglessLayout(root => { + const result = runSiblingless( + root, + ['--max-old-space-size-percentage=50', '--version'], + { OPENCLAUDE_DISABLE_HEAP_RELAUNCH: '1' }, + ) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(`${result.stdout}${result.stderr}`).not.toContain( + "unknown option '--max-old-space-size-percentage=50'", + ) + }) + }) + + test('accepts --max-memory without siblings', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, ['--max-memory=1024', '--version']) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(result.stderr).toBe('') + }) + }) + + test('honors heap env overrides without siblings', () => { + withSiblinglessLayout(root => { + const mbResult = runSiblingless( + root, + ['--version'], + { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096' }, + ) + expect(mbResult.status).toBe(0) + expect(mbResult.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(mbResult.stderr).toBe('') + + const percentageResult = runSiblingless( + root, + ['--version'], + { OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE: '50' }, + ) + expect(percentageResult.status).toBe(0) + expect(percentageResult.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(percentageResult.stderr).toBe('') + }) + }) + + test('strips spaced launcher-only percentage flags without siblings', () => { + withSiblinglessLayout(root => { + const result = runSiblingless(root, [ + '--max-old-space-size-percentage', + '50', + '--version', + ]) + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(`${result.stdout}${result.stderr}`).not.toContain('unknown option') + }) + }) +}) + +describe('openclaude launcher heap fallback/canonical parity', () => { + for (const parityCase of HEAP_PARITY_CASES) { + test(`resolves the same heap with and without siblings: ${parityCase.name}`, () => { + const full = makeHeapObserverLayout(true) + const siblingless = makeHeapObserverLayout(false) + try { + const canonical = runLauncher(full, parityCase.args, parityCase.env) + const fallback = runLauncher( + siblingless, + parityCase.args, + parityCase.env, + ) + expect(canonical.status).toBe(0) + expect(fallback.status).toBe(0) + expect(fallback.stdout).toBe(canonical.stdout) + expect(fallback.stderr).toBe('') + if (parityCase.expectedMb !== null) { + const observed = JSON.parse(canonical.stdout) + expect(observed.heapMb).toBe(parityCase.expectedMb) + expect(observed.heapFlag).toBe( + `--max-old-space-size=${parityCase.expectedMb}`, + ) + } + if (parityCase.expectedMaxMemoryMb !== undefined) { + expect(JSON.parse(canonical.stdout).maxMemoryMb).toBe( + parityCase.expectedMaxMemoryMb, + ) + } + } finally { + rmSync(full, { recursive: true, force: true }) + rmSync(siblingless, { recursive: true, force: true }) + } + }) + } +}) diff --git a/scripts/pr-intent-scan.test.ts b/scripts/pr-intent-scan.test.ts index d50528eeb4..becd3a0e4e 100644 --- a/scripts/pr-intent-scan.test.ts +++ b/scripts/pr-intent-scan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { spawnSync } from 'node:child_process' +import { captureSync } from './subprocess.ts' import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,9 +16,8 @@ function line(content: string, overrides: Partial = {}): DiffLine { } function git(cwd: string, args: string[]): string { - const result = spawnSync('git', args, { + const result = captureSync('git', args, { cwd, - encoding: 'utf8', }) if (result.status !== 0) { throw new Error( diff --git a/scripts/pr-intent-scan.ts b/scripts/pr-intent-scan.ts index 7d8e33313a..8e491edcab 100644 --- a/scripts/pr-intent-scan.ts +++ b/scripts/pr-intent-scan.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { captureSync } from './subprocess.ts' export type FindingSeverity = 'high' | 'medium' @@ -378,9 +378,7 @@ export function scanAddedLines(lines: DiffLine[]): Finding[] { } export function getGitDiff(baseRef: string, headRef = 'HEAD'): string { - const mergeBase = spawnSync('git', ['merge-base', baseRef, headRef], { - encoding: 'utf8', - }) + const mergeBase = captureSync('git', ['merge-base', baseRef, headRef]) if (mergeBase.status !== 0) { throw new Error( @@ -389,10 +387,9 @@ export function getGitDiff(baseRef: string, headRef = 'HEAD'): string { } const base = mergeBase.stdout.trim() - const diff = spawnSync( + const diff = captureSync( 'git', ['diff', '--unified=0', '--no-ext-diff', `${base}...${headRef}`], - { encoding: 'utf8' }, ) if (diff.status !== 0) { diff --git a/scripts/provider-launch.test.ts b/scripts/provider-launch.test.ts index ffc1a1b9cf..67dd12db7d 100644 --- a/scripts/provider-launch.test.ts +++ b/scripts/provider-launch.test.ts @@ -1,5 +1,7 @@ +import { captureSync } from "./subprocess.ts" import { describe, expect, test } from 'bun:test' import { + runProcess, getOpenAILaunchCredentialError, hasUsableOpenAILaunchCredential, } from './provider-launch.ts' @@ -92,3 +94,32 @@ describe('provider-launch OpenAI credential validation', () => { ) }) }) + +describe('development process launches', () => { + test('propagates nonzero exits and launch failures', async () => { + expect(await runProcess(process.execPath, ['-e', 'process.exit(7)'], process.env)).toBe(7) + expect(await runProcess('missing-development-command-l2', [], process.env)).toBe(1) + }) + + test('passes arguments, environment, and cwd literally', async () => { + const literal = 'space ; $value & "quoted"' + const code = await runProcess(process.execPath, ['-e', ` + process.exit(process.argv[1] === process.env.LAUNCH_LITERAL && process.cwd() === process.env.LAUNCH_CWD ? 0 : 9) + `, literal], { ...process.env, LAUNCH_LITERAL: literal, LAUNCH_CWD: process.cwd() }) + expect(code).toBe(0) + }) + + test.skipIf(process.platform === 'win32')('maps a signaled child to the existing failure status', async () => { + expect(await runProcess(process.execPath, ['-e', "process.kill(process.pid, 'SIGTERM')"], process.env)).toBe(1) + }) + + test('inherits both output streams', () => { + const result = captureSync(process.execPath, ['-e', ` + const { runProcess } = await import(${JSON.stringify(import.meta.dir + '/provider-launch.ts')}) + process.exit(await runProcess(process.execPath, ['-e', "process.stdout.write('output'); process.stderr.write('diagnostic'); process.exit(7)"], process.env)) + `], { timeout: 3000 }) + expect(result.status).toBe(7) + expect(result.stdout).toBe('output') + expect(result.stderr).toBe('diagnostic') + }, 5000) +}) diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 4cc538a842..466e9ca64d 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -1,5 +1,4 @@ // @ts-nocheck -import { spawn } from 'node:child_process' import { resolveCodexApiCredentials, } from '../src/services/api/providerConfig.js' @@ -102,17 +101,20 @@ function runCommand(command: string, env: NodeJS.ProcessEnv): Promise { return runProcess(command, [], env) } -function runProcess(command: string, args: string[], env: NodeJS.ProcessEnv): Promise { - return new Promise(resolve => { - const child = spawn(command, args, { +export async function runProcess(command: string, args: string[], env: NodeJS.ProcessEnv): Promise { + try { + const child = Bun.spawn([command, ...args], { cwd: process.cwd(), env, - stdio: 'inherit', + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', }) - - child.on('close', code => resolve(code ?? 1)) - child.on('error', () => resolve(1)) - }) + await child.exited + return child.signalCode ? 1 : child.exitCode ?? 1 + } catch { + return 1 + } } function applyFastFlags(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { diff --git a/scripts/provider-recommend.test.ts b/scripts/provider-recommend.test.ts index b42f49018e..1251eab3f5 100644 --- a/scripts/provider-recommend.test.ts +++ b/scripts/provider-recommend.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, test } from 'bun:test' -import { getOpenAIConfigurationState } from './provider-recommend.ts' +import { describe, expect, spyOn, test } from 'bun:test' +import { snapshotEnv } from '../src/test/envSnapshot.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../src/test/sharedMutationLock.js' +import { getOpenAIConfigurationState, main } from './provider-recommend.ts' function envWithOpenAI( patch: Record, @@ -13,10 +18,6 @@ function envWithOpenAI( return env } -async function readStream(stream: ReadableStream): Promise { - return await new Response(stream).text() -} - describe('provider-recommend OpenAI credential detection', () => { test('detects valid pooled OpenAI credentials', () => { expect( @@ -61,36 +62,45 @@ describe('provider-recommend OpenAI credential detection', () => { }) test('profile:recommend openai path runs with pooled credentials', async () => { - const env = envWithOpenAI({ - OPENAI_API_KEYS: 'key-a,key-b', - OPENAI_API_KEY: undefined, - NO_COLOR: '1', + await acquireSharedMutationLock('scripts/provider-recommend.test.ts') + const restoreEnv = snapshotEnv(['OPENAI_API_KEYS', 'OPENAI_API_KEY', 'NO_COLOR']) + const originalArgv = process.argv + const stdout = spyOn(console, 'log').mockImplementation(() => {}) + const stderr = spyOn(console, 'error').mockImplementation(() => {}) + const exit = spyOn(process, 'exit').mockImplementation(code => { + throw new Error(`Unexpected process.exit(${code})`) + }) + const fetch = spyOn(globalThis, 'fetch').mockImplementation(() => { + throw new Error('Explicit OpenAI recommendations must not use discovery') }) - const proc = Bun.spawn( - [ + + try { + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + process.env.NO_COLOR = '1' + process.argv = [ process.execPath, - 'run', 'scripts/provider-recommend.ts', '--provider', 'openai', '--json', - ], - { - cwd: process.cwd(), - env, - stdout: 'pipe', - stderr: 'pipe', - }, - ) - - const [stdout, stderr, exitCode] = await Promise.all([ - readStream(proc.stdout), - readStream(proc.stderr), - proc.exited, - ]) + ] - expect(stderr).toBe('') - expect(exitCode).toBe(0) - expect(JSON.parse(stdout).openAIConfigured).toBe(true) + // Exercise the real CLI path without racing child startup and cold imports. + await expect(main()).resolves.toBeUndefined() + expect(stderr).not.toHaveBeenCalled() + expect(exit).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + expect(stdout).toHaveBeenCalledTimes(1) + expect(JSON.parse(stdout.mock.calls[0]![0]).openAIConfigured).toBe(true) + } finally { + fetch.mockRestore() + exit.mockRestore() + stderr.mockRestore() + stdout.mockRestore() + process.argv = originalArgv + restoreEnv() + releaseSharedMutationLock() + } }) -}) \ No newline at end of file +}) diff --git a/scripts/provider-recommend.ts b/scripts/provider-recommend.ts index 5cb3dbf7ff..3b2e590816 100644 --- a/scripts/provider-recommend.ts +++ b/scripts/provider-recommend.ts @@ -162,7 +162,7 @@ async function maybeApplyProfile( return true } -async function main(): Promise { +export async function main(): Promise { const options = parseOptions(process.argv.slice(2)) const ollamaAvailable = options.provider !== 'openai' && diff --git a/scripts/run-tests.test.ts b/scripts/run-tests.test.ts index fd6e2b33da..c2e2521409 100644 --- a/scripts/run-tests.test.ts +++ b/scripts/run-tests.test.ts @@ -112,6 +112,102 @@ for (const shards of [1, 2]) { } } +test('selected shards partition the same files as the serial run exactly once', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-runner-selection-')) + try { + const marker = join(dir, 'completed') + const fixtures = Array.from({ length: 8 }, (_, index) => join(dir, `fixture-${index + 1}.test.ts`)) + for (const [index, fixture] of fixtures.entries()) { + await writeFile(fixture, ` + import { test } from 'bun:test' + import { appendFileSync } from 'node:fs' + test('fixture ${index + 1}', () => appendFileSync(process.env.OC_TEST_MARKER!, '${index + 1}\\n')) + `) + } + const run = async (shardIndex?: number) => { + await writeFile(marker, '') + const child = Bun.spawn([ + process.execPath, runner, '--shards=8', + ...(shardIndex === undefined ? [] : [`--shard-index=${shardIndex}`]), + ...fixtures, + ], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, OC_TEST_MARKER: marker } }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]) + expect(exitCode, stdout + stderr).toBe(0) + expect(stderr).toContain(`Completed ${shardIndex === undefined ? '8/8' : '1/1'} test shards.`) + const executed = (await readFile(marker, 'utf8')).trim().split('\n').sort() + return { executed, stderr } + } + const serial = await run() + expect(serial.executed).toEqual(['1', '2', '3', '4', '5', '6', '7', '8']) + const selected: string[] = [] + for (let shardIndex = 1; shardIndex <= 8; shardIndex++) { + const result = await run(shardIndex) + expect(result.stderr.match(/Starting test shard \d+\/8/g)).toEqual([`Starting test shard ${shardIndex}/8`]) + const serialShard = serial.stderr.split(`Starting test shard ${shardIndex}/8`)[1]!.split('Finished test shard')[0]! + const assigned = [...serialShard.matchAll(/fixture-(\d+)\.test\.ts:/g)].map(match => match[1]!).sort() + expect(result.executed).toEqual(assigned) + selected.push(...result.executed) + } + expect(selected.sort()).toEqual(serial.executed) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}, 15_000) + +for (const options of [ + ['--shard-index=0'], + ['--shard-index=-1'], + ['--shard-index=3'], + ['--shard-index=1.5'], + ['--shard-index=invalid'], + ['--shard-index=9007199254740992'], + ['--shard-index='], + ['--shard-index'], + ['--shard-index', '1'], + ['--shard-index=1', '--shard-index=2'], +]) { + test(`test runner rejects invalid selection ${options.join(' ')}`, async () => { + const child = Bun.spawn([process.execPath, runner, '--shards=2', ...options], { + stdout: 'pipe', stderr: 'pipe', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]) + expect(exitCode, stdout + stderr).not.toBe(0) + expect(stderr).toContain('Use one --shard-index=') + expect(stderr).not.toContain('Starting test shard') + }) +} + +for (const scenario of ['assertion', 'exit'] as const) { + test(`selected shard detects ${scenario} failure`, async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-runner-selected-failure-')) + try { + const fixtures = [join(dir, 'first.test.ts'), join(dir, 'later.test.ts')] + for (const fixture of fixtures) { + await writeFile(fixture, ` + import { test, expect } from 'bun:test' + test('failure', () => { ${scenario === 'exit' ? 'process.exit(0)' : 'expect(1).toBe(2)'} }) + `) + } + const child = Bun.spawn([process.execPath, runner, '--shards=2', '--shard-index=2', ...fixtures], { + stdout: 'pipe', stderr: 'pipe', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]) + expect(exitCode, stdout + stderr).toBe(1) + expect(stderr.match(/Starting test shard \d+\/2/g)).toEqual(['Starting test shard 2/2']) + expect(stderr).toContain(`Completed ${scenario === 'exit' ? 0 : 1}/1 test shards.`) + if (scenario === 'exit') expect(stderr).toContain('without a complete JUnit report') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +} + for (const signal of [undefined, 'SIGINT', 'SIGTERM'] as const) { test.skipIf(process.platform === 'win32')(`test runner kills a stuck shard and its child (${signal ?? 'deadline'})`, async () => { const dir = await mkdtemp(join(tmpdir(), 'openclaude-runner-hang-')) @@ -127,15 +223,15 @@ for (const signal of [undefined, 'SIGINT', 'SIGTERM'] as const) { ` await writeFile(fixture, ` import { test } from 'bun:test' - import { execFileSync } from 'node:child_process' import { writeSync } from 'node:fs' test('stuck compiler cleanup', () => { writeSync(2, 'last diagnostic before hang\\n') - execFileSync(process.execPath, ['-e', ${JSON.stringify(subprocess)}], { timeout: 500 }) + Bun.spawnSync([process.execPath, '-e', ${JSON.stringify(subprocess)}], { env: process.env, timeout: 500, killSignal: 'SIGTERM', stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }) }, 200) `) const shardTimeout = signal ? 4000 : 1500 - child = Bun.spawn([process.execPath, runner, `--shard-timeout-ms=${shardTimeout}`, fixture], { + child = Bun.spawn([process.execPath, runner, `--shard-timeout-ms=${shardTimeout}`, + ...(signal ? [] : ['--shards=2', '--shard-index=1']), fixture], { stdout: 'pipe', stderr: 'pipe', timeout: 6000, killSignal: 'SIGKILL', }) const output = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]) @@ -152,7 +248,7 @@ for (const signal of [undefined, 'SIGINT', 'SIGTERM'] as const) { const [, stderr] = await output expect(stderr).toContain('last diagnostic before hang') expect(exitCode, stderr).toBe(signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 124) - if (!signal) expect(stderr).toContain('Test shard 1/1 timed out after 1500ms.') + if (!signal) expect(stderr).toContain('Test shard 1/2 timed out after 1500ms.') // An orphaned compiler must not survive a timeout or cancellation. for (let attempt = 0; attempt < 100; attempt++) { try { process.kill(descendant!, 0) } catch { break } diff --git a/scripts/run-tests.ts b/scripts/run-tests.ts index 1461a7fbd2..90c1ceb35a 100644 --- a/scripts/run-tests.ts +++ b/scripts/run-tests.ts @@ -6,23 +6,29 @@ import { join } from 'node:path' const args = process.argv.slice(2) const shardOptions = args.filter(arg => arg.startsWith('--shards=')) const shards = shardOptions.length ? Number(shardOptions[0]!.slice('--shards='.length)) : 1 +const shardIndexOptions = args.filter(arg => arg === '--shard-index' || arg.startsWith('--shard-index=')) +const shardIndex = shardIndexOptions.length ? Number(shardIndexOptions[0]!.slice('--shard-index='.length)) : undefined const timeoutOptions = args.filter(arg => arg.startsWith('--shard-timeout-ms=')) const timeoutMs = timeoutOptions.length ? Number(timeoutOptions[0]!.split('=')[1]) : 600_000 if (shardOptions.length > 1 || !Number.isSafeInteger(shards) || shards < 1 || args.some(arg => arg === '--shard' || arg.startsWith('--shard='))) { throw new Error('Use one --shards= option; native --shard is not supported.') } +if (shardIndexOptions.length > 1 || (shardIndex !== undefined && + (!Number.isSafeInteger(shardIndex) || shardIndex < 1 || shardIndex > shards))) { + throw new Error('Use one --shard-index= option.') +} if (timeoutOptions.length > 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647) { throw new Error('Use one --shard-timeout-ms= option.') } -const testArgs = args.filter(arg => !arg.startsWith('--shards=') && !arg.startsWith('--shard-timeout-ms=')) +const testArgs = args.filter(arg => !arg.startsWith('--shards=') && !arg.startsWith('--shard-index=') && !arg.startsWith('--shard-timeout-ms=')) // Bun can exit zero without finishing its suite if a test calls process.exit(0). // Its JUnit report is written only when the runner finishes; require a fresh one. const reportDir = await mkdtemp(join(tmpdir(), 'openclaude-test-report-')) let exitCode = 0 let completed = 0 try { - for (let shard = 1; shard <= shards; shard++) { + for (let shard = shardIndex ?? 1; shard <= (shardIndex ?? shards); shard++) { const started = performance.now() console.error(`Starting test shard ${shard}/${shards} (deadline ${timeoutMs}ms).`) const reportPath = join(reportDir, `results-${shard}.xml`) @@ -55,7 +61,6 @@ try { const child = Bun.spawn([ process.execPath, 'test', - '--isolate', ...testArgs, ...(shards > 1 ? [`--shard=${shard}/${shards}`] : []), '--reporter=junit', @@ -138,7 +143,7 @@ try { } } } - if (shards > 1) console.error(`Completed ${completed}/${shards} test shards.`) + if (shards > 1) console.error(`Completed ${completed}/${shardIndex === undefined ? shards : 1} test shards.`) } finally { await rm(reportDir, { recursive: true, force: true }) } diff --git a/scripts/subprocess.test.ts b/scripts/subprocess.test.ts new file mode 100644 index 0000000000..457235c227 --- /dev/null +++ b/scripts/subprocess.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { snapshotEnv } from '../src/test/envSnapshot.js' +import { captureSync } from './subprocess.ts' + +describe('development subprocess capture', () => { + test('inherits environment variables set after startup when env is omitted', () => { + const key = 'OPENCLAUDE_SUBPROCESS_LATE_ENV_TEST' + const restoreEnv = snapshotEnv([key]) + try { + process.env[key] = 'set after startup' + const result = captureSync(process.execPath, ['-e', ` + process.stdout.write(process.env.${key} ?? '') + `]) + expect(result.status).toBe(0) + expect(result.stdout).toBe('set after startup') + } finally { + restoreEnv() + } + }) + + test('captures both streams and a nonzero status without shell expansion', () => { + const literal = 'space ; $value & "quoted"' + const result = captureSync(process.execPath, ['-e', ` + process.stdout.write(process.argv[1]) + process.stderr.write('diagnostic\\n') + process.exit(7) + `, literal]) + expect(result).toEqual({ status: 7, stdout: literal, stderr: 'diagnostic\n' }) + }) + + test('preserves cwd, the supplied environment, and closed piped stdin', () => { + const result = captureSync(process.execPath, ['-e', ` + process.stdin.resume() + process.stdin.on('end', () => { + process.stdout.write(JSON.stringify([process.cwd(), process.env.PROCESS_CAPTURE_TEST])) + }) + `], { cwd: import.meta.dir, env: { PROCESS_CAPTURE_TEST: 'present' }, timeout: 1000 }) + expect(result.status).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([import.meta.dir, 'present']) + }) + + test('returns a missing executable error instead of throwing', () => { + const result = captureSync('missing-development-command-l2', []) + expect(result.status).toBeNull() + expect(result.error?.message).toContain('ENOENT') + expect(result.stdout).toBe('') + expect(result.stderr).toBe('') + }) + + // The child deadline must expire before the test's own deadline. + test('reports timeouts while keeping diagnostics', () => { + const result = captureSync(process.execPath, ['-e', ` + process.stderr.write('before timeout') + setInterval(() => {}, 1000) + `], { timeout: 200 }) + expect(result.status).toBeNull() + expect(result.error?.message).toContain('ETIMEDOUT') + expect(result.stderr).toBe('before timeout') + }, 2000) + + test('reports bounded output capture failures', () => { + const result = captureSync(process.execPath, ['-e', ` + process.stdout.write('x'.repeat(1024 * 1024)) + setInterval(() => {}, 1000) + `], { maxBuffer: 1024, timeout: 1000 }) + expect(result.error?.message).toContain('ENOBUFS') + expect(result.status).toBeNull() + }, 2000) +}) diff --git a/scripts/subprocess.ts b/scripts/subprocess.ts new file mode 100644 index 0000000000..481934dc1d --- /dev/null +++ b/scripts/subprocess.ts @@ -0,0 +1,50 @@ +type CaptureOptions = { + cwd?: string + env?: NodeJS.ProcessEnv + timeout?: number + maxBuffer?: number +} + +type CaptureResult = { + status: number | null + stdout: string + stderr: string + error?: Error +} + +/** Capture development command output without throwing on launch or exit failure. */ +export function captureSync( + command: string, + args: string[], + options: CaptureOptions = {}, +): CaptureResult { + try { + const result = Bun.spawnSync([command, ...args], { + cwd: options.cwd, + env: options.env ?? process.env, + timeout: options.timeout, + maxBuffer: options.maxBuffer ?? 1024 * 1024, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }) + return { + status: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + ...(result.exitedDueToTimeout || result.exitedDueToMaxBuffer + ? { error: new Error(`spawnSync ${command} ${result.exitedDueToTimeout ? 'ETIMEDOUT' : 'ENOBUFS'}`) } + : {}), + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + return { + status: null, + stdout: '', + stderr: '', + error: code === 'ENOENT' + ? new Error(`spawnSync ${command} ENOENT`) + : error as Error, + } + } +} diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index dead9e1cd4..45e8ed6f95 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -1048,14 +1048,14 @@ describe('checkNodeVersion', () => { test('reads the Node.js version from the node executable output', () => { const probe = readNodeExecutableVersion(() => ({ status: 0, - stdout: 'v22.0.0\n', + stdout: 'v24.22.1\n', stderr: '', error: undefined, })) expect(probe).toEqual({ ok: true, - version: 'v22.0.0', + version: 'v24.22.1', }) }) @@ -1064,7 +1064,7 @@ describe('checkNodeVersion', () => { ok: false, label: 'Node.js version', detail: - 'Detected 20.11.1. OpenClaude requires Node.js >=22.0.0. Install Node 22 LTS or newer, then reinstall/re-run OpenClaude.', + 'Detected 20.11.1. OpenClaude requires Node.js >=24.22.1. Install Node 24.22.1 or newer, then reinstall/re-run OpenClaude.', }) }) @@ -1080,7 +1080,7 @@ describe('checkNodeVersion', () => { ok: false, label: 'Node.js version', detail: - 'Unable to run `node --version`: spawn node ENOENT. OpenClaude requires Node.js >=22.0.0 on PATH.', + 'Unable to run `node --version`: spawn node ENOENT. OpenClaude requires Node.js >=24.22.1 on PATH.', }) }) @@ -1089,17 +1089,36 @@ describe('checkNodeVersion', () => { ok: false, label: 'Node.js version', detail: - 'Detected 20.11.1. OpenClaude requires Node.js >=22.0.0. Install Node 22 LTS or newer, then reinstall/re-run OpenClaude.', + 'Detected 20.11.1. OpenClaude requires Node.js >=24.22.1. Install Node 24.22.1 or newer, then reinstall/re-run OpenClaude.', }) }) test('passes supported Node.js versions', () => { - expect(checkNodeVersion('22.0.0')).toEqual({ + expect(checkNodeVersion('24.22.1')).toEqual({ ok: true, label: 'Node.js version', - detail: '22.0.0', + detail: '24.22.1', }) }) + + test('rejects the patch immediately below the Node.js minimum', () => { + expect(checkNodeVersion({ ok: true, version: 'v24.22.0' })).toEqual({ + ok: false, + label: 'Node.js version', + detail: + 'Detected 24.22.0. OpenClaude requires Node.js >=24.22.1. Install Node 24.22.1 or newer, then reinstall/re-run OpenClaude.', + }) + }) + + test.each(['v24.22.1', 'v26.12.1'])( + 'passes the supported probed version %s', version => { + expect(checkNodeVersion({ ok: true, version })).toEqual({ + ok: true, + label: 'Node.js version', + detail: version.slice(1), + }) + }, + ) }) describe('sandbox runtime diagnostics', () => { diff --git a/scripts/system-check.ts b/scripts/system-check.ts index fcd91e5f50..92bc2effde 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' -import { spawnSync } from 'node:child_process' +import { captureSync } from './subprocess.ts' import { resolveCodexApiCredentials, resolveProviderRequest, @@ -766,11 +766,9 @@ export function formatReachabilityFailureDetail( } export function readNodeExecutableVersion( - spawn = spawnSync, + spawn = captureSync, ): NodeExecutableVersionProbe { - const result = spawn('node', ['--version'], { - encoding: 'utf8', - }) + const result = spawn('node', ['--version']) if (result.error) { return { @@ -1403,11 +1401,10 @@ function checkOllamaProcessorMode(): CheckResult { return pass('Ollama processor mode', 'Skipped (Atomic Chat local provider detected, not Ollama).') } - const result = spawnSync('ollama', ['ps'], { - cwd: process.cwd(), - encoding: 'utf8', - shell: true, - }) + // Keep the existing shell lookup, including command wrappers on Windows. + const result = process.platform === 'win32' + ? captureSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'ollama ps'], { cwd: process.cwd() }) + : captureSync('/bin/sh', ['-c', 'ollama ps'], { cwd: process.cwd() }) if (result.status !== 0) { const detail = (result.stderr || result.stdout || 'Unable to run ollama ps').trim() diff --git a/scripts/typecheck-fast.ts b/scripts/typecheck-fast.ts new file mode 100644 index 0000000000..7c88ec259b --- /dev/null +++ b/scripts/typecheck-fast.ts @@ -0,0 +1,13 @@ +import { parse, resolve } from 'node:path' + +const tsconfigPath = resolve(import.meta.dir, '../tsconfig.json') +// Outside the project, `bun check` invokes the builtin instead of our check script. +const result = Bun.spawnSync([process.execPath, 'check', '-p', tsconfigPath], { + cwd: parse(tsconfigPath).root, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', +}) + +if (!result.success) process.exit(result.exitCode ?? 1) +process.exit(0) diff --git a/scripts/verify-clean-install.ts b/scripts/verify-clean-install.ts index 3d0676d6b0..d8d90f4a1e 100644 --- a/scripts/verify-clean-install.ts +++ b/scripts/verify-clean-install.ts @@ -34,7 +34,7 @@ * Note: package.json `overrides` do NOT travel to consumers; this script * intentionally reproduces the user's resolution, not the repo's. */ -import { execFileSync } from 'node:child_process' +import { captureSync } from './subprocess.ts' import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, posix, win32 } from 'node:path' @@ -630,7 +630,13 @@ export function getTarballPayloadProblems( function checkTarballContents(tarballPath: string): void { const scenario = 'tarball' - const listing = execFileSync('tar', ['-tzf', tarballPath], { encoding: 'utf8' }) + const result = captureSync('tar', ['-tzf', tarballPath]) + process.stderr.write(result.stderr) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`Command failed: tar -tzf ${tarballPath}\n${result.stderr}`) + } + const listing = result.stdout const entries = new Set(listing.split(/\r?\n/).map(l => l.trim())) const payloadProblems = getTarballPayloadProblems(entries) for (const problem of payloadProblems) fail(scenario, problem) diff --git a/src/QueryEngine.interruptionTrace.test.ts b/src/QueryEngine.interruptionTrace.test.ts index 5e4a231f96..376cc1af97 100644 --- a/src/QueryEngine.interruptionTrace.test.ts +++ b/src/QueryEngine.interruptionTrace.test.ts @@ -15,6 +15,8 @@ const originalTrace = process.env.OPENCLAUDE_INTERRUPT_TRACE beforeEach(async () => { await acquireSharedMutationLock('QueryEngine.interruptionTrace.test.ts') + // Earlier files can leave trace entries behind in non-isolated runs. + __resetInterruptionTraceForTests() }) afterEach(async () => { diff --git a/src/Tool.ts b/src/Tool.ts index 54fb556084..86c10f1322 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -271,6 +271,8 @@ export type ToolUseContext = { ) => void setConversationId?: (id: UUID) => void agentId?: AgentId // Only set for subagents; use getSessionId() for session ID. Hooks use this to distinguish subagent calls. + /** Last submitted transcript for this producer; undefined for other IDs. */ + getAgentTranscriptPath?: (agentId: AgentId) => string | undefined agentType?: string // Subagent type name. For the main thread's --agent type, hooks fall back to getMainThreadAgentType(). /** When true, canUseTool must always be called even when hooks auto-approve. * Used by speculation for overlay file path rewriting. */ @@ -381,8 +383,15 @@ export type ToolCallProgress

= ( progress: ToolProgress

, ) => void -// Type for any schema that outputs an object with string keys -export type AnyObject = z.ZodType<{ [key: string]: unknown }> +// Consumers only parse/convert schemas. Comparing the full fluent ZodType +// interface recursively instantiates builders for every concrete tool schema. +type ToolSchema = Pick< + z.ZodType, + '_zod' | '~standard' | 'parse' | 'safeParse' +> + +// Type for any schema that outputs an object with string keys. +export type AnyObject = ToolSchema<{ [key: string]: unknown }> /** * Checks if a tool matches the given name (primary name or alias). @@ -439,7 +448,7 @@ export type Tool< readonly inputJSONSchema?: ToolInputJSONSchema // Optional because TungstenTool doesn't define this. TODO: Make it required. // When we do that, we can also go through and make this a bit more type-safe. - outputSchema?: z.ZodType + outputSchema?: ToolSchema inputsEquivalent?(a: z.infer, b: z.infer): boolean isConcurrencySafe(input: z.infer): boolean isEnabled(): boolean diff --git a/src/Tool.types.test.ts b/src/Tool.types.test.ts new file mode 100644 index 0000000000..df0f10535e --- /dev/null +++ b/src/Tool.types.test.ts @@ -0,0 +1,69 @@ +import { z } from 'zod/v4' +import type { AnyObject, Tool } from './Tool.js' +import { createToolFixture } from './test/toolFixtures.js' +import { parseClassifierResponse } from './utils/permissions/classifierShared.js' +import { semanticBoolean } from './utils/semanticBoolean.js' +import { semanticNumber } from './utils/semanticNumber.js' +import { zodToJsonSchema } from './utils/zodToJsonSchema.js' + +type Assert = T +type IsEqual = (() => T extends A ? 1 : 2) extends < + T, +>() => T extends B ? 1 : 2 + ? true + : false + +const schema = z.strictObject({ + count: z.string().transform(Number), + label: z.string().optional(), +}) +const tool = createToolFixture(schema, { name: 'schema-contract' }) + +type _OutputInference = Assert< + IsEqual, { count: number; label?: string }> +> +type _InputInference = Assert< + IsEqual, { count: string; label?: string }> +> +type _CallInference = Assert< + IsEqual[0], z.infer> +> +type _ScalarRejected = Assert< + IsEqual +> +type _UnknownOutput = Assert< + IsEqual['parse']>, unknown> +> + +function assertSchemaConsumers(genericTool: Tool): void { + const numeric = semanticNumber(z.number().default(0)) + const boolean = semanticBoolean(z.boolean().optional()) + type _NumericOutput = Assert, number>> + type _BooleanOutput = Assert< + IsEqual, boolean | undefined> + > + const classified = parseClassifierResponse( + { type: 'tool_use', id: 'schema', name: 'schema', input: {} }, + schema, + ) + type _ClassifierOutput = Assert< + IsEqual | null> + > + + // Concrete tools still expose the schema's construction API and exact output. + tool.inputSchema.extend({ enabled: z.boolean() }) + const parsed: { count: number; label?: string } = tool.inputSchema.parse({}) + void parsed + + // Heterogeneous registries only need parsing and JSON conversion. + const input: Record = genericTool.inputSchema.parse({}) + void input + genericTool.inputSchema.safeParse({}) + zodToJsonSchema(genericTool.inputSchema) + if (genericTool.outputSchema) { + genericTool.outputSchema.safeParse({}) + zodToJsonSchema(genericTool.outputSchema) + } +} + +void assertSchemaConsumers diff --git a/src/__tests__/bugfixes.test.ts b/src/__tests__/bugfixes.test.ts index 909998f66d..0745674254 100644 --- a/src/__tests__/bugfixes.test.ts +++ b/src/__tests__/bugfixes.test.ts @@ -5,7 +5,7 @@ * 1. Gemini `store: false` rejection fix * 2. Session timeout / 500 error fix (stream idle timeout) * 3. Agent loop continuation nudge - * 4. Web search result count improvements + * 5. Gemini Vertex provider drift guards */ import { afterEach, describe, test, expect, mock } from 'bun:test' @@ -496,6 +496,82 @@ describe('Context overflow 500 fix', () => { }) }) +// --------------------------------------------------------------------------- +// Fix N: Gemini Vertex provider drift guards +// --------------------------------------------------------------------------- +describe('Gemini Vertex provider drift guards', () => { + function sectionBetween(content: string, startMarker: string, endMarker: string): string { + const start = content.indexOf(startMarker) + expect(start, `Missing section start: ${startMarker}`).toBeGreaterThanOrEqual(0) + const end = content.indexOf(endMarker, start + startMarker.length) + expect(end, `Missing section end: ${endMarker}`).toBeGreaterThan(start) + return content.slice(start, end) + } + + test('API customer, model flag, and startup detection include Gemini Vertex', async () => { + const authContent = await file('utils/auth.ts').text() + const apiCustomerSection = sectionBetween( + authContent, 'export function is1PApiCustomer', 'export function getOauthAccountInfo', + ) + + expect(apiCustomerSection).toContain('CLAUDE_CODE_USE_GEMINI_VERTEX') + + const providerFlagContent = await file('utils/providerFlag.ts').text() + const modelFlagSection = sectionBetween( + providerFlagContent, 'export function applyModelFlagFromArgs', 'export function applyProviderFlag', + ) + + expect(modelFlagSection).toContain('CLAUDE_CODE_USE_GEMINI_VERTEX') + expect(modelFlagSection).toContain('GEMINI_VERTEX_MODEL') + + const startupScreenContent = await file('components/StartupScreen.ts').text() + const detectProviderSection = sectionBetween( + startupScreenContent, 'export function detectProvider', 'export function printStartupScreen', + ) + + expect(detectProviderSection).toContain('CLAUDE_CODE_USE_GEMINI_VERTEX') + expect(detectProviderSection).toContain('Gemini Vertex') + }) + + test('log and GitHub onboarding clear Gemini Vertex provider state', async () => { + const logContent = await file('utils/log.ts').text() + const errorReportingSection = sectionBetween( + logContent, 'Cloud providers', 'process.env.DISABLE_ERROR_REPORTING', + ) + + expect(errorReportingSection).toContain('CLAUDE_CODE_USE_GEMINI_VERTEX') + + const onboardGithubContent = await file( + 'commands/onboard-github/onboard-github.tsx', + ).text() + const providerKeysSection = sectionBetween( + onboardGithubContent, 'PROVIDER_SPECIFIC_KEYS', '])', + ) + + expect(providerKeysSection).toContain('CLAUDE_CODE_USE_GEMINI_VERTEX') + }) + + test('auth treats Gemini Vertex as a third-party provider', async () => { + const content = await file('utils/auth.ts').text() + const is3PSection = sectionBetween( + content, 'const is3P =', '// Check if user has configured', + ) + + expect(is3PSection).toContain('hasAnyTruthyProviderSelectionFlag') + const { PROVIDER_SELECTION_FLAGS } = await import('../utils/providerSelectionFlags.js') + expect(PROVIDER_SELECTION_FLAGS).toContain('CLAUDE_CODE_USE_GEMINI_VERTEX') + }) + + test('API preconnect skips Gemini Vertex routing', async () => { + const content = await file('utils/apiPreconnect.ts').text() + const cloudSkipSection = sectionBetween( + content, '// Skip if using a cloud provider', '// Skip if proxy/mTLS/unix', + ) + + expect(cloudSkipSection).toContain('CLAUDE_CODE_USE_GEMINI_VERTEX') + }) +}) + // --------------------------------------------------------------------------- // Fix N: Project-scope MCP servers from .mcp.json not detected for 3P providers (issue #696) // --------------------------------------------------------------------------- diff --git a/src/__tests__/security-hardening.test.ts b/src/__tests__/security-hardening.test.ts index d9aa7c121e..d22ef604db 100644 --- a/src/__tests__/security-hardening.test.ts +++ b/src/__tests__/security-hardening.test.ts @@ -104,6 +104,50 @@ describe('SAFE_ENV_VARS excludes credentials', () => { const safeSection = content.slice(safeStart, safeEnd) expect(safeSection).not.toContain('ANTHROPIC_FOUNDRY_API_KEY') }) + + test('Gemini Vertex routing env vars are managed; project ids and locations require trust', async () => { + // Assert runtime classification behavior, not source formatting, so the + // test catches real regressions in env policy instead of breaking on edits. + const { isProviderManagedEnvVar, SAFE_ENV_VARS } = await import( + '../utils/managedEnvConstants.ts' + ) + + for (const envVar of [ + 'CLAUDE_CODE_USE_GEMINI_VERTEX', + 'GEMINI_ACCESS_TOKEN', + 'GEMINI_VERTEX_AUTH_MODE', + 'GEMINI_VERTEX_MODEL', + 'GEMINI_VERTEX_PROJECT', + 'GEMINI_VERTEX_LOCATION', + 'GOOGLE_CLOUD_PROJECT', + 'GCLOUD_PROJECT', + 'GOOGLE_PROJECT_ID', + ]) { + expect(isProviderManagedEnvVar(envVar)).toBe(true) + } + + for (const envVar of [ + 'CLAUDE_CODE_USE_GEMINI_VERTEX', + 'GEMINI_VERTEX_AUTH_MODE', + 'GEMINI_VERTEX_MODEL', + ]) { + expect(SAFE_ENV_VARS.has(envVar)).toBe(true) + } + + // Locations control the authenticated request origin. Project ids from + // untrusted settings would switch Vertex traffic to an + // attacker-controlled GCP project — same policy as + // ANTHROPIC_VERTEX_PROJECT_ID, which is managed but not safe. + for (const envVar of [ + 'GEMINI_VERTEX_LOCATION', + 'GEMINI_VERTEX_PROJECT', + 'GOOGLE_CLOUD_PROJECT', + 'GCLOUD_PROJECT', + 'GOOGLE_PROJECT_ID', + ]) { + expect(SAFE_ENV_VARS.has(envVar)).toBe(false) + } + }) }) // --------------------------------------------------------------------------- diff --git a/src/__tests__/statusNoticeLocalModel.test.ts b/src/__tests__/statusNoticeLocalModel.test.ts index 6d8c101a38..2dec33baf5 100644 --- a/src/__tests__/statusNoticeLocalModel.test.ts +++ b/src/__tests__/statusNoticeLocalModel.test.ts @@ -292,6 +292,7 @@ describe('checkLocalModelContextLoad', () => { CLAUDE_CODE_USE_OPENAI: undefined, CLAUDE_CODE_USE_GITHUB: undefined, CLAUDE_CODE_USE_GEMINI: undefined, + CLAUDE_CODE_USE_GEMINI_VERTEX: undefined, CLAUDE_CODE_USE_MISTRAL: undefined, CLAUDE_CODE_USE_BEDROCK: undefined, CLAUDE_CODE_USE_VERTEX: undefined, @@ -368,6 +369,15 @@ describe('active provider local detection', () => { ).toBe(false) }) + test('does not treat Gemini Vertex as local when OPENAI_API_BASE is stale', () => { + expect( + isActiveProviderLocalModel({ + CLAUDE_CODE_USE_GEMINI_VERTEX: '1', + OPENAI_API_BASE: 'http://localhost:11434/v1', + }), + ).toBe(false) + }) + test('detects local Anthropic-compatible base URLs for the active Anthropic provider', () => { expect( isActiveProviderLocalModel({ diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index 28486e5b45..5e2327ba30 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -407,7 +407,7 @@ const STATE: State = getInitialState() * * **Runtime Requirement:** Uses Node.js `async_hooks.AsyncLocalStorage`. * Not available in browsers or non-Node JavaScript environments. - * SDK consumers must run in OpenClaude's supported Node.js runtime (>=22.0.0). + * SDK consumers must run in OpenClaude's supported Node.js runtime (>=24.22.1). */ type SdkContext = { sessionId: SessionId @@ -415,6 +415,7 @@ type SdkContext = { cwd: string originalCwd: string parentSessionId?: SessionId + registeredHooks?: Partial> } import { AsyncLocalStorage } from 'async_hooks' @@ -1455,7 +1456,14 @@ export function registerHookCallbacks( export function getRegisteredHooks(): Partial< Record > | null { - return STATE.registeredHooks + const queryHooks = getSdkContext()?.registeredHooks + if (!queryHooks || Object.keys(queryHooks).length === 0) return STATE.registeredHooks + const hooks = { ...STATE.registeredHooks } + for (const [event, matchers] of Object.entries(queryHooks)) { + const key = event as HookEvent + hooks[key] = [...(hooks[key] ?? []), ...matchers] + } + return hooks } export function clearRegisteredHooks(): void { diff --git a/src/cli/bgFinalizer.test.ts b/src/cli/bgFinalizer.test.ts index fdc51937a1..af86a02638 100644 --- a/src/cli/bgFinalizer.test.ts +++ b/src/cli/bgFinalizer.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { isProcessRunning } from '../utils/genericProcessUtils.js' +import { isolateProcessExitCode } from '../test/processExitCode.js' import { BACKGROUND_SESSION_ID_ENV, BACKGROUND_SESSION_LAUNCHER_PID_ENV, @@ -37,16 +38,22 @@ const FIXTURE_OUTPUT_LIMIT_BYTES = 64 * 1024 describe('background session finalizer', () => { let configDir: string let sessionsRoot: string + let restoreProcess: () => void beforeEach(async () => { + restoreProcess = isolateProcessExitCode() configDir = await mkdtemp(join(tmpdir(), 'openclaude-bg-finalizer-')) sessionsRoot = join(configDir, 'bg-sessions') _setBackgroundSessionsRootForTesting(sessionsRoot) }) afterEach(async () => { - _setBackgroundSessionsRootForTesting(undefined) - await rm(configDir, { recursive: true, force: true }) + try { + _setBackgroundSessionsRootForTesting(undefined) + await rm(configDir, { recursive: true, force: true }) + } finally { + restoreProcess() + } }) function ownedSession(id: string, pid: number): BackgroundSession { @@ -607,6 +614,7 @@ describe('background session finalizer', () => { id: string child: ReturnType readyPath: string + exited: Promise<[number | null, NodeJS.Signals | null]> }> { const id = `bg-fixture-${mode}` const readyPath = join(configDir, `${id}.ready`) @@ -624,17 +632,21 @@ describe('background session finalizer', () => { stdio: ['ignore', 'ignore', 'ignore'], }) if (!child.pid) throw new Error('fixture did not start') + // Subscribe before registry I/O can yield past a short-lived child's exit. + const exited = once(child, 'exit') as Promise< + [number | null, NodeJS.Signals | null] + > await mkdir(join(sessionsRoot, 'sessions'), { recursive: true }) await writeFile( join(sessionsRoot, 'sessions', `${id}.json`), JSON.stringify(ownedSession(id, child.pid)), ) - return { id, child, readyPath } + return { id, child, readyPath, exited } } - async function waitForFile(path: string): Promise { - for (let attempt = 0; attempt < 500; attempt += 1) { + async function waitForFile(path: string, attempts: number = 500): Promise { + for (let attempt = 0; attempt < attempts; attempt += 1) { if (await Bun.file(path).exists()) return await new Promise(resolve => setTimeout(resolve, 10)) } @@ -907,6 +919,9 @@ describe('background session finalizer', () => { }) if (!child.pid) throw new Error('built CLI fixture did not start') + // Observe termination before yielding to registration I/O: a fast child + // may otherwise exit before the listener is installed. + const exited = once(child, 'exit') as Promise<[number]> await mkdir(join(sessionsRoot, 'sessions'), { recursive: true }) await writeFile( join(sessionsRoot, 'sessions', `${id}.json`), @@ -916,7 +931,7 @@ describe('background session finalizer', () => { command: [childConfig.command, ...childConfig.args], }), ) - const [code] = (await once(child, 'exit')) as [number] + const [code] = await exited return code } @@ -967,6 +982,7 @@ describe('background session finalizer', () => { async function runWaitingDetachedLaunch( name: string, fixtureEnv: NodeJS.ProcessEnv = {}, + readinessAttempts?: number, ): Promise<{ id: string; childPid: number; readyPath: string }> { const readyPath = join(configDir, `${name}.ready`) const launcher = spawn( @@ -1017,7 +1033,7 @@ describe('background session finalizer', () => { if (!id || childPid === undefined) { throw new Error('detached wait launch omitted its session identity') } - await waitForFile(readyPath) + await waitForFile(readyPath, readinessAttempts) ownershipTransferred = true return { id, childPid, readyPath } } finally { @@ -1044,6 +1060,9 @@ describe('background session finalizer', () => { env: { OPENCLAUDE_BG_FINALIZER_FIXTURE_SKIP_READY: '1', }, + // Readiness is deliberately suppressed; only exercise the failure path. + readinessAttempts: 25, + expectedError: 'fixture readiness file was not created', }, ]) { it( @@ -1051,8 +1070,8 @@ describe('background session finalizer', () => { async () => { const name = `failed-handoff-${scenario.label.replaceAll(' ', '-')}` await expect( - runWaitingDetachedLaunch(name, scenario.env), - ).rejects.toThrow() + runWaitingDetachedLaunch(name, scenario.env, scenario.readinessAttempts), + ).rejects.toThrow(scenario.expectedError) const session = (await listBackgroundSessions()).find( candidate => candidate.name === name, ) @@ -1197,8 +1216,8 @@ describe('background session finalizer', () => { { mode: 'throw' as const, status: 'failed', exitCode: 1 }, ]) { it(`records a real ${expectation.mode} child outcome`, async () => { - const { id, child } = await runFixture(expectation.mode) - const [code] = (await once(child, 'exit')) as [number] + const { id, exited } = await runFixture(expectation.mode) + const [code] = await exited expect(code).toBe(expectation.exitCode) expect((await listBackgroundSessions())[0]).toMatchObject({ @@ -1283,13 +1302,11 @@ describe('background session finalizer', () => { 'keeps a handled uncaught exception live until the actual exit', async () => { const releasePath = join(configDir, 'handled-throw.release') - const { id, child, readyPath } = await runFixture( + const { id, child, readyPath, exited } = await runFixture( 'handled-throw', releasePath, ) - const childExit = once(child, 'exit') as Promise< - [number | null, NodeJS.Signals | null] - > + const childExit = exited const forceTimer = setTimeout(() => child.kill('SIGKILL'), 10_000) try { await waitForFile(readyPath) @@ -1341,13 +1358,12 @@ describe('background session finalizer', () => { it.skipIf(process.platform === 'win32')( `records an observed ${expectation.signal} as a failed signal fact`, async () => { - const { id, child, readyPath } = await runFixture(expectation.mode) + const { id, child, readyPath, exited } = await runFixture( + expectation.mode, + ) await waitForFile(readyPath) child.kill(expectation.signal) - const [code, signal] = (await once(child, 'exit')) as [ - number, - NodeJS.Signals | null, - ] + const [code, signal] = await exited expect(code).toBe(expectation.childExitCode) expect(signal).toBeNull() expect((await listBackgroundSessions())[0]).toMatchObject({ @@ -1666,15 +1682,19 @@ describe('background session finalizer', () => { ) it('keeps the installed launcher PID stable and shows outcomes truthfully in ps', async () => { - expect(await runBuiltCliSession('bg-built-success', ['--version'])).toBe(0) - expect( - await runBuiltCliSession('bg-built-failure', [ + // These launches have independent registry IDs. Synchronize on both exit + // events instead of spending the test's budget on serial CLI startups. + const [successCode, failureCode] = await Promise.all([ + runBuiltCliSession('bg-built-success', ['--version']), + runBuiltCliSession('bg-built-failure', [ '--provider-env-file', join(configDir, 'missing.env'), '--print', 'noop', ]), - ).toBe(1) + ]) + expect(successCode).toBe(0) + expect(failureCode).toBe(1) const ps = spawn('node', [installedLauncherPath, 'ps'], { env: { ...process.env, OPENCLAUDE_CONFIG_DIR: configDir }, @@ -1690,7 +1710,7 @@ describe('background session finalizer', () => { ps.stderr?.on('data', chunk => { stderr += chunk }) - const [psCode] = (await once(ps, 'exit')) as [number] + const [psCode] = (await once(ps, 'close')) as [number] expect(psCode).toBe(0) expect(stderr).toBe('') @@ -1707,10 +1727,10 @@ describe('background session finalizer', () => { it.skipIf(process.platform === 'win32')( 'does not invent success when a fixture is forcibly destroyed', async () => { - const { child, readyPath } = await runFixture('wait') + const { child, readyPath, exited } = await runFixture('wait') await waitForFile(readyPath) child.kill('SIGKILL') - await once(child, 'exit') + await exited const refreshed = await refreshBackgroundSessionStatuses({ isProcessAlive: () => false, diff --git a/src/cli/bgRegistry.test.ts b/src/cli/bgRegistry.test.ts index f7d67b7e9c..11b839de4d 100644 --- a/src/cli/bgRegistry.test.ts +++ b/src/cli/bgRegistry.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' import { chmod, mkdir, @@ -1621,7 +1622,7 @@ describe('background session registry', () => { }) it('converges concurrent natural finalizers on one immutable fact', async () => { - await createBackgroundSession({ + const session = await createBackgroundSession({ id: 'bg-concurrent-natural', pid: 346, cwd: '/repo', @@ -1635,24 +1636,41 @@ describe('background session registry', () => { processMarker: TEST_PROCESS_MARKER, }) - const results = await Promise.all([ - recordBackgroundSessionNaturalTermination( - 'bg-concurrent-natural', - { exitCode: 31 }, - { - ownerPid: 346, - now: new Date('2026-06-15T08:10:00.000Z'), - }, - ), - recordBackgroundSessionNaturalTermination( - 'bg-concurrent-natural', - { exitCode: 0 }, - { - ownerPid: 346, - now: new Date('2026-06-15T08:11:00.000Z'), + const factPath = terminalFactPath( + session.id, + 'natural', + TEST_PROCESS_MARKER, + ) + let concurrentResult: Promise | undefined + let firstFact: string | undefined + const result = await recordBackgroundSessionNaturalTermination( + session.id, + { exitCode: 31 }, + { + ownerPid: session.pid, + get now() { + // The async finalizer holds the metadata lock and has read running + // state. Force the exit-event finalizer to install its fact before + // the async writer, without depending on the lock retry window. + recordBackgroundSessionNaturalTerminationSync( + session.id, + { exitCode: 0 }, + { + ownerPid: session.pid, + expectedSession: session, + now: new Date('2026-06-15T08:11:00.000Z'), + }, + ) + firstFact = readFileSync(factPath, 'utf8') + concurrentResult = resolveBackgroundSession(session.id) + return new Date('2026-06-15T08:10:00.000Z') }, - ), - ]) + }, + ) + if (!concurrentResult || firstFact === undefined) { + throw new Error('concurrent finalizer did not run') + } + const results = [result, await concurrentResult] const facts = results.map(result => { if (result.finishedAt === undefined || result.exitCode === undefined) { @@ -1665,6 +1683,11 @@ describe('background session registry', () => { } }) expect(new Set(facts.map(fact => JSON.stringify(fact))).size).toBe(1) + expect(facts[0]).toEqual({ + status: 'exited', + finishedAt: '2026-06-15T08:11:00.000Z', + exitCode: 0, + }) expect([ { status: 'failed', @@ -1680,6 +1703,7 @@ describe('background session registry', () => { expect(await resolveBackgroundSession('bg-concurrent-natural')).toMatchObject( facts[0]!, ) + expect(await readFile(factPath, 'utf8')).toBe(firstFact) }) it('corrects a stale guess with a later exact-owner natural fact', async () => { @@ -2527,14 +2551,20 @@ describe('background session registry', () => { sessionId: 'conversation-kill-natural-race', }) - await Promise.all([ - recordBackgroundSessionNaturalTermination( - 'bg-kill-natural-race', - { exitCode: 0 }, - { ownerPid: 345 }, - ), - markBackgroundSessionKilled('bg-kill-natural-race'), - ]) + // Let kill observe a running session, then finish naturally before its write. + // Force this interleaving without depending on lock retry timing. + await markBackgroundSessionKilled('bg-kill-natural-race', { + _beforeMarkWriteForTesting: async session => { + expect(session.status).toBe('running') + expect( + await recordBackgroundSessionNaturalTermination( + session.id, + { exitCode: 0 }, + { ownerPid: session.pid }, + ), + ).toMatchObject({ status: 'exited', exitCode: 0 }) + }, + }) expect(await resolveBackgroundSession('bg-kill-natural-race')).toMatchObject( { diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index 9a2dc89751..030b5b2011 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -248,6 +248,13 @@ export async function mcpRemoveHandler(name: string, options: { // mcp list (lines 4641–4688) export async function mcpListHandler(): Promise { logEvent('tengu_mcp_list', {}); + // The mcp subcommands skip the session startup that registers built-in + // plugins; register them so an enabled built-in plugin's MCP server (e.g. + // aimlapi@builtin) is listed and health-checked the way a session sees it. + const { + initBuiltinPlugins + } = await import('../../plugins/bundled/index.js'); + initBuiltinPlugins(); const { servers: configs } = await getAllMcpConfigs(); diff --git a/src/cli/handlers/skills.test.ts b/src/cli/handlers/skills.test.ts index ff08a47841..e417593198 100644 --- a/src/cli/handlers/skills.test.ts +++ b/src/cli/handlers/skills.test.ts @@ -13,6 +13,8 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'bun:test' +import { snapshotEnv } from '../../test/envSnapshot.js' +import { isolateProcessExitCode } from '../../test/processExitCode.js' import { acquireSharedMutationLock, @@ -221,25 +223,96 @@ async function withMockedFetch( async function withTempDir(fn: (tempDir: string) => Promise): Promise { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-skill-install-test-')) + const tmpKeys = ['TMPDIR', 'TMP', 'TEMP'] as const + const restoreEnv = snapshotEnv([ + ...tmpKeys, 'OPENCLAUDE_SKILLS_REVOCATIONS_URL', + ]) + const nativeTmpEnv = tmpKeys.map(key => [key, Bun.env[key]] as const) + // Both the installer and the leak assertions must use this fixture's own + // directory, so other runs cannot change the staged-root snapshots. + // Bun's tmpdir reads its native environment even when another suite has + // replaced process.env, so update and restore both environment objects. + for (const key of tmpKeys) { + process.env[key] = tempDir + Bun.env[key] = tempDir + } // The revocation override is process-global input that the fixtures below // resolve against, and production gives it precedence over the sibling // revocations.json. Own it here: clear any value inherited from the host // process so each fixture reads its own list, and hand it back afterwards. - const inheritedRevocationsUrl = process.env.OPENCLAUDE_SKILLS_REVOCATIONS_URL delete process.env.OPENCLAUDE_SKILLS_REVOCATIONS_URL + const restoreProcess = isolateProcessExitCode(0) try { return await fn(tempDir) } finally { - process.exitCode = 0 - if (inheritedRevocationsUrl === undefined) { - delete process.env.OPENCLAUDE_SKILLS_REVOCATIONS_URL - } else { - process.env.OPENCLAUDE_SKILLS_REVOCATIONS_URL = inheritedRevocationsUrl + try { + restoreEnv() + for (const [key, value] of nativeTmpEnv) { + if (value === undefined) delete Bun.env[key] + else Bun.env[key] = value + } + rmSync(tempDir, { recursive: true, force: true }) + } finally { + restoreProcess() } - rmSync(tempDir, { recursive: true, force: true }) } } +test.serial('restores fixture environment and removes its temp root when a callback throws', async () => { + const keys = ['TMPDIR', 'TMP', 'TEMP', 'OPENCLAUDE_SKILLS_REVOCATIONS_URL'] + const inherited = keys.map(key => process.env[key]) + const inheritedNativeTmp = keys.slice(0, 3).map(key => Bun.env[key]) + const failure = new Error('fixture callback failed') + let fixtureRoot: string | undefined + await assert.rejects(withTempDir(async tempDir => { + fixtureRoot = tempDir + assert.equal(tmpdir(), tempDir) + throw failure + }), error => error === failure) + + assert.deepEqual(keys.map(key => process.env[key]), inherited) + assert.deepEqual(keys.slice(0, 3).map(key => Bun.env[key]), inheritedNativeTmp) + assert.ok(fixtureRoot) + assert.equal(existsSync(fixtureRoot), false) +}) + +for (const change of ['appears', 'disappears'] as const) { + test.serial(`ignores a foreign staged temp root that ${change} during install`, async () => { + const sharedTmpdir = tmpdir() + let foreignRoot: string | undefined + try { + if (change === 'disappears') { + foreignRoot = mkdtempSync(join(sharedTmpdir, 'openclaude-skill-install-test-')) + } + await withTempDir(async tempDir => { + const cwd = join(tempDir, 'project') + const source = writeSkillDir(join(tempDir, 'source')) + mkdirSync(cwd, { recursive: true }) + + const stagedBefore = stagedInstallTempDirs() + // Model another run creating a fixture, or finishing its cleanup, + // between this install's snapshots without relying on timing. + if (change === 'appears') { + foreignRoot = mkdtempSync(join(sharedTmpdir, 'openclaude-skill-install-test-')) + } else { + assert.ok(foreignRoot) + rmSync(foreignRoot, { recursive: true, force: true }) + } + await skillsInstallHandler(join(source, 'SKILL.md'), { projectDir: cwd }) + + assert.equal(process.exitCode, 0) + assert.equal( + readFileSync(join(cwd, '.openclaude', 'skills', 'sample-skill', 'SKILL.md'), 'utf8'), + VALID_SKILL, + ) + assertNoNewStagedInstallDirs(stagedBefore) + }) + } finally { + if (foreignRoot) rmSync(foreignRoot, { recursive: true, force: true }) + } + }) +} + test('formats skills list as an aligned human table', () => { const output = formatSkillsListForDisplay( [ @@ -1213,7 +1286,7 @@ test.serial('rejects direct HTTP URL installs without a sha256 pin', async () => }) }) -test.serial('cleans staged temp roots when markdown staging fails', async () => { +test.serial('rejects oversized skill names before staging', async () => { await withTempDir(async tempDir => { const cwd = join(tempDir, 'project') const source = join(tempDir, 'source', 'SKILL.md') @@ -1234,6 +1307,42 @@ test.serial('cleans staged temp roots when markdown staging fails', async () => }) }) +test.serial('cleans staged temp roots when markdown staging fails', async () => { + await acquireSharedMutationLock('skillsInstallHandler staging failure') + const originalFs = getFsImplementation() + try { + await withTempDir(async tempDir => { + const cwd = join(tempDir, 'project') + const source = writeSkillDir(join(tempDir, 'source')) + mkdirSync(cwd, { recursive: true }) + const stagedBefore = stagedInstallTempDirs() + let stagingWriteAttempted = false + setFsImplementation({ + ...originalFs, + writeFile: async () => { + // A valid skill must reach staging, then fail after mkdtemp. An + // invalid name is rejected before allocation and cannot test cleanup. + assert.equal(stagedInstallTempDirs().length, stagedBefore.length + 1) + stagingWriteAttempted = true + throw new Error('staging write failed') + }, + }) + try { + await skillsInstallHandler(join(source, 'SKILL.md'), { projectDir: cwd }) + + assert.ok(stagingWriteAttempted) + assert.equal(process.exitCode, 1) + assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false) + assertNoNewStagedInstallDirs(stagedBefore) + } finally { + setFsImplementation(originalFs) + } + }) + } finally { + releaseSharedMutationLock() + } +}) + test.serial('removes only the targeted project skill directory', async () => { await acquireSharedMutationLock('skillsRemoveHandler') const originalFs = getFsImplementation() diff --git a/src/cli/handlers/skillsVerify.test.ts b/src/cli/handlers/skillsVerify.test.ts index b61e5930c6..ded1a8cefe 100644 --- a/src/cli/handlers/skillsVerify.test.ts +++ b/src/cli/handlers/skillsVerify.test.ts @@ -3,6 +3,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { test } from 'bun:test' +import { isolateProcessExitCode } from '../../test/processExitCode.js' import { acquireSharedMutationLock, @@ -40,6 +41,7 @@ async function withVerifyFixture( }) => Promise, ): Promise { await acquireSharedMutationLock('skillsVerifyHandler') + const restoreProcess = isolateProcessExitCode(0) const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-skill-verify-test-')) const projectDir = join(tempDir, 'project') const projectSkills = join(projectDir, '.openclaude', 'skills') @@ -77,17 +79,23 @@ async function withVerifyFixture( try { await fn({ tempDir, projectDir, userSkills, projectSkills, registryDir, captured }) } finally { - console.log = saved.log - console.error = saved.error - setClaudeConfigHomeDirForTesting(undefined) - setEyebrowRunnerForTesting(undefined) - process.exitCode = 0 - restoreEnv('PATH', saved.path) - restoreEnv('OPENCLAUDE_EYEBROW_BIN', saved.bin) - restoreEnv('OPENCLAUDE_SKILLS_REVOCATIONS_URL', saved.revocationsUrl) - restoreEnv('OPENCLAUDE_SKILLS_REGISTRY_URL', saved.registryUrl) - rmSync(tempDir, { recursive: true, force: true }) - releaseSharedMutationLock() + try { + console.log = saved.log + console.error = saved.error + setClaudeConfigHomeDirForTesting(undefined) + setEyebrowRunnerForTesting(undefined) + restoreEnv('PATH', saved.path) + restoreEnv('OPENCLAUDE_EYEBROW_BIN', saved.bin) + restoreEnv('OPENCLAUDE_SKILLS_REVOCATIONS_URL', saved.revocationsUrl) + restoreEnv('OPENCLAUDE_SKILLS_REGISTRY_URL', saved.registryUrl) + rmSync(tempDir, { recursive: true, force: true }) + } finally { + try { + restoreProcess() + } finally { + releaseSharedMutationLock() + } + } } } diff --git a/src/cli/handlers/xaiAuth.test.ts b/src/cli/handlers/xaiAuth.test.ts index 9723efbc97..7f3ed1bcf1 100644 --- a/src/cli/handlers/xaiAuth.test.ts +++ b/src/cli/handlers/xaiAuth.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os' import { join } from 'node:path' +import { getCwdState, setCwdState } from '../../bootstrap/state.js' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -19,10 +20,13 @@ const SAVED_ENV = { let tempConfigDir = '' let tempCwd = '' +let originalSessionCwd: string const originalCwd = process.cwd() beforeEach(async () => { await acquireSharedMutationLock('cli/handlers/xaiAuth.test.ts') + // Initialize session state before lazy handler imports see the temporary cwd. + originalSessionCwd = getCwdState() tempConfigDir = mkdtempSync(join(tmpdir(), 'openclaude-xai-cli-config-')) tempCwd = mkdtempSync(join(tmpdir(), 'openclaude-xai-cli-cwd-')) process.chdir(tempCwd) @@ -39,6 +43,7 @@ afterEach(() => { try { setClaudeConfigHomeDirForTesting(undefined) process.chdir(originalCwd) + setCwdState(originalSessionCwd) rmSync(tempConfigDir, { recursive: true, force: true }) rmSync(tempCwd, { recursive: true, force: true }) if (SAVED_ENV.CLAUDE_CONFIG_DIR === undefined) { diff --git a/src/cli/printMaxTurns.test.ts b/src/cli/printMaxTurns.test.ts index a18bc682b3..919ca07456 100644 --- a/src/cli/printMaxTurns.test.ts +++ b/src/cli/printMaxTurns.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import { settingsChangeDetector } from '../utils/settings/changeDetector.js' import type { Command } from '../commands.js' import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' import * as queryEngineModule from '../QueryEngine.js' @@ -135,18 +140,6 @@ function createHeadlessRunOptions(maxTurns: number | undefined) { } } -async function waitForAskCall( - askSpy: ReturnType>, -): Promise { - const deadline = Date.now() + 15_000 - while (askSpy.mock.calls.length === 0) { - if (Date.now() >= deadline) { - throw new Error('Timed out waiting for runHeadless to call ask()') - } - await new Promise(resolve => setTimeout(resolve, 25)) - } -} - describe('headless --print max-turns', () => { const savedSimple = process.env.CLAUDE_CODE_SIMPLE @@ -182,46 +175,61 @@ describe('headless --print max-turns', () => { }) test('forwards maxTurns 0 through runHeadless into ask()', async () => { - process.env.CLAUDE_CODE_SIMPLE = '1' - - const shutdownSpy = spyOn(shutdownModule, 'gracefulShutdownSync').mockImplementation(() => {}) - const stdoutSpy = spyOn(processModule, 'writeToStdout').mockImplementation( - () => {}, - ) - const askSpy = spyOn(queryEngineModule, 'ask').mockImplementation( - async function* () { - yield createHeadlessSuccessResult() - }, - ) - - let state = getDefaultAppState() - const getAppState = () => state - const setAppState = (update: (previous: AppState) => AppState) => { - state = update(state) - } - - let runPromise: Promise | undefined + await acquireSharedMutationLock('cli/printMaxTurns.test.ts') try { - runPromise = runHeadless( - 'headless prompt', - getAppState, - setAppState, - [] as Command[], - [] as Tools, - {}, - [], - createHeadlessRunOptions(0), + const unsubscribes: Array<() => void> = [] + const realSubscribe = settingsChangeDetector.subscribe + const subscribeSpy = spyOn(settingsChangeDetector, 'subscribe').mockImplementation( + listener => { + const unsubscribe = realSubscribe(listener) + unsubscribes.push(unsubscribe) + return unsubscribe + }, ) + process.env.CLAUDE_CODE_SIMPLE = '1' - await waitForAskCall(askSpy) - expect(askSpy.mock.calls[0]?.[0]?.maxTurns).toBe(0) - await runPromise - expect(shutdownSpy).toHaveBeenCalledWith(0) + const shutdownSpy = spyOn(shutdownModule, 'gracefulShutdownSync').mockImplementation(() => {}) + const stdoutSpy = spyOn(processModule, 'writeToStdout').mockImplementation( + () => {}, + ) + const askSpy = spyOn(queryEngineModule, 'ask').mockImplementation( + async function* () { + yield createHeadlessSuccessResult() + }, + ) + + let state = getDefaultAppState() + const getAppState = () => state + const setAppState = (update: (previous: AppState) => AppState) => { + state = update(state) + } + + let runPromise: Promise | undefined + try { + runPromise = runHeadless( + 'headless prompt', + getAppState, + setAppState, + [] as Command[], + [] as Tools, + {}, + [], + createHeadlessRunOptions(0), + ) + + await runPromise + expect(askSpy.mock.calls[0]?.[0]?.maxTurns).toBe(0) + expect(shutdownSpy).toHaveBeenCalledWith(0) + } finally { + await runPromise?.catch(() => {}) + for (const unsubscribe of unsubscribes) unsubscribe() + subscribeSpy.mockRestore() + askSpy.mockRestore() + stdoutSpy.mockRestore() + shutdownSpy.mockRestore() + } } finally { - await runPromise?.catch(() => {}) - askSpy.mockRestore() - stdoutSpy.mockRestore() - shutdownSpy.mockRestore() + releaseSharedMutationLock() } }) }) diff --git a/src/cli/structuredIO.ts b/src/cli/structuredIO.ts index f3b2257f13..3f0b7c2936 100644 --- a/src/cli/structuredIO.ts +++ b/src/cli/structuredIO.ts @@ -44,14 +44,22 @@ import { import { writeToStdout } from 'src/utils/process.js' import { jsonStringify } from 'src/utils/slowOperations.js' import { z } from 'zod/v4' +import { clearCACertsCache } from '../utils/caCerts.js' import { notifyCommandLifecycle } from '../utils/commandLifecycle.js' import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js' import { executePermissionRequestHooks } from '../utils/hooks.js' +import { clearMTLSCache } from '../utils/mtls.js' import { filterPermissionRequestHookUpdates, persistPermissionUpdates, } from '../utils/permissions/PermissionUpdate.js' import { applyPermissionUpdatesToLiveContext } from '../utils/permissions/permissionSetup.js' +import { + AGENT_CONFIG_ENV_VARS, + clearProxyCache, + configureGlobalAgents, + getNoProxy, +} from '../utils/proxy.js' import { notifySessionStateChanged, type RequiresActionDetails, @@ -126,7 +134,8 @@ function buildRequiresActionDetails( type PendingRequest = { resolve: (result: T) => void reject: (error: unknown) => void - schema?: z.Schema + // Response consumers only parse; avoid comparing Zod's fluent builder types. + schema?: Pick request: SDKControlRequest } @@ -357,12 +366,23 @@ export class StructuredIO { // Used by bridge session runner for auth token refresh // (CLAUDE_CODE_SESSION_ACCESS_TOKEN) which must be readable // by the REPL process itself, not just child Bash commands. + const previousNoProxy = getNoProxy() const keys = Object.keys(message.variables) + let agentConfigUpdated = false for (const [key, value] of Object.entries(message.variables)) { if (typeof value === 'string') { process.env[key] = value + agentConfigUpdated ||= AGENT_CONFIG_ENV_VARS.has(key) } } + // Rebuild cached/global agents for TLS/proxy settings and effective + // bypass changes, including wildcard normalization pinned by undici. + if (agentConfigUpdated || getNoProxy() !== previousNoProxy) { + clearCACertsCache() + clearMTLSCache() + clearProxyCache() + configureGlobalAgents() + } logForDebugging( `[structuredIO] applied update_environment_variables: ${keys.join(', ')}`, ) @@ -477,7 +497,7 @@ export class StructuredIO { private async sendRequest( request: SDKControlRequest['request'], - schema: z.Schema, + schema: Pick, signal?: AbortSignal, requestId: string = randomUUID(), ): Promise { @@ -779,7 +799,7 @@ export class StructuredIO { message, }, z.object({ - mcp_response: z.any() as z.Schema, + mcp_response: z.any() as z.core.$ZodType, }), ) return response.mcp_response diff --git a/src/cli/transports/HybridTransport.test.ts b/src/cli/transports/HybridTransport.test.ts index 9148d3f850..a94f3a0c77 100644 --- a/src/cli/transports/HybridTransport.test.ts +++ b/src/cli/transports/HybridTransport.test.ts @@ -1,6 +1,14 @@ -import { afterEach, beforeEach, describe, expect, jest, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, describe, expect, jest, mock, test } from 'bun:test' import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js' +const realAxios = { + ...await import(`${import.meta.resolve('axios')}?real=${Date.now()}-${Math.random()}`), +} as typeof import('axios') + +afterAll(() => { + mock.module('axios', () => realAxios) +}) + type AxiosPost = ( url: string, data?: unknown, diff --git a/src/commands/clear/conversation.ts b/src/commands/clear/conversation.ts index 87e08ad45e..a42aa115b0 100644 --- a/src/commands/clear/conversation.ts +++ b/src/commands/clear/conversation.ts @@ -18,6 +18,7 @@ import type { AppState } from '../../state/AppState.js' import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js' import { isLocalAgentTask, + initAgentTaskOutput, type LocalAgentTaskState, } from '../../tasks/LocalAgentTask/LocalAgentTask.js' import { isLocalShellTask } from '../../tasks/LocalShellTask/guards.js' @@ -43,7 +44,6 @@ import { } from '../../utils/sessionStorage.js' import { evictTaskOutput, - initTaskOutputAsSymlink, } from '../../utils/task/diskOutput.js' import { getCurrentWorktreeSession } from '../../utils/worktree.js' import { clearSessionCaches } from './caches.js' @@ -221,18 +221,27 @@ export async function clearConversation({ // Preserved local_agent tasks had their TaskOutput symlink baked against the // old session ID at spawn time, but post-clear transcript writes land under - // the new session directory (appendEntry re-reads getSessionId()). Re-point + // the new session directory (new chains capture the current session). Re-point // the symlinks so TaskOutput reads the live file instead of a frozen pre-clear // snapshot. Only re-point running tasks — finished tasks will never write // again, so re-pointing would replace a valid symlink with a dangling one. // Main-session tasks use the same per-agent path (they write via // recordSidechainTranscript to getAgentTranscriptPath), so no special case. for (const task of preservedLocalAgents) { - if (task.status !== 'running') continue - void initTaskOutputAsSymlink( - task.id, - getAgentTranscriptPath(asAgentId(task.agentId)), - ) + // Re-read after clear's awaits: completion may have finished the producer + // since preservedLocalAgents was captured, without publishing success yet. + const currentTask = getAppState?.().tasks[task.id] + if (!isLocalAgentTask(currentTask) || currentTask.status !== 'running' || currentTask.producerFinished) continue + const destination = getAgentTranscriptPath(asAgentId(task.agentId)) + const outputReady = initAgentTaskOutput(task.id, destination) + void outputReady.catch(() => {}) + // Publish before yielding so completion joins this replacement. Once + // completion starts, producerFinished prevents any further retargeting. + setAppState?.(prev => { + const current = prev.tasks[task.id] + if (!isLocalAgentTask(current) || current.status !== 'running' || current.producerFinished) return prev + return { ...prev, tasks: { ...prev.tasks, [task.id]: { ...current, outputReady } } } + }) } // Re-persist mode and worktree state after the clear so future --resume diff --git a/src/commands/copy/copy.tsx b/src/commands/copy/copy.tsx index d540cdff2c..88d99e5f7a 100644 --- a/src/commands/copy/copy.tsx +++ b/src/commands/copy/copy.tsx @@ -34,7 +34,9 @@ function extractCodeBlocks(markdown: string): CodeBlock[] { if (token.type === 'code') { const codeToken = token as Tokens.Code; blocks.push({ - code: codeToken.text, + code: codeToken.codeBlockStyle === 'indented' + ? codeToken.text.replace(/\n+$/, '') + : codeToken.text, lang: codeToken.lang }); } diff --git a/src/commands/effort/effort.test.tsx b/src/commands/effort/effort.test.tsx index 735e2e57b4..9532211fe3 100644 --- a/src/commands/effort/effort.test.tsx +++ b/src/commands/effort/effort.test.tsx @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, expect, mock, test } from 'bun:test' import React from 'react' import { render } from '../../ink.js' @@ -9,12 +9,40 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' -import * as actualAuth from '../../utils/auth.js' -import * as actualModelSupportOverrides from '../../utils/model/modelSupportOverrides.js' -import * as actualProviders from '../../utils/model/providers.js' -import * as actualSettings from '../../utils/settings/settings.js' -import * as actualThinking from '../../utils/thinking.js' -import * as actualGrowthbook from '../../services/analytics/growthbook.js' + +const nonce = `${Date.now()}-${Math.random()}` +const actualAuth = { + ...await import(`../../utils/auth.js?real=${nonce}`), +} as typeof import('../../utils/auth.js') +const actualModelSupportOverrides = { + ...await import(`../../utils/model/modelSupportOverrides.js?real=${nonce}`), +} as typeof import('../../utils/model/modelSupportOverrides.js') +const actualSettings = { + ...await import(`../../utils/settings/settings.js?real=${nonce}`), +} as typeof import('../../utils/settings/settings.js') +const actualThinking = { + ...await import(`../../utils/thinking.js?real=${nonce}`), +} as typeof import('../../utils/thinking.js') +const actualGrowthbook = { + ...await import(`../../services/analytics/growthbook.js?real=${nonce}`), +} as typeof import('../../services/analytics/growthbook.js') +const realEffortPicker = { + ...await import(`../../components/EffortPicker.js?real=${nonce}`), +} as typeof import('../../components/EffortPicker.js') + +const providerSpecifier = '../../utils/model/providers.js' +const realProvidersForCleanup = { + ...await import( + `${providerSpecifier}?real=${Date.now()}-${Math.random()}` + ), +} + +afterAll(() => { + mock.restore() + mock.module(providerSpecifier, () => realProvidersForCleanup) +}) + +const actualProviders = realProvidersForCleanup const originalEffortEnv = process.env.CLAUDE_CODE_EFFORT_LEVEL @@ -26,6 +54,13 @@ beforeEach(async () => { afterEach(() => { try { mock.restore() + mock.module('../../utils/auth.js', () => actualAuth) + mock.module('../../utils/model/modelSupportOverrides.js', () => actualModelSupportOverrides) + mock.module('../../utils/settings/settings.js', () => actualSettings) + mock.module('../../utils/thinking.js', () => actualThinking) + mock.module('../../services/analytics/growthbook.js', () => actualGrowthbook) + mock.module('../../utils/model/providers.js', () => realProvidersForCleanup) + mock.module('../../components/EffortPicker.js', () => realEffortPicker) if (originalEffortEnv === undefined) { delete process.env.CLAUDE_CODE_EFFORT_LEVEL } else { diff --git a/src/commands/install-github-app/setupGitHubActions.test.ts b/src/commands/install-github-app/setupGitHubActions.test.ts index b7e114170c..15e2fb4a67 100644 --- a/src/commands/install-github-app/setupGitHubActions.test.ts +++ b/src/commands/install-github-app/setupGitHubActions.test.ts @@ -6,6 +6,7 @@ import { } from '../../test/sharedMutationLock.js' type BrowserModule = typeof import('../../utils/browser.js') +type ConfigModule = typeof import('../../utils/config.js') type ExecFileNoThrowModule = typeof import('../../utils/execFileNoThrow.js') type ExecCall = { @@ -15,6 +16,7 @@ type ExecCall = { type RealModules = { browser: BrowserModule + config: ConfigModule execFileNoThrow: ExecFileNoThrowModule } @@ -41,12 +43,15 @@ async function importRealModules(): Promise { const cacheKey = `${Date.now()}-${Math.random()}` realModules = { - browser: (await import( - `../../utils/browser.ts?setup-actions-real-${cacheKey}` - )) as BrowserModule, - execFileNoThrow: (await import( - `../../utils/execFileNoThrow.ts?setup-actions-real-${cacheKey}` - )) as ExecFileNoThrowModule, + browser: { + ...await import(`../../utils/browser.ts?setup-actions-real-${cacheKey}`), + } as BrowserModule, + config: { + ...await import(`../../utils/config.js?real=${cacheKey}`), + } as ConfigModule, + execFileNoThrow: { + ...await import(`../../utils/execFileNoThrow.ts?setup-actions-real-${cacheKey}`), + } as ExecFileNoThrowModule, } return realModules } @@ -212,6 +217,8 @@ afterEach(() => { try { mock.restore() if (realModules) { + mock.module('src/utils/config.js', () => realModules!.config) + mock.module('../../utils/config.js', () => realModules!.config) mock.module( '../../utils/execFileNoThrow.js', () => realModules!.execFileNoThrow, diff --git a/src/commands/model/model.fastModeSwitch.test.ts b/src/commands/model/model.fastModeSwitch.test.ts index 04809d31cf..8857b52f82 100644 --- a/src/commands/model/model.fastModeSwitch.test.ts +++ b/src/commands/model/model.fastModeSwitch.test.ts @@ -1,6 +1,9 @@ import { afterEach, expect, mock, test } from 'bun:test' -import * as actualFastMode from '../../utils/fastMode.js' +const nonce = `${Date.now()}-${Math.random()}` +const actualFastMode = { + ...await import(`../../utils/fastMode.js?real=${nonce}`), +} as typeof import('../../utils/fastMode.js') // Regression for #1119 / jatmn review on PR #1164 — the cross-profile // `/model` branch must run the same fast-mode reconciliation as the regular @@ -9,6 +12,7 @@ import * as actualFastMode from '../../utils/fastMode.js' afterEach(() => { mock.restore() + mock.module('../../utils/fastMode.js', () => actualFastMode) }) function mockFastMode( diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index 6f05c5ad39..c01c0c8989 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -1,6 +1,7 @@ import { PassThrough } from 'node:stream' +import { snapshotEnv } from '../../test/envSnapshot.js' -import { afterEach, beforeEach, expect, mock, setSystemTime, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, expect, mock, setSystemTime, test } from 'bun:test' import React from 'react' import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js' @@ -22,20 +23,63 @@ import { getRouteDescriptor, } from '../../integrations/index.js' import { mergeRouteCatalogEntries } from '../../utils/model/routeCatalogOptions.js' -import * as actualFastModeForModelTest from '../../utils/fastMode.js' -import * as actualExtraUsageForModelTest from '../../utils/extraUsage.js' -import * as actualDiscoveryServiceForModelTest from '../../integrations/discoveryService.js' -import * as actualDiscoveryCacheForModelTest from '../../integrations/discoveryCache.js' -const REAL_DISCOVERY_SERVICE_FOR_MODEL_TEST = { ...actualDiscoveryServiceForModelTest } -const REAL_DISCOVERY_CACHE_FOR_MODEL_TEST = { ...actualDiscoveryCacheForModelTest } +const nonce = `${Date.now()}-${Math.random()}` +const realSettings = { + ...await import(`../../utils/settings/settings.js?real=${nonce}`), +} as typeof import('../../utils/settings/settings.js') +const realModelAllowlist = { + ...await import(`../../utils/model/modelAllowlist.js?real=${nonce}`), +} as typeof import('../../utils/model/modelAllowlist.js') +const realConfig = { + ...await import(`../../utils/config.js?real=${nonce}`), +} as typeof import('../../utils/config.js') +const realModelPicker = { + ...await import(`../../components/ModelPicker.js?real=${nonce}`), +} as typeof import('../../components/ModelPicker.js') +const realOpenAIModelDiscovery = { + ...await import(`../../utils/model/openaiModelDiscovery.js?real=${nonce}`), +} as typeof import('../../utils/model/openaiModelDiscovery.js') + +const REAL_DISCOVERY_SERVICE_FOR_MODEL_TEST = { + ...await import(`../../integrations/discoveryService.js?real=${nonce}`), +} +const REAL_DISCOVERY_CACHE_FOR_MODEL_TEST = { + ...await import(`../../integrations/discoveryCache.js?real=${nonce}`), +} // Snapshot the real fast-mode module up front so the cross-profile switch test // can mock it with a full surface and restore the real one afterwards. -const REAL_FAST_MODE_FOR_MODEL_TEST = { ...actualFastModeForModelTest } +const REAL_FAST_MODE_FOR_MODEL_TEST = { + ...await import(`../../utils/fastMode.js?real=${nonce}`), +} // Same for extraUsage, so the cross-profile confirmation test can force the // `Billed as extra usage` branch and restore the real surface afterwards. -const REAL_EXTRA_USAGE_FOR_MODEL_TEST = { ...actualExtraUsageForModelTest } +const REAL_EXTRA_USAGE_FOR_MODEL_TEST = { + ...await import(`../../utils/extraUsage.js?real=${nonce}`), +} +const REAL_PROVIDER_PROFILES_FOR_MODEL_TEST = { + ...await import(`../../utils/providerProfiles.js?real=${nonce}`), +} + +function restoreDiscoveryModules(): void { + mock.module( + '../../integrations/discoveryService.js', + () => REAL_DISCOVERY_SERVICE_FOR_MODEL_TEST, + ) + mock.module( + '../../integrations/discoveryCache.js', + () => REAL_DISCOVERY_CACHE_FOR_MODEL_TEST, + ) +} + +afterAll(() => { + mock.restore() + mock.module('../../utils/providerProfiles.js', () => REAL_PROVIDER_PROFILES_FOR_MODEL_TEST) + restoreDiscoveryModules() + mock.module('../../utils/fastMode.js', () => REAL_FAST_MODE_FOR_MODEL_TEST) + mock.module('../../utils/extraUsage.js', () => REAL_EXTRA_USAGE_FOR_MODEL_TEST) +}) type SettingsModule = typeof import('../../utils/settings/settings.js') @@ -226,6 +270,11 @@ beforeEach(async () => { afterEach(() => { try { mock.restore() + mock.module('../../utils/settings/settings.js', () => realSettings) + mock.module('../../utils/model/modelAllowlist.js', () => realModelAllowlist) + mock.module('../../utils/config.js', () => realConfig) + mock.module('../../components/ModelPicker.js', () => realModelPicker) + mock.module('../../utils/model/openaiModelDiscovery.js', () => realOpenAIModelDiscovery) resetSettingsCache() settingsForTest = {} restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) @@ -650,6 +699,70 @@ test('/model still applies a Claude alias on a non-Command-Code OpenAI route', a expect(messages[0]).not.toContain('requires the Anthropic Messages protocol') }) +test.each([ + ['MiniMax-M2.5', true], + ['MiniMax-M2.7', true], + ['MiniMax-Text-01', true], + ['abab6.5s-chat', true], + ['abab6.5-chat', true], + ['abab6.5g-chat', true], + ['abab6-chat', true], + ['MiniMax-M3', true], + ['MiniMax-M3.1-Flash', true], + ['custom-minimax-deployment', false], +])('/model preserves explicit MiniMax selection validity for %s', async (model, valid) => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.minimax.io/v1' + process.env.OPENAI_API_KEY = 'minimax-test-key' + process.env.OPENAI_MODEL = 'MiniMax-M3.1-Flash' + for (const key of [ + 'OPENAI_API_BASE', 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_MISTRAL', 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + ]) delete process.env[key] + mockProviderProfiles() + + const { call } = await importFreshModelModule(`minimax-explicit-${model}`) + const { AppStateProvider, getDefaultAppState, useAppState } = await import('../../state/AppState.js') + const { render } = await import('../../ink.js') + const messages: string[] = [] + let selectedModel: ModelSetting | undefined + function CaptureModel() { + selectedModel = useAppState(state => state.mainLoopModel) + return null + } + const stdout = new PassThrough() + ;(stdout as unknown as { columns: number }).columns = 120 + const originalFetch = globalThis.fetch + const fetch = mock(async () => { throw new Error('Known model validation must not call the API') }) + globalThis.fetch = fetch as unknown as typeof globalThis.fetch + let instance: Awaited> | undefined + try { + const element = await call(result => { if (result) messages.push(result) }, {} as never, model) + instance = await render( + + {element} + + , + stdout as unknown as NodeJS.WriteStream, + ) + await waitForCondition(() => messages.length > 0) + if (valid) { + expect(messages[0]).toContain('Set model to') + await waitForCondition(() => selectedModel === model) + expect(selectedModel).toBe(model) + } else { + expect(messages[0]).toContain(`Model '${model}' not found in MiniMax catalog`) + expect(selectedModel).toBe('MiniMax-M3.1-Flash') + } + expect(fetch).not.toHaveBeenCalled() + } finally { + instance?.unmount() + stdout.end() + globalThis.fetch = originalFetch + } +}) + test('opens the model picker without awaiting descriptor-backed route refresh', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' @@ -3254,6 +3367,54 @@ test('/model refresh treats empty legacy OpenAI-compatible discovery as failed n ) }) +test.each([ + ['ollama', 'http://localhost:11434/v1'], + ['lmstudio', 'http://localhost:1234/v1'], + ['atomic-chat', 'http://127.0.0.1:1337/v1'], + ['openrouter', 'https://openrouter.ai/api/v1'], +])('%s picker retains advertised MiniMax models before and after refresh', async (provider, baseUrl) => { + const restoreEnv = snapshotEnv([ + 'CLAUDE_CODE_USE_OPENAI', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', + 'OPENAI_API_KEY', 'OPENAI_MODEL', 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + ], { clear: true }) + let rendered: Awaited> | undefined + try { + const activeProfile = { + id: 'local-profile', name: provider, provider, + baseUrl, model: 'qwen3:latest', apiKey: '', + } + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = activeProfile.baseUrl + process.env.OPENAI_MODEL = activeProfile.model + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1' + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id + mockProviderProfiles({ getActiveProviderProfile: () => activeProfile }) + const models = provider === 'openrouter' + ? ['minimax/minimax-m2.5', 'minimax/minimax-m2.7'] + : ['minimax-m3.1-flash:cloud', 'minimax-m3:cloud', 'minimax-m2.7:cloud', 'minimaxai/minimax-m2.7', 'minimax-m3.1-flash-q4_k_m'] + mockDescriptorDiscovery({ + routeId: provider, + cachedModels: [{ id: models[0]!, apiName: models[0]! }], + discoveredModels: models.map(id => ({ id, apiName: id })), + }) + rendered = await renderModelCommandWithCapturedPicker(`${provider}-minimax-discovery`) + const pickerValues = () => (rendered!.getCapturedProps().optionsOverride as ModelOption[]).map(option => option.value) + expect(pickerValues()).toContain(models[0]) + expect(pickerValues()).not.toContain(models[1]) + expect(rendered.getCapturedProps().onRefresh).toEqual(expect.any(Function)) + rendered.getCapturedProps().onRefresh!() + await waitForCondition(() => pickerValues().includes(models[1]!)) + for (const model of models) expect(pickerValues()).toContain(model) + } finally { + rendered?.instance.unmount() + rendered?.stdout.end() + restoreEnv() + } +}) + test('interactive model picker refresh keeps descriptor options allowlist-filtered', async () => { useSettings({ availableModels: ['allowed-route'] } as SettingsJson) const activeProfile = { @@ -3429,18 +3590,22 @@ test('/model does not auto-refresh descriptor models when nonessential traffic i discoverModelsForRoute, })) - mockProviderProfiles({ - getActiveOpenAIModelOptionsCache: () => [], - getActiveProviderProfile: () => undefined, - getProfileModelOptions: () => [], - setActiveOpenAIModelOptionsCache: () => {}, - }) + try { + mockProviderProfiles({ + getActiveOpenAIModelOptionsCache: () => [], + getActiveProviderProfile: () => undefined, + getProfileModelOptions: () => [], + setActiveOpenAIModelOptionsCache: () => {}, + }) - const { call } = await importFreshModelModule('descriptor-privacy-open') - const result = await call(() => {}, {} as never, '') + const { call } = await importFreshModelModule('descriptor-privacy-open') + const result = await call(() => {}, {} as never, '') - expect(result).toBeTruthy() - expect(discoverModelsForRoute).not.toHaveBeenCalled() + expect(result).toBeTruthy() + expect(discoverModelsForRoute).not.toHaveBeenCalled() + } finally { + restoreDiscoveryModules() + } }) test('cross-profile /model switch drops latched fast mode before activating an unsupported target profile (#1119)', async () => { diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index c1dc23ad67..4875b9ef98 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -497,6 +497,7 @@ async function loadDescriptorDiscoveryContext( routeLabel, staticEntries, routeDefaultModel, + routeId, ) return { @@ -553,6 +554,7 @@ async function loadDescriptorDiscoveryContext( routeLabel, mergedEntries, routeDefaultModel, + routeId, ) return { @@ -933,6 +935,7 @@ function ModelPickerWrapper({ discoveryContext.routeLabel, result?.models ?? [], discoveryContext.routeDefaultModel, + discoveryContext.routeId, ), { profileModelSurface: discoveryContext.profileModelSurface, @@ -1285,6 +1288,7 @@ async function refreshModelsAndSummarize(): Promise { discoveryContext.routeLabel, result?.models ?? [], discoveryContext.routeDefaultModel, + discoveryContext.routeId, ), { profileModelSurface: discoveryContext.profileModelSurface, diff --git a/src/commands/onboard-github/onboard-github.test.ts b/src/commands/onboard-github/onboard-github.test.ts index 4177433caa..56ec5e8e2c 100644 --- a/src/commands/onboard-github/onboard-github.test.ts +++ b/src/commands/onboard-github/onboard-github.test.ts @@ -97,6 +97,7 @@ describe('onboarding auth precedence cleanup', () => { GITHUB_ENTERPRISE_URL: 'https://github.old.example.com', CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1', CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'profile_old', + CLAUDE_CODE_USE_GEMINI_VERTEX: '1', } applyGithubOnboardingProcessEnv('github:copilot', undefined, env) @@ -115,6 +116,9 @@ describe('onboarding auth precedence cleanup', () => { expect(env.GITHUB_ENTERPRISE_URL).toBeUndefined() expect(env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() + // The fast path must clear Gemini Vertex routing too, like the + // device-flow finalize path (PROVIDER_SPECIFIC_KEYS). + expect(env.CLAUDE_CODE_USE_GEMINI_VERTEX).toBeUndefined() expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined() expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined() @@ -127,6 +131,7 @@ describe('onboarding auth precedence cleanup', () => { expect(settingsEnv.OPENAI_PROJECT).toBeUndefined() expect(settingsEnv.OPENAI_ORGANIZATION).toBeUndefined() expect(settingsEnv.GITHUB_ENTERPRISE_URL).toBeUndefined() + expect(settingsEnv.CLAUDE_CODE_USE_GEMINI_VERTEX).toBeUndefined() }) test('persists Enterprise URL when switching to GitHub Enterprise', () => { diff --git a/src/commands/onboard-github/onboard-github.tsx b/src/commands/onboard-github/onboard-github.tsx index 78229de962..88e43521d4 100644 --- a/src/commands/onboard-github/onboard-github.tsx +++ b/src/commands/onboard-github/onboard-github.tsx @@ -39,6 +39,7 @@ type Step = 'menu' | 'ghe-url' | 'copilot-key' | 'device-busy' | 'error' const PROVIDER_SPECIFIC_KEYS = new Set([ 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GEMINI_VERTEX', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', @@ -113,6 +114,7 @@ export function buildGithubOnboardingSettingsEnv( OPENAI_API_BASE: undefined, CLAUDE_CODE_USE_OPENAI: undefined, CLAUDE_CODE_USE_GEMINI: undefined, + CLAUDE_CODE_USE_GEMINI_VERTEX: undefined, CLAUDE_CODE_USE_BEDROCK: undefined, CLAUDE_CODE_USE_VERTEX: undefined, CLAUDE_CODE_USE_FOUNDRY: undefined, @@ -143,6 +145,7 @@ export function applyGithubOnboardingProcessEnv( delete env.CLAUDE_CODE_USE_OPENAI delete env.CLAUDE_CODE_USE_GEMINI + delete env.CLAUDE_CODE_USE_GEMINI_VERTEX delete env.CLAUDE_CODE_USE_BEDROCK delete env.CLAUDE_CODE_USE_VERTEX delete env.CLAUDE_CODE_USE_FOUNDRY diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index d3d3d3183f..ee51c97573 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -23,6 +23,11 @@ import { } from '../../test/sharedMutationLock.js' import { createProfileFile } from '../../utils/providerProfile.js' +const nonce = `${Date.now()}-${Math.random()}` +const realProviderConfig = { + ...await import(`../../services/api/providerConfig.js?real=${nonce}`), +} as typeof import('../../services/api/providerConfig.js') + const SYNC_START = '\x1B[?2026h' const SYNC_END = '\x1B[?2026l' const ORIGINAL_SIMPLE_ENV = process.env.CLAUDE_CODE_SIMPLE @@ -169,6 +174,7 @@ beforeEach(async () => { afterEach(() => { try { mock.restore() + mock.module('../../services/api/providerConfig.js', () => realProviderConfig) if (ORIGINAL_SIMPLE_ENV === undefined) { delete process.env.CLAUDE_CODE_SIMPLE @@ -809,3 +815,16 @@ test('ProviderWizard hides Codex OAuth while running in bare mode', async () => expect(output).toContain('Set up a provider profile') expect(output).not.toContain('Codex OAuth') }) + +test('buildCurrentProviderSummary recognizes the env-only native Vertex route', () => { + const summary = buildCurrentProviderSummary({ + processEnv: { + CLAUDE_CODE_USE_GEMINI_VERTEX: '1', GEMINI_VERTEX_PROJECT: 'my-project', + GEMINI_VERTEX_LOCATION: 'europe-west4', GEMINI_VERTEX_MODEL: 'gemini-2.5-pro', + }, + persisted: null, + }) + expect(summary.providerLabel).toBe('Google Vertex AI Gemini') + expect(summary.modelLabel).toBe('gemini-2.5-pro') + expect(summary.endpointLabel).toBe('https://aiplatform.googleapis.com/v1/projects/my-project/locations/europe-west4') +}) diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 4f0269f37a..12a5a83c71 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -1,3 +1,4 @@ +import { getGeminiVertexModel, getGeminiVertexLocation, getGeminiVertexProjectId, geminiVertexAdcWillResolveProject } from '../../utils/geminiAuth.js' import * as React from 'react' import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js' @@ -291,6 +292,18 @@ export function buildCurrentProviderSummary(options?: { const persisted = options?.persisted ?? loadProfileFile() const savedProfileLabel = persisted?.profile ?? 'none' + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI_VERTEX)) { + const project = getGeminiVertexProjectId(processEnv) ?? + (geminiVertexAdcWillResolveProject(processEnv) ? '' : '') + const endpoint = `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${getGeminiVertexLocation(processEnv)}` + return { + providerLabel: 'Google Vertex AI Gemini', + modelLabel: getSafeDisplayValue(getGeminiVertexModel(processEnv), secretSource), + endpointLabel: getSafeDisplayValue(endpoint, secretSource), + savedProfileLabel, + } + } + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) { const geminiMetadata = getProviderPresetUiMetadata('gemini', processEnv) return { diff --git a/src/commands/replay/replay.test.tsx b/src/commands/replay/replay.test.tsx index 7bfa6e17cc..8c1a4093fe 100644 --- a/src/commands/replay/replay.test.tsx +++ b/src/commands/replay/replay.test.tsx @@ -10,13 +10,29 @@ import { AppStateProvider } from '../../state/AppState.js' import type { LogOption, ReplayIndex } from '../../types/logs.js' import { renderToString } from '../../utils/staticRender.js' import * as realBootstrapState from '../../bootstrap/state.js' -import * as realGetWorktreePaths from '../../utils/getWorktreePaths.js' -import * as realSessionStorage from '../../utils/sessionStorage.js' -import * as realReplayIndex from '../../utils/replayIndex.js' -import * as realUseTerminalSize from '../../hooks/useTerminalSize.js' -import * as realModalContext from '../../context/modalContext.js' -import * as realLogSelector from '../../components/LogSelector.js' -import * as realReplayTimeline from './ReplayTimeline.js' + +const nonce = `${Date.now()}-${Math.random()}` +const realGetWorktreePaths = { + ...await import(`../../utils/getWorktreePaths.js?real=${nonce}`), +} +const realSessionStorage = { + ...await import(`../../utils/sessionStorage.js?real=${nonce}`), +} +const realReplayIndex = { + ...await import(`../../utils/replayIndex.js?real=${nonce}`), +} +const realUseTerminalSize = { + ...await import(`../../hooks/useTerminalSize.js?real=${nonce}`), +} +const realModalContext = { + ...await import(`../../context/modalContext.js?real=${nonce}`), +} +const realLogSelector = { + ...await import(`../../components/LogSelector.js?real=${nonce}`), +} +const realReplayTimeline = { + ...await import(`./ReplayTimeline.js?real=${nonce}`), +} type LogSelectorProps = { logs: LogOption[] @@ -130,6 +146,7 @@ describe('/replay command', () => { loadSameRepoMessageLogs: loadSameRepoMessageLogsMock, })) mock.module('../../utils/replayIndex.js', () => ({ + ...realReplayIndex, loadReplayIndex: loadReplayIndexMock, })) mock.module('../../hooks/useTerminalSize.js', () => ({ @@ -139,6 +156,7 @@ describe('/replay command', () => { useIsInsideModal: () => false, })) mock.module('../../components/LogSelector.js', () => ({ + ...realLogSelector, LogSelector: (props: LogSelectorProps) => { lastLogSelectorProps = props return {`LogSelector:${props.logs.length}`} diff --git a/src/commands/resume/resume.test.tsx b/src/commands/resume/resume.test.tsx index fc8e91c4bd..0c1635f385 100644 --- a/src/commands/resume/resume.test.tsx +++ b/src/commands/resume/resume.test.tsx @@ -18,6 +18,19 @@ import type { LocalJSXCommandOnDone, } from '../../types/command.js' +const realSessionStorage = { + ...await import(`../../utils/sessionStorage.js?real=${Date.now()}-${Math.random()}`), +} +const realGoalPersistence = { + ...await import(`../../services/goal/persistence.js?real=${Date.now()}-${Math.random()}`), +} +const realGetWorktreePaths = { + ...await import(`../../utils/getWorktreePaths.js?real=${Date.now()}-${Math.random()}`), +} +const realReplayIndex = { + ...await import(`../../utils/replayIndex.js?real=${Date.now()}-${Math.random()}`), +} + type OnDoneArgs = Parameters let saveGoalStateMock: ReturnType @@ -190,12 +203,18 @@ describe('/resume and /continue unified command', () => { searchSessionsByCustomTitle: searchSessionsByCustomTitleMock, })) mock.module('../../utils/replayIndex.js', () => ({ + // Bun caches the initial mock's export names, including after restoration. + ...realReplayIndex, loadReplayIndex: loadReplayIndexMock, })) }) afterEach(() => { mock.restore() + mock.module('../../utils/sessionStorage.js', () => realSessionStorage) + mock.module('../../services/goal/persistence.js', () => realGoalPersistence) + mock.module('../../utils/getWorktreePaths.js', () => realGetWorktreePaths) + mock.module('../../utils/replayIndex.js', () => realReplayIndex) }) test('/continue continues an active goal', async () => { diff --git a/src/components/HighlightedCode/Fallback.test.tsx b/src/components/HighlightedCode/Fallback.test.tsx new file mode 100644 index 0000000000..5a2523a184 --- /dev/null +++ b/src/components/HighlightedCode/Fallback.test.tsx @@ -0,0 +1,61 @@ +import { expect, spyOn, test } from 'bun:test' +import chalk from 'chalk' +import { PassThrough } from 'node:stream' +import React from 'react' +import { Ansi, createRoot } from '../../ink.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { getCliHighlightPromise } from '../../utils/cliHighlight.js' +import { HighlightedCodeFallback } from './Fallback.js' + +test('remounting with colors disabled does not reuse colored highlights', async () => { + await acquireSharedMutationLock('HighlightedCode/Fallback.test.tsx') + const previousLevel = chalk.level + const stdout = new PassThrough() + const stdin = new PassThrough() + const code = 'const cachedColorLevel = 42;' + const renderAnsi = Ansi.type + // Inspect the highlighted string before Ink can strip ANSI colors itself. + const ansi = spyOn(Ansi, 'type').mockImplementation(props => renderAnsi(props)) + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + let timer: ReturnType | undefined + + try { + expect(await getCliHighlightPromise()).not.toBeNull() + chalk.level = 1 + const highlighted = Promise.withResolvers() + ansi.mockImplementation(props => { + if (props.children.includes('\x1b')) highlighted.resolve() + return renderAnsi(props) + }) + root.render() + await Promise.race([ + highlighted.promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Highlighting did not complete')), 2500) + }), + ]) + expect(ansi.mock.calls.at(-1)![0].children).toContain('\x1b') + + chalk.level = 0 + ansi.mockClear() + root.render() + const output = ansi.mock.calls.at(-1)![0].children + expect(output).toBe(code) + expect(output).not.toContain('\x1b') + } finally { + clearTimeout(timer) + root.unmount() + ansi.mockRestore() + stdout.end() + stdin.end() + chalk.level = previousLevel + releaseSharedMutationLock() + } +}) diff --git a/src/components/HighlightedCode/Fallback.tsx b/src/components/HighlightedCode/Fallback.tsx index 4258077345..1457ca7c86 100644 --- a/src/components/HighlightedCode/Fallback.tsx +++ b/src/components/HighlightedCode/Fallback.tsx @@ -1,4 +1,5 @@ import { c as _c } from "react-compiler-runtime"; +import chalk from 'chalk'; import { extname } from 'path'; import React, { Suspense, use, useMemo } from 'react'; import { Ansi, Text } from '../../ink.js'; @@ -15,11 +16,11 @@ type Props = { // Module-level highlight cache — hl.highlight() is the hot cost on virtual- // scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash -// of code+language to avoid retaining full source strings (#24180 RSS fix). +// of code+language and Chalk level to avoid retaining full source strings (#24180 RSS fix). const HL_CACHE_MAX = 500; const hlCache = new Map(); function cachedHighlight(hl: NonNullable>>, code: string, language: string): string { - const key = hashPair(language, code); + const key = `${chalk.level}:${hashPair(language, code)}`; const hit = hlCache.get(key); if (hit !== undefined) { hlCache.delete(key); diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 0dadd055f0..defe1a063b 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -140,12 +140,12 @@ function MarkdownBody(t0) { nonTableContent = ""; } }; - for (const token of tokens) { + for (const [index, token] of tokens.entries()) { if (token.type === "table") { flushNonTableContent(); elements.push(); } else { - nonTableContent = nonTableContent + formatToken(token, theme, 0, null, null, highlight); + nonTableContent = nonTableContent + formatToken(token, theme, 0, null, null, highlight, tokens[index - 1]); nonTableContent; } } diff --git a/src/components/ModelPicker.switchProfile.test.ts b/src/components/ModelPicker.switchProfile.test.ts index 3634601470..0f40676727 100644 --- a/src/components/ModelPicker.switchProfile.test.ts +++ b/src/components/ModelPicker.switchProfile.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, expect, mock, test } from 'bun:test' import { resetModelStringsForTestingOnly } from '../bootstrap/state.js' import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js' @@ -7,25 +7,41 @@ import { resetSettingsCache, setSessionSettingsCache, } from '../utils/settings/settingsCache.js' -import * as actualAuth from '../utils/auth.js' -import * as actualModelOptions from '../utils/model/modelOptions.js' -import * as actualProviderProfiles from '../utils/providerProfiles.js' -import * as actualProviders from '../utils/model/providers.js' import type { ProviderProfile } from '../utils/config.js' -// Snapshot the real modules before any mock.module runs. bun live-repoints the -// `actual*` namespaces to the active mock, so these plain-object copies are the -// stable handle on the genuine implementations (2026-04-30 mock-leak lesson). -const realAuth = { ...actualAuth } -const realModelOptions = { ...actualModelOptions } -const realProviderProfiles = { ...actualProviderProfiles } -const realProviders = { ...actualProviders } +const nonce = `${Date.now()}-${Math.random()}` +const realAuth = { + ...await import(`../utils/auth.js?real=${nonce}`), +} as typeof import('../utils/auth.js') +const realModelOptions = { + ...await import(`../utils/model/modelOptions.js?real=${nonce}`), +} as typeof import('../utils/model/modelOptions.js') +const realProviderProfiles = { + ...await import(`../utils/providerProfiles.js?real=${nonce}`), +} as typeof import('../utils/providerProfiles.js') + +const providerSpecifier = '../utils/model/providers.js' +const realProvidersForCleanup = { + ...await import( + `${providerSpecifier}?real=${Date.now()}-${Math.random()}` + ), +} + +afterAll(() => { + mock.restore() + mock.module(providerSpecifier, () => realProvidersForCleanup) + mock.module('../utils/auth.js', () => realAuth) + mock.module('../utils/model/modelOptions.js', () => realModelOptions) + mock.module('../utils/providerProfiles.js', () => realProviderProfiles) +}) + +const realProviders = realProvidersForCleanup // bun's mock.module is process-wide and mock.restore() does NOT undo it, so // each mock is gated and falls through to the real implementation whenever // activeProfilesOverride is null — a transparent passthrough for every other // suite in the same `bun test` run. -let activeProfilesOverride: Partial | null = null +let activeProfilesOverride: Partial | null = null mock.module('../utils/model/providers.js', () => ({ ...realProviders, @@ -80,7 +96,7 @@ mock.module('../utils/providerProfiles.js', () => ({ // mock modelOptions for the fresh ModelPicker import so its isGenuineSwitch- // ProfileValue is bound to the fresh instance. async function importFreshModelPicker( - profilesMock: Partial, + profilesMock: Partial, options: { trackGetModelOptions?: boolean } = {}, diff --git a/src/components/ModelPicker.test.tsx b/src/components/ModelPicker.test.tsx index 4f49f9c5ed..1aaff0d687 100644 --- a/src/components/ModelPicker.test.tsx +++ b/src/components/ModelPicker.test.tsx @@ -17,9 +17,17 @@ import { } from '../utils/settings/settingsCache.js' import type { SettingsJson } from '../utils/settings/types.js' +const nonce = `${Date.now()}-${Math.random()}` +const realSettings = { + ...await import(`../utils/settings/settings.js?real=${nonce}`), +} as typeof import('../utils/settings/settings.js') +const realModelAllowlist = { + ...await import(`../utils/model/modelAllowlist.js?real=${nonce}`), +} as typeof import('../utils/model/modelAllowlist.js') + type SettingsModule = typeof import('../utils/settings/settings.js') -let actualSettingsModule: SettingsModule | undefined +let actualSettingsModule: SettingsModule | undefined = realSettings let settingsForTest: SettingsJson = {} function useSettings(settings: SettingsJson): void { @@ -83,6 +91,8 @@ beforeEach(async () => { afterEach(() => { try { mock.restore() + mock.module('../utils/settings/settings.js', () => realSettings) + mock.module('../utils/model/modelAllowlist.js', () => realModelAllowlist) resetSettingsCache() settingsForTest = {} } finally { diff --git a/src/components/PromptInput/utils.test.ts b/src/components/PromptInput/utils.test.ts index 39f35d630d..0b078c629f 100644 --- a/src/components/PromptInput/utils.test.ts +++ b/src/components/PromptInput/utils.test.ts @@ -119,7 +119,7 @@ test.each([ /** Observes the real hook after mounting its AppState provider. */ function HookProbe() { observedBanner = useSwarmBanner() - React.useEffect(() => { notifyRendered() }, []) + React.useLayoutEffect(() => { notifyRendered() }, []) return null } diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index 4620d6bf9a..5b9a4f2e0a 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -14,6 +14,32 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock.js' +const nonce = `${Date.now()}-${Math.random()}` +const realProviderProfiles = { + ...await import(`../utils/providerProfiles.js?real=${nonce}`), +} as typeof import('../utils/providerProfiles.js') +const realProviderDiscovery = { + ...await import(`../utils/providerDiscovery.js?real=${nonce}`), +} as typeof import('../utils/providerDiscovery.js') +const realDiscoveryService = { + ...await import(`../integrations/discoveryService.js?real=${nonce}`), +} as typeof import('../integrations/discoveryService.js') +const realGithubModelsCredentials = { + ...await import(`../utils/githubModelsCredentials.js?real=${nonce}`), +} as typeof import('../utils/githubModelsCredentials.js') +const realCodexCredentials = { + ...await import(`../utils/codexCredentials.js?real=${nonce}`), +} as typeof import('../utils/codexCredentials.js') +const realProviderProfile = { + ...await import(`../utils/providerProfile.js?real=${nonce}`), +} as typeof import('../utils/providerProfile.js') +const realProviderManagerAimlapi = { + ...await import(`./providerManagerAimlapi.js?real=${nonce}`), +} as typeof import('./providerManagerAimlapi.js') +const realUseCodexOAuthFlow = { + ...await import(`./useCodexOAuthFlow.js?real=${nonce}`), +} as typeof import('./useCodexOAuthFlow.js') + type SettingsModule = typeof import('../utils/settings/settings.js') type ProviderStartupOverridesModule = typeof import('../utils/providerStartupOverrides.js') @@ -158,6 +184,7 @@ const PRESET_ORDER = [ 'Atomic Chat', 'Ollama', 'LongCat', + 'Merge Gateway', 'MiniMax', 'Mistral AI', 'Moonshot AI - API', @@ -828,6 +855,14 @@ beforeEach(async () => { afterEach(() => { try { mock.restore() + mock.module('../utils/providerProfiles.js', () => realProviderProfiles) + mock.module('../utils/providerDiscovery.js', () => realProviderDiscovery) + mock.module('../integrations/discoveryService.js', () => realDiscoveryService) + mock.module('../utils/githubModelsCredentials.js', () => realGithubModelsCredentials) + mock.module('../utils/codexCredentials.js', () => realCodexCredentials) + mock.module('../utils/providerProfile.js', () => realProviderProfile) + mock.module('./providerManagerAimlapi.js', () => realProviderManagerAimlapi) + mock.module('./useCodexOAuthFlow.js', () => realUseCodexOAuthFlow) mock.module('../utils/settings/settings.js', () => actualSettingsModule) mock.module('../utils/providerStartupOverrides.js', () => actualProviderStartupOverridesModule) diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 56c9bd2399..0bb1d13eb2 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -117,6 +117,7 @@ import { import { clearStartupProviderOverrides } from '../utils/providerStartupOverrides.js' import { redactSensitiveInfo, redactUrlForDisplay } from '../utils/redaction.js' import { registerCleanup } from '../utils/cleanupRegistry.js' +import { PROVIDER_SELECTION_FLAGS } from '../utils/providerSelectionFlags.js' import { updateSettingsForSource } from '../utils/settings/settings.js' import { type OptionWithDescription, @@ -1714,6 +1715,14 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { } function activateGithubProvider(): string | null { + // Clear every provider-selection flag except GitHub from the central list + // so a previously selected provider (e.g. Gemini Vertex, checked before the + // GitHub branch in getAnthropicClient) can't keep routing after switching. + const clearedSelectionFlags = Object.fromEntries( + PROVIDER_SELECTION_FLAGS.filter( + flag => flag !== 'CLAUDE_CODE_USE_GITHUB', + ).map(flag => [flag, undefined]), + ) const { error } = updateSettingsForSource('userSettings', { env: { CLAUDE_CODE_USE_GITHUB: '1', @@ -1728,11 +1737,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { OPENAI_ORGANIZATION: undefined as unknown as string, OPENAI_BASE_URL: undefined as unknown as string, OPENAI_API_BASE: undefined as unknown as string, - CLAUDE_CODE_USE_OPENAI: undefined as unknown as string, - CLAUDE_CODE_USE_GEMINI: undefined as unknown as string, - CLAUDE_CODE_USE_BEDROCK: undefined as unknown as string, - CLAUDE_CODE_USE_VERTEX: undefined as unknown as string, - CLAUDE_CODE_USE_FOUNDRY: undefined as unknown as string, + ...(clearedSelectionFlags as Record), }, }) if (error) { @@ -1748,11 +1753,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { delete process.env.OPENAI_ORGANIZATION delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_API_BASE - delete process.env.CLAUDE_CODE_USE_OPENAI - delete process.env.CLAUDE_CODE_USE_GEMINI - delete process.env.CLAUDE_CODE_USE_BEDROCK - delete process.env.CLAUDE_CODE_USE_VERTEX - delete process.env.CLAUDE_CODE_USE_FOUNDRY + for (const flag of PROVIDER_SELECTION_FLAGS) { + if (flag !== 'CLAUDE_CODE_USE_GITHUB') delete process.env[flag] + } delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER] diff --git a/src/components/StartupScreen.test.ts b/src/components/StartupScreen.test.ts index 2ecab962ef..f2645b308c 100644 --- a/src/components/StartupScreen.test.ts +++ b/src/components/StartupScreen.test.ts @@ -1,3 +1,4 @@ +import { snapshotEnv } from '../test/envSnapshot.js' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test' const actualSettings = await import('../utils/settings/settings.js') @@ -38,6 +39,7 @@ const ENV_KEYS = [ 'CI', 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GEMINI_VERTEX', 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', @@ -451,3 +453,23 @@ describe('detectProvider — modelOverride from --model flag', () => { expect(result.model).toContain('sonnet') }) }) + +test.each([ + ['my-project', undefined, 'my-project'], + [undefined, 'adc', ''], +] as const)('native startup displays the project or ADC placeholder', (project, mode, displayed) => { + const restore = snapshotEnv(['CLAUDE_CODE_USE_GEMINI_VERTEX', 'GEMINI_VERTEX_PROJECT', + 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GOOGLE_PROJECT_ID', + 'GEMINI_VERTEX_LOCATION', 'GEMINI_VERTEX_MODEL', 'GEMINI_VERTEX_AUTH_MODE'], { clear: true }) + try { + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = 'yes' + if (project) process.env.GEMINI_VERTEX_PROJECT = project + if (mode) process.env.GEMINI_VERTEX_AUTH_MODE = mode + expect(detectProvider()).toMatchObject({ + name: 'Gemini Vertex', model: 'gemini-2.5-flash', + baseUrl: 'https://aiplatform.googleapis.com/v1/projects/' + displayed + '/locations/global', + }) + } finally { + restore() + } +}) diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts index e6f4407b20..8cd8538d03 100644 --- a/src/components/StartupScreen.ts +++ b/src/components/StartupScreen.ts @@ -1,3 +1,5 @@ +import { isEnvTruthy } from '../utils/envUtils.js' +import { getGeminiVertexModel, getGeminiVertexLocation, getGeminiVertexProjectId, geminiVertexAdcWillResolveProject } from '../utils/geminiAuth.js' /** * OpenClaude startup screen — filled-block text logo with sunset gradient. * Called once at CLI startup before the Ink UI renders. @@ -78,11 +80,21 @@ const LOGO_CLAUDE = [ // ─── Provider detection ─────────────────────────────────────────────────────── export function detectProvider(modelOverride?: string): { name: string; model: string; baseUrl: string; isLocal: boolean } { + const useGeminiVertex = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX) const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true' const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true' const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true' const useMistral = process.env.CLAUDE_CODE_USE_MISTRAL === '1' || process.env.CLAUDE_CODE_USE_MISTRAL === 'true' + if (useGeminiVertex) { + const model = modelOverride?.trim() || getGeminiVertexModel(process.env) || 'gemini-2.5-flash' + const location = getGeminiVertexLocation(process.env) + const project = getGeminiVertexProjectId(process.env) ?? + (geminiVertexAdcWillResolveProject(process.env) ? '' : '') + const baseUrl = `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${location}` + return { name: 'Gemini Vertex', model, baseUrl, isLocal: false } + } + if (useGemini) { const model = modelOverride || process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL const baseUrl = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai' diff --git a/src/components/ThemePicker.test.tsx b/src/components/ThemePicker.test.tsx index dd05ab0c4d..9f32fb1588 100644 --- a/src/components/ThemePicker.test.tsx +++ b/src/components/ThemePicker.test.tsx @@ -130,7 +130,7 @@ afterAll(() => { }) test('updates the preview when keyboard focus moves to another theme', async () => { - const { ThemePicker } = await import('./ThemePicker.js') + const { ThemePicker } = await import(`./ThemePicker.js?theme-picker=${Date.now()}-${Math.random()}`) const { stdout, stdin, getOutput } = createTestStreams() const root = await createRoot({ stdout: stdout as unknown as NodeJS.WriteStream, diff --git a/src/components/TrustDialog/utils.test.ts b/src/components/TrustDialog/utils.test.ts index 8ef3c9b28f..aa5446a213 100644 --- a/src/components/TrustDialog/utils.test.ts +++ b/src/components/TrustDialog/utils.test.ts @@ -1,10 +1,13 @@ -import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, describe, expect, spyOn, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import type { PermissionRule } from '../../utils/permissions/PermissionRule.js' -import { getRelativeSettingsFilePathForSource as REAL_canonicalPath } from '../../utils/settings/settings.js' +import * as settingsModule from '../../utils/settings/settings.js' +import * as permissionsLoader from '../../utils/permissions/permissionsLoader.js' import type { SettingsJson } from '../../utils/settings/types.js' +const REAL_canonicalPath = settingsModule.getRelativeSettingsFilePathForSource + // TrustDialog/utils.ts emits file-path strings shown to users in the trust // dialog. These MUST come from getRelativeSettingsFilePathForSource — the // single source of truth — not hardcoded literals. The previous code hardcoded @@ -52,24 +55,17 @@ const permissionRulesState: { localSettings: [], } -mock.module('../../utils/settings/settings.js', () => ({ - // Stateful — needs mocking to control test inputs. - getSettingsForSource: (source: 'projectSettings' | 'localSettings') => - settingsState[source], - // Pure — pass the REAL function through. The mock exists only because - // utils.ts imports both names from the same module; we must provide both. - getRelativeSettingsFilePathForSource: REAL_canonicalPath, -})) - -mock.module('../../utils/permissions/permissionsLoader.js', () => ({ - getPermissionRulesForSource: ( - source: 'projectSettings' | 'localSettings', - ): PermissionRule[] => permissionRulesState[source], -})) +const settingsSpy = spyOn(settingsModule, 'getSettingsForSource').mockImplementation( + source => settingsState[source as keyof typeof settingsState], +) +const permissionRulesSpy = spyOn(permissionsLoader, 'getPermissionRulesForSource').mockImplementation( + source => permissionRulesState[source as keyof typeof permissionRulesState], +) afterAll(() => { try { - mock.restore() + settingsSpy.mockRestore() + permissionRulesSpy.mockRestore() } finally { releaseSharedMutationLock() } diff --git a/src/components/agents/new-agent-creation/wizard-steps/wizardSteps.test.tsx b/src/components/agents/new-agent-creation/wizard-steps/wizardSteps.test.tsx index 889ea6dd6d..6ff94147c5 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/wizardSteps.test.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/wizardSteps.test.tsx @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, expect, mock, test } from 'bun:test' import React, { useEffect } from 'react' import stripAnsi from 'strip-ansi' @@ -8,6 +8,15 @@ import { Box, createRoot, Text } from '../../../../ink.js' import { WizardProvider, useWizard } from '../../../wizard/index.js' import type { AgentWizardData } from '../types.js' +// Copy real exports before mock.module can replace live module bindings. +const nonce = `${Date.now()}-${Math.random()}` +const realColorPicker = { + ...await import(`../../ColorPicker.js?real=${nonce}`), +} +const realSelect = { + ...await import(`../../../CustomSelect/select.js?real=${nonce}`), +} + type ColorPickerProps = { onConfirm: (color: string | undefined) => void } @@ -160,6 +169,12 @@ afterEach(() => { } }) +afterAll(() => { + // mock.restore() does not remove Bun module mock registrations. + mock.module('../../ColorPicker.js', () => realColorPicker) + mock.module('../../../CustomSelect/select.js', () => realSelect) +}) + test('ColorStep does not create a final agent when required wizard data is missing', async () => { const { ColorStep } = await importFreshStep('./ColorStep.js') diff --git a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.render.test.tsx b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.render.test.tsx index 5cff8f15ff..28b9a7c0cd 100644 --- a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.render.test.tsx +++ b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.render.test.tsx @@ -22,6 +22,20 @@ import type { Notification } from '../../../context/notifications.js' import { getDefaultAppState } from '../../../state/AppStateStore.js' import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../../tools/ExitPlanModeTool/constants.js' +const nonce = `${Date.now()}-${Math.random()}` +const realPlans = { + ...await import(`../../../utils/plans.js?real=${nonce}`), +} +const realPermissionRuleExplanation = { + ...await import(`../PermissionRuleExplanation.js?real=${nonce}`), +} +const realMarkdown = { + ...await import(`../../Markdown.js?real=${nonce}`), +} +const realNotifications = { + ...await import(`../../../context/notifications.js?real=${nonce}`), +} + // A real path whose parent directory does not exist, so writeFile rejects with // a genuine ENOENT — no fs/promises mock needed. const unwritablePlanPath = join( @@ -142,7 +156,6 @@ beforeEach(async () => { useNotifications: () => ({ addNotification, removeNotification: () => {} }), })) // V2 plan-file path points at a missing parent dir → real ENOENT on write. - const realPlans = await import('../../../utils/plans.ts') mock.module('../../../utils/plans.js', () => ({ ...realPlans, getPlanFilePath: () => unwritablePlanPath, @@ -154,6 +167,10 @@ beforeEach(async () => { afterEach(() => { try { mock.restore() + mock.module('../../../utils/plans.js', () => realPlans) + mock.module('../PermissionRuleExplanation.js', () => realPermissionRuleExplanation) + mock.module('../../Markdown.js', () => realMarkdown) + mock.module('src/context/notifications.js', () => realNotifications) } finally { releaseSharedMutationLock() } diff --git a/src/entrypoints/cli.test.ts b/src/entrypoints/cli.test.ts index 7570173332..62859fbed6 100644 --- a/src/entrypoints/cli.test.ts +++ b/src/entrypoints/cli.test.ts @@ -14,8 +14,10 @@ import { mock, } from 'bun:test' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { copyFile, mkdtemp, rm, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { Command } from '@commander-js/extra-typings' import { applyChildProcessHeapOptions } from './applyChildProcessHeapOptions.js' import { @@ -487,6 +489,8 @@ describe('cli.tsx — background routing behavior', () => { afterEach(() => { process.argv = [...savedArgv] + // Later SDK initialization must not replay a test's provider selection. + clearRememberedProviderFlagForTests() }) it('dispatches background management commands before startup work', async () => { @@ -1112,82 +1116,130 @@ describe('cli.tsx — background routing behavior', () => { }) describe('Node 24 premature exit regression (issue #1678)', () => { - it('built CLI stays alive during initialization in interactive mode without premature exit', async () => { - const os = await import('node:os') - const path = await import('node:path') - const fs = await import('node:fs/promises') - const url = await import('node:url') + let buildDir: string - const scriptPath = path.join(os.tmpdir(), `test-cli-startup-${Date.now()}.mjs`) - const cliUrl = url.pathToFileURL(path.resolve(import.meta.dir, '../../dist/cli.mjs')).href - let proc + beforeAll(async () => { + const repoRoot = resolve(import.meta.dir, '../..') + buildDir = await mkdtemp(join(tmpdir(), 'openclaude-startup-test-')) + // Build current source privately, as in commanderCompatibility.test.ts. + // Unit tests must not depend on a missing or stale repository dist bundle. + for (const directory of ['src', 'scripts', 'node_modules']) { + await symlink(join(repoRoot, directory), join(buildDir, directory), 'junction') + } + for (const file of ['package.json', 'tsconfig.json']) { + await copyFile(join(repoRoot, file), join(buildDir, file)) + } + const build = Bun.spawn([process.execPath, 'run', 'scripts/build.ts'], { + cwd: buildDir, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + timeout: 120_000, + }) + const [exitCode, stdout, stderr] = await Promise.all([ + build.exited, + new Response(build.stdout).text(), + new Response(build.stderr).text(), + ]) + if (exitCode !== 0) { + throw new Error(`CLI build failed (${exitCode}):\n${stdout}\n${stderr}`) + } + }, 130_000) // Leave time beyond the build deadline for output and cleanup. - try { - await Bun.write(scriptPath, ` + afterAll(async () => { + if (buildDir) await rm(buildDir, { recursive: true, force: true }) + }) + + it('built CLI stays alive during initialization in interactive mode without premature exit', async () => { + const scriptPath = join(buildDir, 'startup.mjs') + const cliUrl = pathToFileURL(join(buildDir, 'dist/cli.mjs')).href + await Bun.write(scriptPath, ` // Mock TTY so the CLI thinks it's interactive and starts the TUI process.stdout.isTTY = true; process.stdin.isTTY = true; process.stdin.setRawMode = () => {}; - process.env.OPENCLAUDE_DISABLE_TELEMETRY = '1'; - process.env.OPENGATEWAY_API_KEY = 'dummy'; - - // Ensure the CLI auto-runs even if the test runner disabled it globally - delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN; - - // Use absolute import to work from os.tmpdir() + process.stdin.ref = () => process.stdin; + process.stdin.unref = () => process.stdin; // If the entrypoint uses void main(), this promise resolves immediately. // If it correctly uses await main(), it stays pending while the CLI runs. - import('${cliUrl}').then(() => { + import(${JSON.stringify(cliUrl)}).then(() => { console.log('---PREMATURE_EVAL_END---'); process.exit(0); }); `) - proc = Bun.spawn(['node', scriptPath], { stdout: 'pipe' }) - const reader = proc.stdout.getReader() - - let gotOutput = false - let evaluationEndedPrematurely = false - - async function readStdout() { + // Deliberately supply only the environment needed by this interactive + // fixture. Parent provider settings, CI, and auto-run guards must not + // change startup, and no real user/project configuration should be read. + const proc = Bun.spawn(['node', scriptPath], { + cwd: buildDir, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + TMPDIR: tmpdir(), + TEMP: tmpdir(), + TMP: tmpdir(), + HOME: buildDir, + USERPROFILE: buildDir, + OPENCLAUDE_CONFIG_DIR: join(buildDir, 'config'), + XDG_CONFIG_HOME: join(buildDir, 'config'), + XDG_CACHE_HOME: join(buildDir, 'cache'), + NODE_COMPILE_CACHE: join(buildDir, 'cache'), + OPENCLAUDE_DISABLE_TELEMETRY: '1', + OPENGATEWAY_API_KEY: 'dummy', + }, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + timeout: 8_000, + }) + let stdout = '' + let stderr = '' + async function capture(stream: ReadableStream, append: (text: string) => void) { + const reader = stream.getReader() + const decoder = new TextDecoder() + try { while (true) { const { done, value } = await reader.read() if (done) break - const text = new TextDecoder().decode(value) - if (text.includes('---PREMATURE_EVAL_END---')) { - evaluationEndedPrematurely = true - } else if (text.trim().length > 0) { - gotOutput = true - } + append(decoder.decode(value, { stream: true })) } + append(decoder.decode()) + } finally { + reader.releaseLock() } + } + const output = Promise.all([ + capture(proc.stdout, text => { stdout += text }), + capture(proc.stderr, text => { stderr += text }), + ]) - // Start reading without awaiting it yet - const readPromise = readStdout() - - // Wait until we get startup output or detect premature evaluation end - const start = Date.now() - while (!gotOutput && !evaluationEndedPrematurely && Date.now() - start < 5000) { + try { + // Wait for the real startup banner, a premature evaluation, or an exit. + const deadline = Date.now() + 7_000 + while (!stdout.includes('Ready — type') && + !stdout.includes('---PREMATURE_EVAL_END---') && + proc.exitCode === null && Date.now() < deadline) { await new Promise(r => setTimeout(r, 10)) } - - expect(gotOutput).toBe(true) + if (!stdout.includes('Ready — type')) { + throw new Error(`CLI did not reach startup (exit ${proc.exitCode}):\n${stdout}\n${stderr}`) + } // The critical regression window: wait 500ms *after* output. // With void main(), Node 24 will exit during the subsequent async imports because the event loop empties, // which allows the import() promise above to resolve and emit the signal. await new Promise(r => setTimeout(r, 500)) - expect(evaluationEndedPrematurely).toBe(false) - expect(proc.exitCode).toBe(null) + expect(stdout).not.toContain('---PREMATURE_EVAL_END---') + expect(proc.exitCode, `CLI exited during initialization:\n${stderr}`).toBe(null) expect(proc.killed).toBe(false) } finally { - if (proc && proc.exitCode === null && !proc.killed) { - proc.kill() - } - await fs.unlink(scriptPath).catch(() => {}) + if (proc.exitCode === null) proc.kill('SIGKILL') + await proc.exited + await output } - }) + }, 10_000) // Leave time beyond the eight-second child deadline for cleanup. it('cli.tsx uses top-level await for main() to prevent premature exit', async () => { const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text() diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 27fd715b35..4f0ec5833d 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -11,7 +11,7 @@ import { } from '../utils/printFlag.js' // Defensive compatibility guard for environments where globalThis.File is -// unexpectedly absent. OpenClaude's supported runtime is Node >=22; this is +// unexpectedly absent. OpenClaude's supported runtime is Node >=24.22.1; this is // not a Node 18 support guarantee. The guard is harmless on supported Node // versions and prevents undici's module evaluation from throwing in unusual // embedded/runtime setups. diff --git a/src/entrypoints/commanderCompatibility.test.ts b/src/entrypoints/commanderCompatibility.test.ts new file mode 100644 index 0000000000..215ff076da --- /dev/null +++ b/src/entrypoints/commanderCompatibility.test.ts @@ -0,0 +1,73 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { copyFile, mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +let buildDir: string +let cliPath: string + +function runCli(args: string[]) { + const env: NodeJS.ProcessEnv = { ...process.env, OPENCLAUDE_DISABLE_TELEMETRY: '1' } + delete env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + return Bun.spawnSync(['node', cliPath, ...args], { + env, + timeout: 8_000, + }) +} + +describe('built CLI Commander compatibility', () => { + beforeAll(async () => { + const repoRoot = resolve(import.meta.dir, '../..') + buildDir = await mkdtemp(join(tmpdir(), 'openclaude-commander-test-')) + // The build script writes to cwd/dist. Give it the current source and + // dependencies in an isolated workspace so every run tests a fresh bundle. + for (const directory of ['src', 'scripts', 'node_modules']) { + await symlink(join(repoRoot, directory), join(buildDir, directory), 'junction') + } + for (const file of ['package.json', 'tsconfig.json']) { + await copyFile(join(repoRoot, file), join(buildDir, file)) + } + const build = Bun.spawn([process.execPath, 'run', 'scripts/build.ts'], { + cwd: buildDir, + stdout: 'pipe', + stderr: 'pipe', + timeout: 120_000, + }) + const [exitCode, stdout, stderr] = await Promise.all([ + build.exited, + new Response(build.stdout).text(), + new Response(build.stderr).text(), + ]) + if (exitCode !== 0) { + throw new Error(`CLI build failed (${exitCode}):\n${stdout}\n${stderr}`) + } + cliPath = join(buildDir, 'dist/cli.mjs') + }, 130_000) // Leave time beyond the build deadline for output and cleanup. + + afterAll(async () => { + if (buildDir) await rm(buildDir, { recursive: true, force: true }) + }) + + // Allow time for the eight-second subprocess deadline and assertions. + test('registers the stderr debug option without breaking help', () => { + const result = runCli(['--debug-to-stderr', '--help']) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toContain('Usage: openclaude') + expect(result.stdout.toString()).toContain('--yolo, --dangerously-skip-permissions') + expect(result.stderr.toString()).not.toContain('error:') + }, 10_000) + + test('rejects excess prompt arguments before starting a session', () => { + const result = runCli(['first', 'second']) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain('too many arguments') + expect(result.stderr.toString()).toContain('Expected 1 argument but got 2') + }, 10_000) + + test('parses a negative numeric value and reports the option validation error', () => { + const result = runCli(['--max-turns', '-1']) + expect(result.exitCode).toBe(1) + expect(result.stderr.toString()).toContain("option '--max-turns ' argument '-1' is invalid") + expect(result.stderr.toString()).not.toContain('unknown option') + }, 10_000) +}) diff --git a/src/entrypoints/mcp.ts b/src/entrypoints/mcp.ts index 5b0fb7ba08..237df48337 100644 --- a/src/entrypoints/mcp.ts +++ b/src/entrypoints/mcp.ts @@ -7,7 +7,7 @@ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true' import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { ZodError } from 'zod' +import { ZodError } from 'zod/v4' import { CallToolRequestSchema, type CallToolResult, @@ -35,7 +35,7 @@ import { getMainLoopModel } from '../utils/model/model.js' import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js' import { setCwd } from '../utils/Shell.js' import { jsonStringify } from '../utils/slowOperations.js' -import { getErrorParts } from '../utils/toolErrors.js' +import { formatMcpValidationError, getErrorParts } from '../utils/toolErrors.js' import { zodToJsonSchema } from '../utils/zodToJsonSchema.js' type ToolInput = Tool['inputSchema'] @@ -233,7 +233,7 @@ export async function startMCPServer( content: [ { type: 'text', - text: `Tool ${name} input is invalid:\n${error.errors.map(e => `- ${e.path.join('.')}: ${e.message}`).join('\n')}`, + text: formatMcpValidationError(name, error), }, ], } diff --git a/src/entrypoints/sandboxTypes.ts b/src/entrypoints/sandboxTypes.ts index 9f117cf095..401169a221 100644 --- a/src/entrypoints/sandboxTypes.ts +++ b/src/entrypoints/sandboxTypes.ts @@ -90,7 +90,7 @@ export const SandboxFilesystemConfigSchema = lazySchema(() => */ export const SandboxSettingsSchema = lazySchema(() => z - .object({ + .looseObject({ enabled: z.boolean().optional(), failIfUnavailable: z .boolean() @@ -101,7 +101,7 @@ export const SandboxSettingsSchema = lazySchema(() => 'When false (default), a warning is shown and commands run unsandboxed. ' + 'Intended for managed-settings deployments that require sandboxing as a hard gate.', ), - // Note: enabledPlatforms is an undocumented setting read via .passthrough() + // Note: enabledPlatforms is an undocumented setting read via z.looseObject() // It restricts sandboxing to specific platforms (e.g., ["macos"]). // // Added to unblock NVIDIA enterprise rollout: they want to enable @@ -139,8 +139,7 @@ export const SandboxSettingsSchema = lazySchema(() => }) .optional() .describe('Custom ripgrep configuration for bundled ripgrep support'), - }) - .passthrough(), + }), ) // Inferred types from schemas diff --git a/src/entrypoints/sdk/controlTypes.ts b/src/entrypoints/sdk/controlTypes.ts index 8909e8f2d0..f2f121717c 100644 --- a/src/entrypoints/sdk/controlTypes.ts +++ b/src/entrypoints/sdk/controlTypes.ts @@ -15,7 +15,7 @@ import type { SDKPartialAssistantMessageSchema } from './coreSchemas.js' */ /* eslint-disable @typescript-eslint/no-explicit-any */ -type InferSchema z.ZodType> = z.infer> +type InferSchema z.core.$ZodType> = z.infer> export type SDKControlInitializeRequest = InferSchema< typeof SDKControlInitializeRequestSchema diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index fe921c758e..5e67cccf31 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -69,6 +69,7 @@ const API_PROVIDER_VALUES = [ 'foundry', 'openai', 'gemini', + 'gemini-vertex', 'github', 'codex', 'nvidia-nim', diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index fa28f406b6..4743b8eabc 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -1483,7 +1483,7 @@ export type AccountInfo = { subscriptionType?: string tokenSource?: string apiKeySource?: string - apiProvider?: "firstParty" | "bedrock" | "vertex" | "foundry" | "openai" | "gemini" | "github" | "codex" | "nvidia-nim" | "minimax" | "mistral" | "xai" | "xiaomi-mimo" + apiProvider?: "firstParty" | "bedrock" | "vertex" | "foundry" | "openai" | "gemini" | "gemini-vertex" | "github" | "codex" | "nvidia-nim" | "minimax" | "mistral" | "xai" | "xiaomi-mimo" } export type AgentMcpServerSpec = string | (Record> = {} constructor( engine: QueryEngine | null, @@ -417,6 +421,7 @@ class QueryImpl implements Query { userAgents?: QueryOptions['agents'], mcpServers?: Record, permissionContext: ToolPermissionContext = getEmptyToolPermissionContext(), + hooks?: QueryOptions['hooks'], ) { this._engineWasInjected = engine !== null if (engine) this._engine = engine @@ -433,6 +438,28 @@ class QueryImpl implements Query { this.userAgents = userAgents this.mcpServers = mcpServers this.permissionContext = permissionContext + // Keep SDK callbacks on this query, never in the process-global registry. + for (const [event, matchers] of Object.entries(hooks ?? {})) { + if (!isHookEvent(event) || !Array.isArray(matchers)) continue + const callbackMatchers: HookCallbackMatcher[] = [] + for (const matcher of matchers) { + if (!matcher || typeof matcher !== 'object' || !('hooks' in matcher)) continue + if (!Array.isArray(matcher.hooks)) continue + if ('matcher' in matcher && matcher.matcher !== undefined && typeof matcher.matcher !== 'string') continue + const callbacks = matcher.hooks.filter( + (hook): hook is HookCallback['callback'] => typeof hook === 'function', + ) + if (callbacks.length === 0) continue + callbackMatchers.push({ + matcher: 'matcher' in matcher && typeof matcher.matcher === 'string' ? matcher.matcher : '', + hooks: callbacks.map(callback => ({ + type: 'callback', + callback: async (...args) => callback(...args), + })), + }) + } + if (callbackMatchers.length > 0) this.hookCallbacks[event] = callbackMatchers + } } /** The session ID for this query. Available immediately after query() returns. */ @@ -508,6 +535,7 @@ class QueryImpl implements Query { sessionProjectDir: null as string | null, // Resolved below after session resolution cwd: this.cwd, originalCwd: this.cwd, + registeredHooks: this.hookCallbacks, } const self = this @@ -726,7 +754,11 @@ class QueryImpl implements Query { })(), ) - yield* inner + try { + yield* inner + } finally { + this.clearHookCallbacks() + } } async setModel(model: string): Promise { @@ -758,7 +790,15 @@ class QueryImpl implements Query { this.engine.updateTools(updatedTools) } + private clearHookCallbacks(): void { + // Mutate the context's shared object so detached async work sees cleanup too. + for (const event of Object.keys(this.hookCallbacks)) { + delete this.hookCallbacks[event as HookEvent] + } + } + close(): void { + this.clearHookCallbacks() const wrapperController = this.abortController this.interruptWithSource('sdk_close') requestSdkRootAbort(wrapperController, 'sdk_close', 'sdk_query') @@ -1120,7 +1160,7 @@ export function query(params: { // Also pass sessionId, fork/forkSession, continue, cwd, resumeSessionAt, and agents. const effectiveSessionId = options.sessionId || options.resume const shouldFork = options.fork || options.forkSession - const queryImpl = new QueryImpl(null, prompt, ac, appStateStore, envOverrides, effectiveSessionId, shouldFork, options.continue, cwd, options.resumeSessionAt, options.agents, options.mcpServers, permissionContext) + const queryImpl = new QueryImpl(null, prompt, ac, appStateStore, envOverrides, effectiveSessionId, shouldFork, options.continue, cwd, options.resumeSessionAt, options.agents, options.mcpServers, permissionContext, options.hooks) // Build the canUseTool that supports external permission resolution. // When no user canUseTool callback is provided, this creates a pending diff --git a/src/entrypoints/sdk/sdkUtilityTypes.ts b/src/entrypoints/sdk/sdkUtilityTypes.ts index c6731107c6..26b34433ad 100644 --- a/src/entrypoints/sdk/sdkUtilityTypes.ts +++ b/src/entrypoints/sdk/sdkUtilityTypes.ts @@ -9,12 +9,13 @@ import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' /** - * API usage with every nullable field made required and non-null. + * API usage with counters made required and non-null. * * The Anthropic API types most usage fields as `T | null`; internally the * CLI zero-initializes them (see EMPTY_USAGE in services/api/emptyUsage.ts) - * so accumulation code doesn't have to null-check on every add. + * so accumulation code doesn't have to null-check on every add. Fallback-credit + * metadata stays nullable because absence is distinct from a redemption outcome. */ export type NonNullableUsage = { - [K in keyof Usage]: NonNullable -} + [K in keyof Omit]: NonNullable +} & Pick diff --git a/src/hooks/fileSuggestions.test.ts b/src/hooks/fileSuggestions.test.ts index d2ec8546aa..df2dfb8f57 100644 --- a/src/hooks/fileSuggestions.test.ts +++ b/src/hooks/fileSuggestions.test.ts @@ -188,16 +188,22 @@ function installFileSuggestionsDependencyMocks(options: LoadModuleOptions = {}): async function loadFileSuggestionsModule(options: LoadModuleOptions = {}) { activeSpawnScenario = options.spawnScenario - actualCrossSpawnModule ??= await import('cross-spawn') - actualRipgrepModule ??= await import('../utils/ripgrep.js') - actualFileIndexModule ??= await import('../native-ts/file-index/index.js') + const nonce = `${Date.now()}-${Math.random()}` + actualCrossSpawnModule ??= { + ...await import(`${import.meta.resolve('cross-spawn')}?real=${nonce}`), + } + actualRipgrepModule ??= { + ...await import(`../utils/ripgrep.js?real=${nonce}`), + } + actualFileIndexModule ??= { + ...await import(`../native-ts/file-index/index.js?real=${nonce}`), + } actualMarkdownConfigLoaderModule ??= await import( '../utils/markdownConfigLoader.js' ) installFileSuggestionsDependencyMocks(options) - const nonce = `${Date.now()}-${Math.random()}` const module = await import(`./fileSuggestions.ts?ts=${nonce}`) - restoreFileSuggestionsDependencyMocks() + // Restore in afterEach so live imports stay mocked while the test runs. return module } diff --git a/src/hooks/useApiKeyVerification.test.tsx b/src/hooks/useApiKeyVerification.test.tsx index 334e640728..0c21b7e3e2 100644 --- a/src/hooks/useApiKeyVerification.test.tsx +++ b/src/hooks/useApiKeyVerification.test.tsx @@ -1,15 +1,23 @@ import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, expect, mock, test } from 'bun:test' import React from 'react' import { createRoot, Text } from '../ink.js' -import * as realState from '../bootstrap/state.js' -import * as realClaudeApi from '../services/api/claude.js' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' -import * as realAuth from '../utils/auth.js' + +const nonce = `${Date.now()}-${Math.random()}` +const realAuth = { + ...await import(`../utils/auth.js?real=${nonce}`), +} +const realState = { + ...await import(`../bootstrap/state.js?real=${nonce}`), +} +const realClaudeApi = { + ...await import(`../services/api/claude.js?real=${nonce}`), +} type AuthState = { anthropicAuthEnabled: boolean @@ -75,6 +83,10 @@ afterEach(() => { } }) +afterAll(() => { + mock.module('../utils/auth.js', () => realAuth) +}) + test('useApiKeyVerification resets stale missing status when the session switches to a third-party provider', async () => { const authState: AuthState = { anthropicAuthEnabled: true, diff --git a/src/hooks/useBackgroundTaskNavigation.interruptionTrace.test.tsx b/src/hooks/useBackgroundTaskNavigation.interruptionTrace.test.tsx index 46b1a66212..5d8f5a4d63 100644 --- a/src/hooks/useBackgroundTaskNavigation.interruptionTrace.test.tsx +++ b/src/hooks/useBackgroundTaskNavigation.interruptionTrace.test.tsx @@ -22,6 +22,8 @@ beforeEach(async () => { await acquireSharedMutationLock( 'hooks/useBackgroundTaskNavigation.interruptionTrace.test.tsx', ) + // Earlier files can leave trace entries behind in non-isolated runs. + __resetInterruptionTraceForTests() }) afterEach(() => { diff --git a/src/hooks/useIdeLogging.ts b/src/hooks/useIdeLogging.ts index e73c2301fc..37749e9c29 100644 --- a/src/hooks/useIdeLogging.ts +++ b/src/hooks/useIdeLogging.ts @@ -10,7 +10,7 @@ const LogEventSchema = lazySchema(() => method: z.literal('log_event'), params: z.object({ eventName: z.string(), - eventData: z.object({}).passthrough(), + eventData: z.looseObject({}), }), }), ) diff --git a/src/i18n.locale.test.ts b/src/i18n.locale.test.ts new file mode 100644 index 0000000000..aa183b2dc0 --- /dev/null +++ b/src/i18n.locale.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { formatDescriptionWithSource } from './commands.js' +import onboardGithub from './commands/onboard-github/index.js' +import usage from './commands/usage/index.js' +import { getOpenClaudeCommandDescriptionKey } from './i18n/index.js' +import { detectLocale } from './i18n/locale.js' +import { snapshotEnv } from './test/envSnapshot.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from './utils/settings/settingsCache.js' + +let restoreEnv: () => void + +beforeEach(() => { + restoreEnv = snapshotEnv(['LC_ALL', 'LC_MESSAGES', 'LANG'], { clear: true }) +}) + +afterEach(() => { + resetSettingsCache() + restoreEnv() +}) + +// --------------------------------------------------------------------------- +// Settings-based detection +// --------------------------------------------------------------------------- + +describe('detectLocale – settings', () => { + test('returns zh-HK when settings.language is "zh-HK"', () => { + setSessionSettingsCache({ + settings: { language: 'zh-HK' }, + errors: [], + }) + expect(detectLocale()).toBe('zh-HK') + }) + + test('returns zh-HK for case-insensitive "zh-hk"', () => { + setSessionSettingsCache({ + settings: { language: 'zh-hk' }, + errors: [], + }) + expect(detectLocale()).toBe('zh-HK') + }) + + test('returns en for "en"', () => { + setSessionSettingsCache({ + settings: { language: 'en' }, + errors: [], + }) + expect(detectLocale()).toBe('en') + }) + + test('returns vi for "vi"', () => { + setSessionSettingsCache({ + settings: { language: 'vi' }, + errors: [], + }) + expect(detectLocale()).toBe('vi') + }) + + test('does NOT match zh_CN to zh-HK', () => { + setSessionSettingsCache({ + settings: { language: 'zh_CN' }, + errors: [], + }) + // zh_CN is not in the language map → falls through to env, then 'en' + expect(detectLocale()).toBe('en') + }) + + test('does NOT match en_HK to zh-HK', () => { + setSessionSettingsCache({ + settings: { language: 'en_HK' }, + errors: [], + }) + expect(detectLocale()).toBe('en') + }) +}) + +// --------------------------------------------------------------------------- +// Environment-based detection (no settings cache) +// --------------------------------------------------------------------------- + +describe('detectLocale – environment variables', () => { + /** Clear settings cache and all locale env vars so env path is exercised. */ + function clearAll() { + resetSettingsCache() + setSessionSettingsCache({ settings: {}, errors: [] }) + delete process.env.LC_ALL + delete process.env.LC_MESSAGES + delete process.env.LANG + } + + test('LC_ALL=zh_HK.UTF-8 → zh-HK', () => { + clearAll() + process.env.LC_ALL = 'zh_HK.UTF-8' + expect(detectLocale()).toBe('zh-HK') + }) + + test('LC_MESSAGES=vi_VN.UTF-8 → vi', () => { + clearAll() + process.env.LC_MESSAGES = 'vi_VN.UTF-8' + expect(detectLocale()).toBe('vi') + }) + + test('LANG=en_US.UTF-8 → en', () => { + clearAll() + process.env.LANG = 'en_US.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test('LC_ALL=zh_CN.UTF-8 does NOT match zh-HK', () => { + clearAll() + process.env.LC_ALL = 'zh_CN.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test('LANG=en_HK.UTF-8 does NOT match zh-HK', () => { + clearAll() + process.env.LANG = 'en_HK.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test.each([ + ['en_US.UTF-8', 'zh_HK.UTF-8', 'vi_VN.UTF-8', 'en'], + ['', 'zh_HK.UTF-8', 'vi_VN.UTF-8', 'zh-HK'], + ['', '', 'vi_VN.UTF-8', 'vi'], + ['C', 'zh_HK.UTF-8', 'vi_VN.UTF-8', 'en'], + ] as const)('prioritizes LC_ALL=%s, LC_MESSAGES=%s, LANG=%s → %s', + (lcAll, lcMessages, lang, expected) => { + clearAll() + process.env.LC_ALL = lcAll + process.env.LC_MESSAGES = lcMessages + process.env.LANG = lang + expect(detectLocale()).toBe(expected) + }, + ) + + test('a supported settings language overrides all locale variables', () => { + setSessionSettingsCache({ settings: { language: 'en' }, errors: [] }) + process.env.LC_ALL = 'zh_HK.UTF-8' + process.env.LC_MESSAGES = 'vi_VN.UTF-8' + process.env.LANG = 'zh_HK.UTF-8' + expect(detectLocale()).toBe('en') + }) + + test.each([ + ['zh_HK@traditional', 'zh-HK'], + ['zh_HK.UTF-8@traditional', 'zh-HK'], + ['zh_CN@traditional', 'en'], + ['en_HK@traditional', 'en'], + ] as const)('LANG=%s → %s', (lang, expected) => { + clearAll() + process.env.LANG = lang + expect(detectLocale()).toBe(expected) + }) + + test('falls back to en when no env vars are set', () => { + clearAll() + expect(detectLocale()).toBe('en') + }) +}) + +// --------------------------------------------------------------------------- +// Command-description consumer resolution +// --------------------------------------------------------------------------- + +describe('zh-HK command descriptions', () => { + test.each([ + [usage.name, usage, '顯示計劃用量上限'], + [onboardGithub.name, onboardGithub, '設定 GitHub Copilot 認證資訊'], + ] as const)('preserves the purpose of /%s', (_name, command, expected) => { + setSessionSettingsCache({ settings: { language: 'zh-HK' }, errors: [] }) + const localizationKey = getOpenClaudeCommandDescriptionKey(command.name) + expect(localizationKey).toBeDefined() + + const localizedCommand = { ...command, localizationKey } + expect(formatDescriptionWithSource(localizedCommand)).toBe(expected) + + // The same real command must retain its canonical description in English. + setSessionSettingsCache({ settings: { language: 'en' }, errors: [] }) + expect(formatDescriptionWithSource(localizedCommand)).toBe(command.description) + }) +}) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7beb10a22b..302cd12d5c 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,6 +1,7 @@ import { detectLocale } from './locale.js' import { en } from './languages/en.js' import { vi } from './languages/vi.js' +import zhHK from './languages/zh-HK.js' import type { I18nDictionary, InterpolationValues, @@ -10,6 +11,7 @@ import type { const dictionaries: Record = { en, vi, + 'zh-HK': zhHK, } export { detectLocale } diff --git a/src/i18n/languages/zh-HK.ts b/src/i18n/languages/zh-HK.ts new file mode 100644 index 0000000000..b83d6aeb7c --- /dev/null +++ b/src/i18n/languages/zh-HK.ts @@ -0,0 +1,53 @@ +export default { + "common.ok": "確定", + "common.cancel": "取消", + "common.back": "返回", + "common.continue": "繼續", + "common.save": "儲存", + "common.delete": "刪除", + "common.edit": "編輯", + "common.loading": "載入中...", + "common.success": "成功", + "common.failed": "失敗", + "common.yes": "是", + "common.no": "否", + "common.options": "選項", + "common.shortcut_hint": "按 Enter 繼續,Esc 返回", + + "commands.provider.description": "開啟服務商設定選單與切換設定檔", + "commands.model.description": "切換當前對話的 AI 模型", + "commands.onboard-github.description": "設定 GitHub Copilot 認證資訊", + "commands.buddy.description": "喚醒或設定 Terminal Pixel Buddy", + "commands.repomap.description": "檢視專案結構地圖與權重排名", + "commands.btw.description": "傳送旁白提問而不中斷主對話 Context", + "commands.usage.description": "顯示計劃用量上限", + "commands.help.description": "顯示 OpenClaude 指令與快捷鍵指南", + + "provider.title": "設定服務商設定檔", + "provider.select_method": "選擇登入或連接方式:", + "provider.options.subscription": "Claude 訂閱 (Pro / Team / Enterprise)", + "provider.options.console": "Anthropic Console API 帳號", + "provider.options.third_party": "第三方平台 (OpenAI, Gemini, DeepSeek, Ollama 等)", + "provider.presets.title": "選擇預設服務商:", + "provider.presets.gitlawb": "Gitlawb Opengateway (推薦)", + "provider.presets.deepseek": "DeepSeek Native API", + "provider.presets.aimlapi": "AI/ML API", + "provider.presets.ollama": "Ollama 本地服務", + "provider.enter_key": "請輸入 API Key:", + "provider.invalid_key": "API Key 無效,請重新輸入。", + "provider.saved": "服務商設定已儲存至 ~/.openclaude-profile.json", + + "model.current_model": "當前模型:", + "model.select_prompt": "輸入或選擇 Model ID:", + "model.custom_input": "自訂 Model ID", + "model.switched_success": "已成功切換模型至 {model}", + + "errors.node_not_found": "未找到 Node.js 執行檔。", + "errors.unsupported_region": "當前地區限制直接連線至 Anthropic。", + "errors.api_401": "API 認證失敗 (401)。", + "errors.api_429": "已超出請求速率限制 (429)。", + + "prompts.input_placeholder": "輸入提示詞或指令...", + "prompts.bash_mode": "[Bash 模式] 輸入 Shell 指令...", + "prompts.side_question": "[/btw 旁白提問] 不會影響當前會話 Context..." +}; diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts index f00f91afa1..561a179884 100644 --- a/src/i18n/locale.ts +++ b/src/i18n/locale.ts @@ -2,18 +2,46 @@ import { getInitialSettings } from '../utils/settings/settings.js' import { getSessionSettingsCache } from '../utils/settings/settingsCache.js' import type { Locale } from './types.js' -const LANGUAGE_MAP: Record = { - english: 'en', - en: 'en', - vietnamese: 'vi', - vi: 'vi', +const LANGUAGE_MAP: Map = new Map([ + ['english', 'en'], + ['en', 'en'], + ['vietnamese', 'vi'], + ['vi', 'vi'], + ['zh-hk', 'zh-HK'], + ['zh_hk', 'zh-HK'], + ['traditional chinese', 'zh-HK'], + ['chinese (hong kong)', 'zh-HK'], +]) + +function localeFromEnv(): Locale | undefined { + const raw = ( + process.env.LC_ALL || + process.env.LC_MESSAGES || + process.env.LANG || + '' + ).toLowerCase() + + if (!raw) return undefined + + // POSIX locales can carry a codeset and/or a modifier after the region. + const [langRegion] = raw.split(/[.@]/) + const [lang, region] = langRegion.split(/[-_]/) + + if (lang === 'zh' && region === 'hk') return 'zh-HK' + if (lang === 'vi') return 'vi' + if (lang === 'en') return 'en' + + return undefined } export function detectLocale(): Locale { const settings = getSessionSettingsCache()?.settings ?? getInitialSettings() const lang = settings.language - if (typeof lang !== 'string') { - return 'en' + + if (typeof lang === 'string') { + const mapped = LANGUAGE_MAP.get(lang.toLowerCase()) + if (mapped) return mapped } - return LANGUAGE_MAP[lang.toLowerCase()] ?? 'en' + + return localeFromEnv() ?? 'en' } diff --git a/src/i18n/types.ts b/src/i18n/types.ts index fc65e4a8b8..d7c2c31fb9 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,4 +1,4 @@ -export type Locale = 'en' | 'vi' +export type Locale = 'en' | 'vi' | 'zh-HK' export type LocalizationKey = string diff --git a/src/ink/Ansi.tsx b/src/ink/Ansi.tsx index c397b058fd..46c41ab546 100644 --- a/src/ink/Ansi.tsx +++ b/src/ink/Ansi.tsx @@ -25,7 +25,7 @@ type SpanProps = { * Component that parses ANSI escape codes and renders them using Text components. * * Use this as an escape hatch when you have pre-formatted ANSI strings from - * external tools (like cli-highlight) that need to be rendered in Ink. + * syntax highlighting that need to be rendered in Ink. * * Memoized to prevent re-renders when parent changes but children string is the same. */ diff --git a/src/ink/colorize.test.ts b/src/ink/colorize.test.ts new file mode 100644 index 0000000000..bfbbc59728 --- /dev/null +++ b/src/ink/colorize.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' + +const colorizeUrl = new URL('./colorize.ts', import.meta.url).href + +// Each process imports chalk and colorize after its terminal environment is set. +// Leave two seconds for assertions beyond the 8-second subprocess deadline. +test.each([ + { force: '0', terminal: '', tmux: '', level: 0, color: 'X' }, + { force: '1', terminal: '', tmux: '', level: 1, color: '\x1b[91mX\x1b[39m' }, + { force: '2', terminal: '', tmux: '', level: 2, color: '\x1b[38;5;196mX\x1b[39m' }, + { force: '3', terminal: '', tmux: '', level: 3, color: '\x1b[38;5;196mX\x1b[39m' }, + { force: '0', terminal: 'vscode', tmux: '', level: 0, color: 'X' }, + { force: '2', terminal: 'vscode', tmux: '', level: 3, color: '\x1b[38;5;196mX\x1b[39m' }, + { force: '3', terminal: 'vscode', tmux: 'test', level: 2, color: '\x1b[38;5;196mX\x1b[39m' }, +])('colorize respects FORCE_COLOR=$force, terminal=$terminal, tmux=$tmux', ({ force, terminal, tmux, level, color }) => { + const script = ` + import chalk from 'chalk'; + import { colorize } from ${JSON.stringify(colorizeUrl)}; + process.stdout.write(JSON.stringify({ + level: chalk.level, + foreground: colorize('X', 'ansi256(196)', 'foreground'), + background: colorize('X', 'ansi256(196)', 'background'), + })); + ` + const result = spawnSync(process.execPath, ['--eval', script], { + env: { + ...process.env, + FORCE_COLOR: force, + TERM_PROGRAM: terminal, + TMUX: tmux, + CLAUDE_CODE_TMUX_TRUECOLOR: '', + COLORTERM: '', + }, + encoding: 'utf8', + timeout: 8_000, + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + const rendered = JSON.parse(result.stdout) + expect(rendered.level).toBe(level) + expect(rendered.foreground).toBe(color) + expect(rendered.background).toBe(level === 0 ? 'X' : level === 1 + ? '\x1b[101mX\x1b[49m' + : '\x1b[48;5;196mX\x1b[49m') +}, 10_000) diff --git a/src/ink/output.test.ts b/src/ink/output.test.ts index ad4a397b1b..b060dbe229 100644 --- a/src/ink/output.test.ts +++ b/src/ink/output.test.ts @@ -71,6 +71,7 @@ function writeFullFrame(output: Output, width: number, height: number): void { beforeEach(() => { logSpy = spyOn(debug, 'logForDebugging').mockImplementation(() => {}) + logSpy.mockClear() }) afterEach(() => { diff --git a/src/ink/termio/osc.test.ts b/src/ink/termio/osc.test.ts index da55021aab..233c466408 100644 --- a/src/ink/termio/osc.test.ts +++ b/src/ink/termio/osc.test.ts @@ -5,6 +5,11 @@ import { releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' +const nonce = `${Date.now()}-${Math.random()}` +const actualExecFileNoThrow = { + ...await import(`../../utils/execFileNoThrow.js?real=${nonce}`), +} + const originalEnv = { ...process.env } const originalPlatform = process.platform const mockedClipboardPath = join(process.cwd(), 'openclaude-clipboard.txt') @@ -23,6 +28,7 @@ const execFileNoThrowMock = mock( function installOscMocks(): void { mock.module('../../utils/execFileNoThrow.js', () => ({ + ...actualExecFileNoThrow, execFileNoThrow: execFileNoThrowMock, execFileNoThrowWithCwd: execFileNoThrowMock, })) @@ -70,6 +76,7 @@ describe('Windows clipboard fallback', () => { afterEach(() => { try { mock.restore() + mock.module('../../utils/execFileNoThrow.js', () => actualExecFileNoThrow) process.env = { ...originalEnv } Object.defineProperty(process, 'platform', { value: originalPlatform }) } finally { @@ -125,6 +132,7 @@ describe('clipboard path behavior remains stable', () => { afterEach(() => { try { mock.restore() + mock.module('../../utils/execFileNoThrow.js', () => actualExecFileNoThrow) process.env = { ...originalEnv } Object.defineProperty(process, 'platform', { value: originalPlatform }) } finally { diff --git a/src/integrations/aimlapi/config.test.ts b/src/integrations/aimlapi/config.test.ts index 2b7dec338a..d6006398cf 100644 --- a/src/integrations/aimlapi/config.test.ts +++ b/src/integrations/aimlapi/config.test.ts @@ -2,12 +2,15 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' import { AIMLAPI_MANAGED_ATTRIBUTION_HEADER_NAMES, + AIMLAPI_MCP_URL, DEFAULT_PARTNER_ID, buildPartnerCheckoutReturnUrls, buildPartnerReturnUrl, + isAimlapiMcpUrl, isCanonicalAimlapiInferenceBaseUrl, isTrustedAimlapiRequestUrl, resolveAimlapiAttributionHeaders, + resolveAimlapiMcpHeaders, resolvePartnerId, resolveEndpoints, withResolvedPartnerHeader, @@ -264,3 +267,58 @@ test('proxy attribution strips every managed name spelling and keeps X-Tenant', expect(headers.get('x-tenant')).toBe('acme') expect(Object.keys(resolved)).toEqual(['X-Tenant']) }) + +test('MCP host gate is an exact https hostname match, never a suffix', () => { + expect(isAimlapiMcpUrl(AIMLAPI_MCP_URL)).toBe(true) + expect(isAimlapiMcpUrl('https://MCP.aimlapi.com/mcp')).toBe(true) + expect(isAimlapiMcpUrl('https://mcp.aimlapi.com:443/other')).toBe(true) + // Sibling aimlapi hosts are not the MCP server. + expect(isAimlapiMcpUrl('https://api.aimlapi.com/v1')).toBe(false) + expect(isAimlapiMcpUrl('https://staging.mcp.aimlapi.com/mcp')).toBe(false) + // Look-alikes, cleartext and garbage are rejected. + expect(isAimlapiMcpUrl('https://mcp.aimlapi.com.attacker.test/mcp')).toBe(false) + expect(isAimlapiMcpUrl('https://evilmcp.aimlapi.com/mcp')).toBe(false) + expect(isAimlapiMcpUrl('https://attacker.test/mcp.aimlapi.com/mcp')).toBe(false) + expect(isAimlapiMcpUrl('http://mcp.aimlapi.com/mcp')).toBe(false) + expect(isAimlapiMcpUrl('not-a-url')).toBe(false) +}) + +test('MCP headers gain both mandatory attribution headers on the MCP host only', () => { + const resolved = resolveAimlapiMcpHeaders( + { Authorization: 'Bearer test-key' }, + AIMLAPI_MCP_URL, + ) + expectSingleManagedAttribution(resolved, { + 'X-AIMLAPI-Source': 'agent/openclaude', + 'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID, + }) + expect(resolved.Authorization).toBe('Bearer test-key') + + // Any other MCP server keeps exactly the headers it was configured with. + for (const url of [ + 'https://mcp.example.test/mcp', + 'https://mcp.aimlapi.com.attacker.test/mcp', + 'http://mcp.aimlapi.com/mcp', + ]) { + expect(resolveAimlapiMcpHeaders({ 'X-Tenant': 'acme' }, url)).toEqual({ + 'X-Tenant': 'acme', + }) + } +}) + +test('MCP attribution restamps a configured partner id instead of combining it', () => { + const resolved = resolveAimlapiMcpHeaders( + { + 'x-aimlapi-partner-id': 'part_attacker', + 'X-AIMLAPI-SOURCE': 'agent/attacker', + 'X-Tenant': 'acme', + }, + AIMLAPI_MCP_URL, + ) + expectSingleManagedAttribution(resolved, { + 'X-AIMLAPI-Source': 'agent/openclaude', + 'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID, + }) + expect(new Headers(resolved).get('x-aimlapi-partner-id')).not.toContain(',') + expect(resolved['X-Tenant']).toBe('acme') +}) diff --git a/src/integrations/aimlapi/config.ts b/src/integrations/aimlapi/config.ts index a71b69db9d..b22bd950c5 100644 --- a/src/integrations/aimlapi/config.ts +++ b/src/integrations/aimlapi/config.ts @@ -251,6 +251,48 @@ export function resolveAimlapiAttributionHeaders( return rest } +/** + * AI/ML API MCP server (streamable HTTP). It accepts the same + * `Authorization: Bearer ` as the inference API and falls back to + * OAuth when no Authorization header is sent. + */ +export const AIMLAPI_MCP_URL = 'https://mcp.aimlapi.com/mcp' +const AIMLAPI_MCP_HOSTNAME = new URL(AIMLAPI_MCP_URL).hostname + +/** + * True only for an https URL whose hostname is exactly the AI/ML API MCP host. + * Deliberately not a suffix match: attribution must never reach a look-alike + * (`mcp.aimlapi.com.evil.test`) or a sibling host the user pointed elsewhere. + */ +export function isAimlapiMcpUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ( + parsed.protocol === 'https:' && + parsed.hostname.toLowerCase() === AIMLAPI_MCP_HOSTNAME + ) + } catch { + return false + } +} + +/** + * Stamp the mandatory attribution headers onto an MCP server's request headers + * when (and only when) the server is the AI/ML API MCP host. This covers both + * the built-in `aimlapi` plugin server and a server the user added by hand + * with `openclaude mcp add`. Any other MCP server's headers are returned + * untouched. Managed names are restamped through the same helper the + * inference path uses, so a configured header cannot replace the partner id. + */ +export function resolveAimlapiMcpHeaders( + headers: Readonly>, + url: string, +): Record { + if (!isAimlapiMcpUrl(url)) return { ...headers } + // An undefined base URL selects the canonical (attributed) branch. + return resolveAimlapiAttributionHeaders(headers, undefined) +} + /** * Build the co-branded checkout return URLs the hosted payment page redirects * to after the user pays or cancels. Carrying `sessionToken` + `partnerCheckout=1` diff --git a/src/integrations/aimlapi/topup.test.ts b/src/integrations/aimlapi/topup.test.ts index 8baaa6d174..e6e0ac8d96 100644 --- a/src/integrations/aimlapi/topup.test.ts +++ b/src/integrations/aimlapi/topup.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, expect, jest, mock, test } from 'bun:test' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -78,6 +78,7 @@ afterAll(() => { }) afterEach(() => { + jest.useRealTimers() globalThis.fetch = originalFetch lastSavedProfileEnv = undefined setClaudeConfigHomeDirForTesting(undefined) @@ -1950,6 +1951,7 @@ test('a terminal poll error awaits onSession before rejecting, not racing ahead }) test('polling retries a transient transport failure', async () => { + jest.useFakeTimers() process.env.AIMLAPI_APP_URL = 'https://app.example.test' let attempts = 0 globalThis.fetch = mock(async () => { @@ -1965,13 +1967,22 @@ test('polling retries a transient transport failure', async () => { verificationBaseUrl: 'https://front.example.test', }) - await expect(pollUntilPaid(client, 'session')).resolves.toEqual( + const polling = pollUntilPaid(client, 'session') + // Let the failed request schedule its retry before advancing the poll timer. + await new Promise(resolve => setImmediate(resolve)) + jest.advanceTimersByTime(2999) + await new Promise(resolve => setImmediate(resolve)) + expect(attempts).toBe(1) + jest.advanceTimersByTime(1) + + await expect(polling).resolves.toEqual( expect.objectContaining({ status: 'paid' }), ) expect(attempts).toBe(2) }) test('polling retains and retries the same session after a rate limit', async () => { + jest.useFakeTimers() process.env.AIMLAPI_APP_URL = 'https://app.example.test' let attempts = 0 globalThis.fetch = mock(async () => { @@ -1988,9 +1999,23 @@ test('polling retains and retries the same session after a rate limit', async () }) const sessions: string[] = [] - await expect( - pollUntilPaid(client, 'session', undefined, value => { sessions.push(value) }), - ).resolves.toEqual(expect.objectContaining({ status: 'paid' })) + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout') + const polling = pollUntilPaid(client, 'session', undefined, value => { sessions.push(value) }) + try { + // Reading the 429 body may take multiple turns before the retry is scheduled. + for (let tick = 0; tick < 100 && !setTimeoutSpy.mock.calls.some(call => call[1] === 3000); tick++) { + await new Promise(resolve => setImmediate(resolve)) + } + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3000) + } finally { + setTimeoutSpy.mockRestore() + } + jest.advanceTimersByTime(2999) + await new Promise(resolve => setImmediate(resolve)) + expect(attempts).toBe(1) + jest.advanceTimersByTime(1) + + await expect(polling).resolves.toEqual(expect.objectContaining({ status: 'paid' })) expect(attempts).toBe(2) expect(sessions).toEqual([]) }) diff --git a/src/integrations/aimlapi/topupState.test.ts b/src/integrations/aimlapi/topupState.test.ts index e20fdc871f..90483e4925 100644 --- a/src/integrations/aimlapi/topupState.test.ts +++ b/src/integrations/aimlapi/topupState.test.ts @@ -953,8 +953,18 @@ test('a receipt that fails schema validation fails closed instead of being claim ) }) -for (const leasePairField of ['exchangeLease', 'keyMintLease'] as const) { - test(`a receipt with a malformed ${leasePairField}Owner fails closed instead of being claimed over as empty`, () => { +test.each([ + ['exchangeLease / malformed owner', 'exchangeLease', 12345, 'now'], + ['exchangeLease / malformed timestamp', 'exchangeLease', 'some-owner', 'not-a-number'], + ['exchangeLease / missing timestamp', 'exchangeLease', 'some-owner', undefined], + ['exchangeLease / missing owner', 'exchangeLease', undefined, 'now'], + ['keyMintLease / malformed owner', 'keyMintLease', 12345, 'now'], + ['keyMintLease / malformed timestamp', 'keyMintLease', 'some-owner', 'not-a-number'], + ['keyMintLease / missing timestamp', 'keyMintLease', 'some-owner', undefined], + ['keyMintLease / missing owner', 'keyMintLease', undefined, 'now'], +] as const)( + 'malformed lease pairs fail closed: %s', + (_label, leasePairField, owner, timestamp) => { const directory = useTemporaryConfig() const claimed = claimAimlapiTopupState(intent) saveAimlapiTopupState({ @@ -964,77 +974,24 @@ for (const leasePairField of ['exchangeLease', 'keyMintLease'] as const) { }) const statePath = join(directory, 'aimlapi-topup.json') const seeded = JSON.parse(readFileSync(statePath, 'utf8')) - // A non-string owner: isLeaseLive would read this as merely "not - // currently live" on its own, but the whole receipt must still be - // rejected as untrustworthy rather than silently claimed over. - seeded[`${leasePairField}Owner`] = 12345 - seeded[`${leasePairField}At`] = Date.now() + const now = Date.now() + if (owner === undefined) { + delete seeded[`${leasePairField}Owner`] + } else { + seeded[`${leasePairField}Owner`] = owner + } + if (timestamp === undefined) { + delete seeded[`${leasePairField}At`] + } else { + seeded[`${leasePairField}At`] = timestamp === 'now' ? now : timestamp + } writeFileSync(statePath, JSON.stringify(seeded)) expect(() => claimAimlapiTopupState({ ...intent, amountUsdMinor: 5000 })).toThrow( /does not match the expected format/, ) - }) - - test(`a receipt with a malformed ${leasePairField}At fails closed instead of being claimed over as empty`, () => { - const directory = useTemporaryConfig() - const claimed = claimAimlapiTopupState(intent) - saveAimlapiTopupState({ - ...intent, - paymentSessionId: claimed.paymentSessionId, - resumeSessionToken: 'live-resume-token', - }) - const statePath = join(directory, 'aimlapi-topup.json') - const seeded = JSON.parse(readFileSync(statePath, 'utf8')) - seeded[`${leasePairField}Owner`] = 'some-owner' - seeded[`${leasePairField}At`] = 'not-a-number' - writeFileSync(statePath, JSON.stringify(seeded)) - - expect(() => claimAimlapiTopupState({ ...intent, amountUsdMinor: 5000 })).toThrow( - /does not match the expected format/, - ) - }) - - test(`a receipt with a one-sided ${leasePairField} pair (owner with no timestamp) fails closed`, () => { - const directory = useTemporaryConfig() - const claimed = claimAimlapiTopupState(intent) - saveAimlapiTopupState({ - ...intent, - paymentSessionId: claimed.paymentSessionId, - resumeSessionToken: 'live-resume-token', - }) - const statePath = join(directory, 'aimlapi-topup.json') - const seeded = JSON.parse(readFileSync(statePath, 'utf8')) - seeded[`${leasePairField}Owner`] = 'some-owner' - // No matching *At field — the old, independent-field validation would - // have accepted this (each field was only checked in isolation). - delete seeded[`${leasePairField}At`] - writeFileSync(statePath, JSON.stringify(seeded)) - - expect(() => claimAimlapiTopupState({ ...intent, amountUsdMinor: 5000 })).toThrow( - /does not match the expected format/, - ) - }) - - test(`a receipt with a one-sided ${leasePairField} pair (timestamp with no owner) fails closed`, () => { - const directory = useTemporaryConfig() - const claimed = claimAimlapiTopupState(intent) - saveAimlapiTopupState({ - ...intent, - paymentSessionId: claimed.paymentSessionId, - resumeSessionToken: 'live-resume-token', - }) - const statePath = join(directory, 'aimlapi-topup.json') - const seeded = JSON.parse(readFileSync(statePath, 'utf8')) - delete seeded[`${leasePairField}Owner`] - seeded[`${leasePairField}At`] = Date.now() - writeFileSync(statePath, JSON.stringify(seeded)) - - expect(() => claimAimlapiTopupState({ ...intent, amountUsdMinor: 5000 })).toThrow( - /does not match the expected format/, - ) - }) -} + }, +) // By-key checkouts (a saved profile or an env-sourced credential) have no // passwordless account email to key the intent on, so the intent's `email` diff --git a/src/integrations/brands/minimax.ts b/src/integrations/brands/minimax.ts index 04b573dda0..19efbaf8e9 100644 --- a/src/integrations/brands/minimax.ts +++ b/src/integrations/brands/minimax.ts @@ -13,6 +13,7 @@ export default defineBrand({ supportsPreciseTokenCount: false, }, modelIds: [ + 'minimax-m3.1-flash', 'minimax-m3', 'minimax-m2', 'minimax-m2.1', diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 016927076b..d1b98d26b1 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -32,6 +32,7 @@ const EXPECTED_PRESETS = [ 'together', 'groq', 'hicap', + 'merge-gateway', 'azure-openai', 'openrouter', 'lmstudio', diff --git a/src/integrations/descriptors.ts b/src/integrations/descriptors.ts index 363bd3c7f3..364f152047 100644 --- a/src/integrations/descriptors.ts +++ b/src/integrations/descriptors.ts @@ -10,6 +10,7 @@ export type TransportKind = | 'openai-compatible' | 'local' | 'gemini-native' + | 'gemini-vertex' | 'bedrock' | 'vertex' diff --git a/src/integrations/discoveryCache.test.ts b/src/integrations/discoveryCache.test.ts index a07a0b4cb0..c5f7af9f1b 100644 --- a/src/integrations/discoveryCache.test.ts +++ b/src/integrations/discoveryCache.test.ts @@ -22,7 +22,7 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock.js' -const originalConfigDir = process.env.CLAUDE_CONFIG_DIR +const originalConfigDir = process.env.OPENCLAUDE_CONFIG_DIR let tempDir: string @@ -37,7 +37,7 @@ function createModel(id: string): ModelCatalogEntry { beforeEach(async () => { await acquireSharedMutationLock('discoveryCache.test.ts') tempDir = mkdtempSync(join(tmpdir(), 'openclaude-discovery-cache-test-')) - process.env.CLAUDE_CONFIG_DIR = tempDir + process.env.OPENCLAUDE_CONFIG_DIR = tempDir setOriginalFsImplementation() await clearDiscoveryCache() }) @@ -46,9 +46,9 @@ afterEach(() => { try { setOriginalFsImplementation() if (originalConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR + delete process.env.OPENCLAUDE_CONFIG_DIR } else { - process.env.CLAUDE_CONFIG_DIR = originalConfigDir + process.env.OPENCLAUDE_CONFIG_DIR = originalConfigDir } rmSync(tempDir, { recursive: true, force: true }) } finally { diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 8970b2a377..f198aae4e7 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, jest, mock, spyOn, test } from 'bun:test' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -25,9 +25,11 @@ const originalEnv = { OPENAI_AUTH_HEADER_VALUE: process.env.OPENAI_AUTH_HEADER_VALUE, OPENAI_AUTH_SCHEME: process.env.OPENAI_AUTH_SCHEME, APISMART_API_KEY: process.env.APISMART_API_KEY, + MERGE_GATEWAY_API_KEY: process.env.MERGE_GATEWAY_API_KEY, ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, + CLAUDE_CODE_USE_GEMINI_VERTEX: process.env.CLAUDE_CODE_USE_GEMINI_VERTEX, CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK, @@ -70,9 +72,11 @@ function clearProviderEnv(): void { delete process.env.OPENAI_AUTH_HEADER_VALUE delete process.env.OPENAI_AUTH_SCHEME delete process.env.APISMART_API_KEY + delete process.env.MERGE_GATEWAY_API_KEY delete process.env.ANTHROPIC_CUSTOM_HEADERS delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_GEMINI_VERTEX delete process.env.CLAUDE_CODE_USE_MISTRAL delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_BEDROCK @@ -95,6 +99,7 @@ beforeEach(async () => { afterEach(() => { try { + jest.useRealTimers() mock.restore() globalThis.fetch = originalFetch rmSync(tempDir, { recursive: true, force: true }) @@ -111,9 +116,11 @@ afterEach(() => { restoreEnvValue('OPENAI_AUTH_HEADER_VALUE') restoreEnvValue('OPENAI_AUTH_SCHEME') restoreEnvValue('APISMART_API_KEY') + restoreEnvValue('MERGE_GATEWAY_API_KEY') restoreEnvValue('ANTHROPIC_CUSTOM_HEADERS') restoreEnvValue('CLAUDE_CODE_USE_OPENAI') restoreEnvValue('CLAUDE_CODE_USE_GEMINI') + restoreEnvValue('CLAUDE_CODE_USE_GEMINI_VERTEX') restoreEnvValue('CLAUDE_CODE_USE_MISTRAL') restoreEnvValue('CLAUDE_CODE_USE_GITHUB') restoreEnvValue('CLAUDE_CODE_USE_BEDROCK') @@ -128,6 +135,215 @@ afterEach(() => { }) describe('discoverModelsForRoute', () => { + test('uses Merge Gateway native discovery with its dedicated credential', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + process.env.OPENAI_API_KEY = 'unrelated-openai-key' + + let requestUrl: string | null = null + let authorization: string | null = null + setMockFetch(mock((input: string | URL | Request, init?: RequestInit) => { + requestUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + authorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model: 'anthropic/claude-sonnet-5', + display_name: 'Claude Sonnet 5', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch) + + const result = await discoverModelsForRoute('merge-gateway', { + forceRefresh: true, + }) + + expect(requestUrl).toBe( + 'https://api-gateway.merge.dev/v1/models', + ) + expect(authorization).toBe('Bearer merge-key') + expect(result).toMatchObject({ + routeId: 'merge-gateway', + source: 'network', + discoveredModelCount: 1, + }) + }) + + test('publishes only conversational Merge models to the catalog and cache', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + const nativeModel = (model: string, input: string[], output: string[]) => ({ + model, vendors: { native: { capabilities: { input, output } } }, + }) + const models = [ + nativeModel('anthropic/claude-sonnet-5', ['text', 'image', 'document'], ['text', 'tool_use']), + nativeModel('provider/audio-chat', ['text', 'audio'], ['text', 'audio']), + { + model: 'provider/multi-vendor-chat', vendors: { + speech: { capabilities: { input: ['text'], output: ['audio'] } }, + chat: { capabilities: { input: ['text'], output: ['text'] } }, + }, + }, + // Modalities, rather than names, also exclude unknown media/model families. + nativeModel('provider/vector-v1', ['text'], ['embedding']), + nativeModel('provider/narrator-v1', ['text'], ['audio']), + nativeModel('provider/movie-v1', ['text', 'image'], ['video']), + nativeModel('provider/painter-v1', ['text'], ['image']), + nativeModel('provider/transcription-v1', ['audio'], ['text']), + nativeModel('provider/ranker-v1', ['text'], ['score']), + // Sparse native/compatible payloads still exclude known non-chat families. + { model: 'google/gemini-embedding-001' }, + { model: 'minimax/speech-2.6-turbo' }, + { id: 'google/veo-3.1-generate-001' }, + ] + const fetchMock = mock(() => Promise.resolve(new Response(JSON.stringify({ data: models, has_more: false })))) + setMockFetch(fetchMock as unknown as typeof globalThis.fetch) + const result = await discoverModelsForRoute('merge-gateway', { forceRefresh: true }) + expect(result).toMatchObject({ source: 'network', discoveredModelCount: 3, error: null }) + expect(result?.models.map(model => model.apiName)).toEqual([ + 'default_routing', 'openai/gpt-5.5', + 'anthropic/claude-sonnet-5', 'provider/audio-chat', 'provider/multi-vendor-chat', + ]) + const cached = await discoverModelsForRoute('merge-gateway') + expect(cached?.source).toBe('cache') + expect(cached?.models).toEqual(result?.models) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + test('withholds explicit and ambient Merge keys from retargeted discovery', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + process.env.MERGE_GATEWAY_API_KEY = 'merge-secret' + const authorizations: Array = [] + setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get('authorization')) + return Promise.resolve(new Response(JSON.stringify({ data: [] }))) + }) as unknown as typeof globalThis.fetch) + for (const apiKey of [undefined, 'merge-secret']) { + await discoverModelsForRoute('merge-gateway', { + baseUrl: 'https://proxy.example/v1', apiKey, forceRefresh: true, + }) + } + expect(authorizations).toEqual([]) + }) + + test('caches the complete native Merge catalog after cursor continuation', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + const urls: string[] = [] + setMockFetch(mock((input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)) + urls.push(url.toString()) + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer merge-key') + expect(url.origin).toBe('https://api-gateway.merge.dev') + const second = url.searchParams.get('cursor') === 'page/2?opaque=1' + return Promise.resolve(new Response(JSON.stringify({ + data: second ? [{ model: 'provider/later' }, { model: 'provider/first' }] : [{ model: 'provider/first' }], + has_more: !second, next_cursor: second ? null : 'page/2?opaque=1', + }))) + }) as unknown as typeof globalThis.fetch) + const result = await discoverModelsForRoute('merge-gateway', { forceRefresh: true }) + expect(result).toMatchObject({ source: 'network', discoveredModelCount: 2, error: null }) + expect(result?.models.map(model => model.apiName)).toContain('provider/later') + expect(urls).toHaveLength(2) + expect((await discoverModelsForRoute('merge-gateway'))?.source).toBe('cache') + expect(urls).toHaveLength(2) + }) + + test('allows each Merge discovery page its own timeout', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + const pages = Array.from({ length: 3 }, () => Promise.withResolvers()) + let calls = 0 + const fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => { + const page = ++calls + const signal = init?.signal + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + reject(signal?.reason) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, 2000) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) onAbort() + pages[page - 1]!.resolve() + }) + return new Response(JSON.stringify({ + data: [{ model: `provider/page-${page}` }], + has_more: page < 3, next_cursor: page < 3 ? `page-${page + 1}` : null, + })) + }) + setMockFetch(fetchMock as unknown as typeof globalThis.fetch) + + jest.useFakeTimers() + try { + const discovery = discoverModelsForRoute('merge-gateway', { forceRefresh: true }) + const completed = discovery.then(() => 'completed' as const) + // Each page completes within its own five-second deadline, but the + // complete request exceeds one shared deadline. Wait for the next fetch + // to install both timers before advancing the clock again. + for (const page of pages) { + const next = await Promise.race([ + page.promise.then(() => 'page' as const), + completed, + ]) + expect(next).toBe('page') + jest.advanceTimersByTime(2000) + } + const result = await discovery + expect(result).toMatchObject({ source: 'network', discoveredModelCount: 3, error: null }) + expect(result?.models.map(model => model.apiName).filter(name => name.startsWith('provider/page-'))).toEqual([ + 'provider/page-1', 'provider/page-2', 'provider/page-3', + ]) + const cached = await discoverModelsForRoute('merge-gateway') + expect(cached?.source).toBe('cache') + expect(cached?.models).toEqual(result?.models) + expect(fetchMock).toHaveBeenCalledTimes(3) + } finally { + jest.useRealTimers() + } + }) + + test.each(['http-error', 'repeated-cursor', 'missing-cursor', 'page-bound'])('incomplete Merge discovery preserves fallback: %s', async failure => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + for (const withCache of [false, true]) { + if (withCache) { + setMockFetch(mock(() => Promise.resolve(new Response(JSON.stringify({data: [{model: 'provider/complete'}], has_more: false})))) as unknown as typeof globalThis.fetch) + await discoverModelsForRoute('merge-gateway', {forceRefresh: true}) + } + let calls = 0 + setMockFetch(mock(() => { + calls++ + if (failure === 'http-error' && calls > 1) return Promise.resolve(new Response('unavailable', { status: 503 })) + return Promise.resolve(new Response(JSON.stringify({ + data: [{ model: 'provider/partial' }], has_more: true, + next_cursor: failure === 'missing-cursor' ? null : failure === 'page-bound' ? 'page-' + calls : 'page-2', + }))) + }) as unknown as typeof globalThis.fetch) + const result = await discoverModelsForRoute('merge-gateway', {forceRefresh: true}) + expect(result?.error).not.toBeNull() + expect(result?.models.map(model => model.apiName)).not.toContain('provider/partial') + expect(result?.models.map(model => model.apiName)).toContain(withCache ? 'provider/complete' : 'default_routing') + expect(result?.source).toBe('stale-cache') + expect(calls).toBeLessThanOrEqual(100) + } + }) + test('does not send an ApiSmart key to an overridden discovery URL', async () => { const { discoverModelsForRoute } = await loadDiscoveryServiceModule() process.env.APISMART_API_KEY = 'apismart-secret' diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 580b0db798..0fd6aa0d07 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -16,6 +16,7 @@ import { filterAvailableCatalogEntries } from './registry.js' import { getRouteDescriptor, isCanonicalApismartInferenceBaseUrl, + isCanonicalMergeGatewayInferenceBaseUrl, isCanonicalXaiInferenceBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteCredentialValue, @@ -26,6 +27,7 @@ import type { } from '../utils/providerDiscovery.js' import { fetchOpenAICompatibleModelsRaw, + fetchMergeGatewayModelsRaw, listOpenAICompatibleModels, probeOllamaModelCatalog, probeAtomicChatReadiness, @@ -217,8 +219,10 @@ function getRouteDiscoveryApiKey( // URL. Apply the same exact inference-endpoint boundary used by requests and // profiles before considering either a caller-provided or ambient key. if ( - routeId === 'apismart' && - !isCanonicalApismartInferenceBaseUrl(baseUrl) + (routeId === 'apismart' && + !isCanonicalApismartInferenceBaseUrl(baseUrl)) || + (routeId === 'merge-gateway' && + !isCanonicalMergeGatewayInferenceBaseUrl(baseUrl)) ) { return undefined } @@ -427,10 +431,14 @@ async function runDiscovery( case 'openai-compatible': { if (discovery.mapModel) { - const rawModels = await fetchOpenAICompatibleModelsRaw({ + const fetchModels = routeId === 'merge-gateway' + ? fetchMergeGatewayModelsRaw + : fetchOpenAICompatibleModelsRaw + const rawModels = await fetchModels({ baseUrl: getRouteBaseUrl(routeId, options), apiKey: getRouteDiscoveryApiKey(routeId, options), headers: getRouteDiscoveryHeaders(routeId, options), + path: discovery.path, }) if (rawModels === null) { return null @@ -449,6 +457,7 @@ async function runDiscovery( baseUrl: getRouteBaseUrl(routeId, options), apiKey: getRouteDiscoveryApiKey(routeId, options), headers: getRouteDiscoveryHeaders(routeId, options), + path: discovery.path, }) return models?.map(model => toDiscoveredModelEntry(model)) ?? null } diff --git a/src/integrations/gateways/api-route.test.ts b/src/integrations/gateways/api-route.test.ts index 98b4824f50..3014ad26e3 100644 --- a/src/integrations/gateways/api-route.test.ts +++ b/src/integrations/gateways/api-route.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { describe, expect, test } from 'bun:test' import { routeForPreset } from '../compatibility.js' import { getProviderPresetUiMetadata } from '../providerUiMetadata.js' @@ -7,32 +7,47 @@ import { resolveRouteCredentialValue, resolveRouteIdFromBaseUrl, } from '../routeMetadata.js' -import gateway, { mapApiRouteModel } from './api-route.js' +import { mapApiRouteModel } from './api-route.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' -test('API Route uses a dedicated hybrid OpenAI-compatible gateway contract', () => { - expect(gateway.defaultBaseUrl).toBe('https://global.api-route.com/v1') - expect(gateway.defaultModel).toBe('claude-sonnet-4-6') - expect(gateway.setup.credentialEnvVars).toEqual(['API_ROUTE_API_KEY']) - expect(gateway.setup.dedicatedCredentialsOnly).toBe(false) - expect(gateway.preset?.apiKeyEnvVars).toEqual(['API_ROUTE_API_KEY']) - expect(gateway.validation).toMatchObject({ - kind: 'credential-env', - routing: { matchDefaultBaseUrl: true }, - credentialEnvVars: ['API_ROUTE_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_KEY'], - }) - expect(gateway.transportConfig).toEqual({ - kind: 'openai-compatible', - openaiShim: { - supportsApiFormatSelection: false, - supportsAuthHeaders: false, - maxTokensField: 'max_tokens', +describe('api-route gateway', () => { + describe.each([ + { + routeId: 'api-route', + expected: { + baseUrl: 'https://global.api-route.com/v1', + defaultModel: 'claude-sonnet-4-6', + setup: { + credentialEnvVars: ['API_ROUTE_API_KEY'], + dedicatedCredentialsOnly: false, + }, + preset: { apiKeyEnvVars: ['API_ROUTE_API_KEY'] }, + validation: { + kind: 'credential-env', + routing: { matchDefaultBaseUrl: true }, + credentialEnvVars: [ + 'API_ROUTE_API_KEY', + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ], + }, + openaiShim: { + supportsApiFormatSelection: false, + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + catalogSource: 'hybrid', + discovery: { requiresAuth: true, mapModel: mapApiRouteModel }, + } satisfies GatewayCatalogExpectations, }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) }) - expect(gateway.catalog?.source).toBe('hybrid') - expect(gateway.catalog?.discovery?.requiresAuth).toBe(true) - expect(gateway.catalog?.models?.map(model => model.apiName)).toEqual([ - 'claude-sonnet-4-6', - ]) }) test('API Route preset uses the existing generic profile path', () => { diff --git a/src/integrations/gateways/apismart.test.ts b/src/integrations/gateways/apismart.test.ts index 2086ddd17b..342d95ff32 100644 --- a/src/integrations/gateways/apismart.test.ts +++ b/src/integrations/gateways/apismart.test.ts @@ -1,29 +1,32 @@ import { describe, expect, test } from 'bun:test' import apismart from './apismart.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' const mapModel = apismart.catalog?.discovery?.mapModel describe('apismart gateway', () => { - test('uses hybrid discovery with dedicated credentials', () => { - expect(apismart.id).toBe('apismart') - expect(apismart.catalog?.source).toBe('hybrid') - expect(apismart.catalog?.discovery?.kind).toBe('openai-compatible') - expect(apismart.catalog?.discovery?.requiresAuth).toBe(true) - expect(apismart.setup.dedicatedCredentialsOnly).toBe(true) - expect(apismart.setup.credentialEnvVars).toEqual(['APISMART_API_KEY']) - expect(apismart.defaultBaseUrl).toBe('https://gw.apismart.ai/v1') - expect(apismart.defaultModel).toBe('DEEPSEEK_V4_FLASH') - expect(mapModel).toBeDefined() - }) - - test('curated catalog keeps ApiSmart case-sensitive model ids', () => { - const models = apismart.catalog?.models ?? [] - expect(models.some(model => model.apiName === 'DEEPSEEK_V4_FLASH')).toBe( - true, - ) - expect(models.some(model => model.apiName === 'GLM_5.2')).toBe(true) - expect(models.some(model => model.apiName === 'QWEN_3_7_MAX')).toBe(true) + describe.each([ + { + routeId: 'apismart', + expected: { + baseUrl: 'https://gw.apismart.ai/v1', + defaultModel: 'DEEPSEEK_V4_FLASH', + setup: { + credentialEnvVars: ['APISMART_API_KEY'], + dedicatedCredentialsOnly: true, + }, + catalogSource: 'hybrid', + discovery: { requiresAuth: true }, + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) }) test('mapModel keeps chat models and drops image/video ids', () => { diff --git a/src/integrations/gateways/commandcode.test.ts b/src/integrations/gateways/commandcode.test.ts index 8f7c2e0cce..a76cbe2886 100644 --- a/src/integrations/gateways/commandcode.test.ts +++ b/src/integrations/gateways/commandcode.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { describe, expect, test } from 'bun:test' import { routeForPreset } from '../compatibility.js' import { getProviderPresetUiMetadata } from '../providerUiMetadata.js' @@ -7,54 +7,60 @@ import { resolveRouteCredentialValue, resolveRouteIdFromBaseUrl, } from '../routeMetadata.js' -import gateway, { +import { getCommandcodeChatCompletionsModelError, mapCommandcodeModel, } from './commandcode.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' -test('Command Code uses a dedicated hybrid OpenAI-compatible gateway contract', () => { - expect(gateway.defaultBaseUrl).toBe( - 'https://api.commandcode.ai/provider/v1', - ) - expect(gateway.defaultModel).toBe('deepseek/deepseek-v4-flash') - expect(gateway.setup.credentialEnvVars).toEqual([ - 'CMD_API_KEY', - 'COMMANDCODE_API_KEY', - 'COMMAND_CODE_API_KEY', - ]) - expect(gateway.setup.dedicatedCredentialsOnly).toBe(true) - expect(gateway.preset?.apiKeyEnvVars).toEqual([ - 'CMD_API_KEY', - 'COMMANDCODE_API_KEY', - 'COMMAND_CODE_API_KEY', - ]) - expect(gateway.validation).toMatchObject({ - kind: 'credential-env', - routing: { matchDefaultBaseUrl: true }, - credentialEnvVars: [ - 'CMD_API_KEY', - 'COMMANDCODE_API_KEY', - 'COMMAND_CODE_API_KEY', - ], - }) - expect(gateway.transportConfig).toEqual({ - kind: 'openai-compatible', - openaiShim: { - requiredApiFormat: 'chat_completions', - supportsAuthHeaders: false, - maxTokensField: 'max_tokens', +describe('commandcode gateway', () => { + describe.each([ + { + routeId: 'commandcode', + expected: { + baseUrl: 'https://api.commandcode.ai/provider/v1', + defaultModel: 'deepseek/deepseek-v4-flash', + setup: { + credentialEnvVars: [ + 'CMD_API_KEY', + 'COMMANDCODE_API_KEY', + 'COMMAND_CODE_API_KEY', + ], + dedicatedCredentialsOnly: true, + }, + preset: { + apiKeyEnvVars: [ + 'CMD_API_KEY', + 'COMMANDCODE_API_KEY', + 'COMMAND_CODE_API_KEY', + ], + }, + validation: { + kind: 'credential-env', + routing: { matchDefaultBaseUrl: true }, + credentialEnvVars: [ + 'CMD_API_KEY', + 'COMMANDCODE_API_KEY', + 'COMMAND_CODE_API_KEY', + ], + }, + openaiShim: { + requiredApiFormat: 'chat_completions', + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + catalogSource: 'hybrid', + discovery: { requiresAuth: false, mapModel: mapCommandcodeModel }, + } satisfies GatewayCatalogExpectations, }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) }) - expect(gateway.catalog?.source).toBe('hybrid') - expect(gateway.catalog?.discovery?.requiresAuth).toBe(false) - expect(gateway.catalog?.models?.map(model => model.apiName)).toEqual([ - 'deepseek/deepseek-v4-flash', - 'deepseek/deepseek-v4-pro', - 'gpt-5.6-sol', - 'moonshotai/Kimi-K2.5', - 'MiniMaxAI/MiniMax-M3', - 'z-ai/glm-5.3-flash', - ]) }) test('Command Code preset uses the existing generic profile path', () => { diff --git a/src/integrations/gateways/concentrate.test.ts b/src/integrations/gateways/concentrate.test.ts index 963c90ac9c..df325d497c 100644 --- a/src/integrations/gateways/concentrate.test.ts +++ b/src/integrations/gateways/concentrate.test.ts @@ -1,37 +1,45 @@ import { describe, expect, test } from 'bun:test' import concentrate from './concentrate.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' const mapModel = concentrate.catalog?.discovery?.mapModel describe('concentrate gateway', () => { - test('uses dynamic discovery and supports fallback auth', () => { - expect(concentrate.id).toBe('concentrate') - expect(concentrate.label).toBe('Concentrate') - expect(concentrate.category).toBe('aggregating') - expect(concentrate.defaultBaseUrl).toBe('https://api.concentrate.ai/v1') - expect(concentrate.defaultModel).toBe('deepseek-v4-flash') - expect(concentrate.supportsModelRouting).toBe(true) - - expect(concentrate.setup.requiresAuth).toBe(true) - expect(concentrate.setup.authMode).toBe('api-key') - expect(concentrate.setup.credentialEnvVars).toEqual(['CONCENTRATE_API_KEY']) - expect(concentrate.setup.dedicatedCredentialsOnly).toBeUndefined() - - expect(concentrate.catalog?.source).toBe('dynamic') - expect(concentrate.catalog?.discovery?.kind).toBe('openai-compatible') - expect(concentrate.catalog?.discovery?.requiresAuth).toBe(false) - - expect(concentrate.validation?.kind).toBe('credential-env') - expect( - (concentrate.validation as { credentialEnvVars: string[] }).credentialEnvVars, - ).toEqual([ - 'CONCENTRATE_API_KEY', - 'OPENAI_API_KEYS', - 'OPENAI_API_KEY', - ]) - - expect(mapModel).toBeDefined() + describe.each([ + { + routeId: 'concentrate', + expected: { + baseUrl: 'https://api.concentrate.ai/v1', + defaultModel: 'deepseek-v4-flash', + category: 'aggregating', + supportsModelRouting: true, + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['CONCENTRATE_API_KEY'], + dedicatedCredentialsOnly: undefined, + }, + validation: { + kind: 'credential-env', + credentialEnvVars: [ + 'CONCENTRATE_API_KEY', + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ], + }, + catalogSource: 'dynamic', + discovery: { requiresAuth: false }, + allowEmptyCatalog: true, + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) }) test('mapModel keeps chat models and drops non-chat ids', () => { diff --git a/src/integrations/gateways/custom.test.ts b/src/integrations/gateways/custom.test.ts index 460e95f2eb..688e8c35b8 100644 --- a/src/integrations/gateways/custom.test.ts +++ b/src/integrations/gateways/custom.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { _clearRegistryForTesting, ensureIntegrationsLoaded, getCatalogForGateway } from '../index.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' describe('custom gateway', () => { beforeEach(() => { @@ -9,6 +13,32 @@ describe('custom gateway', () => { afterEach(() => { _clearRegistryForTesting() + ensureIntegrationsLoaded() + }) + + describe.each([ + { + routeId: 'custom', + expected: { + baseUrl: undefined, + defaultModel: 'llama3.1:8b', + setup: { requiresAuth: false, credentialEnvVars: undefined }, + preset: { + fallbackBaseUrl: 'http://localhost:11434/v1', + apiKeyEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'], + }, + openaiShim: { + supportsApiFormatSelection: true, + supportsAuthHeaders: true, + }, + catalogSource: 'hybrid', + allowEmptyCatalog: true, + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) }) test('discovers /v1/models and maps context_length to contextWindow', async () => { diff --git a/src/integrations/gateways/gitlawb-opengateway.test.ts b/src/integrations/gateways/gitlawb-opengateway.test.ts index e178758c04..fb22f1cfbd 100644 --- a/src/integrations/gateways/gitlawb-opengateway.test.ts +++ b/src/integrations/gateways/gitlawb-opengateway.test.ts @@ -1,17 +1,32 @@ import { describe, expect, test } from 'bun:test' import opengateway, { mapOpenGatewayModel } from './gitlawb-opengateway.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' describe('gitlawb-opengateway live model mapping', () => { - test('uses hybrid discovery against the public models list', () => { - expect(opengateway.catalog?.source).toBe('hybrid') - expect(opengateway.catalog?.discovery).toEqual( - expect.objectContaining({ - kind: 'openai-compatible', - requiresAuth: false, - }), - ) - expect(opengateway.catalog?.discovery?.mapModel).toBe(mapOpenGatewayModel) - expect(opengateway.startup?.probeReadiness).toBeUndefined() + describe.each([ + { + routeId: 'gitlawb-opengateway', + expected: { + defaultModel: 'mimo-v2.5-pro', + setup: { + credentialEnvVars: [ + 'OPENGATEWAY_API_KEY', + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ], + }, + catalogSource: 'hybrid', + discovery: { requiresAuth: false, mapModel: mapOpenGatewayModel }, + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + expect(opengateway.startup?.probeReadiness).toBeUndefined() + }) }) test('maps gateway routes including auto and free models', () => { diff --git a/src/integrations/gateways/gmi-cloud.test.ts b/src/integrations/gateways/gmi-cloud.test.ts index 8e43e16528..eb313c23c5 100644 --- a/src/integrations/gateways/gmi-cloud.test.ts +++ b/src/integrations/gateways/gmi-cloud.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { describe, expect, test } from 'bun:test' import { routeForPreset } from '../compatibility.js' import { getProviderPresetUiMetadata } from '../providerUiMetadata.js' @@ -9,35 +9,43 @@ import { resolveRouteIdFromBaseUrl, } from '../routeMetadata.js' import { resolveModelRuntimeLimits } from '../runtimeMetadata.js' -import gateway, { mapGmiCloudModel } from './gmi-cloud.js' +import { mapGmiCloudModel } from './gmi-cloud.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' -test('GMI Cloud uses a dedicated-key hybrid OpenAI-compatible gateway contract', () => { - expect(gateway.defaultBaseUrl).toBe('https://api.gmi-serving.com/v1') - expect(gateway.defaultModel).toBe('Qwen/Qwen3.8-Max') - expect(gateway.setup.credentialEnvVars).toEqual(['GMI_API_KEY']) - expect(gateway.setup.dedicatedCredentialsOnly).toBe(true) - expect(gateway.preset?.apiKeyEnvVars).toEqual(['GMI_API_KEY']) - expect(gateway.validation).toMatchObject({ - kind: 'credential-env', - routing: { matchDefaultBaseUrl: true }, - credentialEnvVars: ['GMI_API_KEY'], - }) - expect(gateway.transportConfig).toEqual({ - kind: 'openai-compatible', - openaiShim: { - supportsApiFormatSelection: false, - supportsAuthHeaders: false, - maxTokensField: 'max_tokens', +describe('gmi-cloud gateway', () => { + describe.each([ + { + routeId: 'gmi-cloud', + expected: { + baseUrl: 'https://api.gmi-serving.com/v1', + defaultModel: 'Qwen/Qwen3.8-Max', + setup: { + credentialEnvVars: ['GMI_API_KEY'], + dedicatedCredentialsOnly: true, + }, + preset: { apiKeyEnvVars: ['GMI_API_KEY'] }, + validation: { + kind: 'credential-env', + routing: { matchDefaultBaseUrl: true }, + credentialEnvVars: ['GMI_API_KEY'], + }, + openaiShim: { + supportsApiFormatSelection: false, + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + catalogSource: 'hybrid', + discovery: { requiresAuth: true }, + } satisfies GatewayCatalogExpectations, }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) }) - expect(gateway.catalog?.source).toBe('hybrid') - expect(gateway.catalog?.discovery?.requiresAuth).toBe(true) - expect(gateway.catalog?.models?.map(model => model.apiName)).toEqual([ - 'Qwen/Qwen3.8-Max', - 'Qwen/Qwen3.8-Flash', - 'deepseek-ai/DeepSeek-V4-Pro', - 'deepseek-ai/DeepSeek-V4-Flash', - ]) }) test('GMI Cloud preset resolves to its gateway route and reads GMI env vars', () => { diff --git a/src/integrations/gateways/llmtr.test.ts b/src/integrations/gateways/llmtr.test.ts index d41fb11b1f..5e53317dd7 100644 --- a/src/integrations/gateways/llmtr.test.ts +++ b/src/integrations/gateways/llmtr.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from 'bun:test' +import { describe, expect, test } from 'bun:test' +import { getCatalogForGateway } from '../index.js' import { routeForPreset } from '../compatibility.js' import { getProviderPresetUiMetadata } from '../providerUiMetadata.js' import { @@ -9,39 +10,45 @@ import { } from '../routeMetadata.js' import catalog, { mapLlmtrModel } from './llmtr.models.js' import gateway from './llmtr.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' -test('LLMTR uses the standard OpenAI-compatible gateway contract', () => { - expect(gateway.defaultBaseUrl).toBe('https://llmtr.com/v1') - expect(gateway.defaultModel).toBe('deepseek/deepseek-v4-flash') - expect(gateway.setup.credentialEnvVars).toEqual([ - 'LLMTR_API_KEY', - 'OPENAI_API_KEY', - ]) - expect(gateway.setup.dedicatedCredentialsOnly).not.toBe(true) - expect(gateway.preset?.apiKeyEnvVars).toEqual([ - 'LLMTR_API_KEY', - 'OPENAI_API_KEY', - ]) - expect(gateway.validation).toMatchObject({ - kind: 'credential-env', - routing: { matchDefaultBaseUrl: true }, - credentialEnvVars: [ - 'LLMTR_API_KEY', - 'OPENAI_API_KEYS', - 'OPENAI_API_KEY', - ], - }) - expect(gateway.validation?.routing?.matchBaseUrlHosts).toBeUndefined() - expect(gateway.transportConfig).toEqual({ - kind: 'openai-compatible', - openaiShim: { - requiredApiFormat: 'chat_completions', - supportsAuthHeaders: false, - maxTokensField: 'max_tokens', +describe('llmtr gateway', () => { + describe.each([ + { + routeId: 'llmtr', + expected: { + baseUrl: 'https://llmtr.com/v1', + defaultModel: 'deepseek/deepseek-v4-flash', + setup: { credentialEnvVars: ['LLMTR_API_KEY', 'OPENAI_API_KEY'] }, + preset: { apiKeyEnvVars: ['LLMTR_API_KEY', 'OPENAI_API_KEY'] }, + validation: { + kind: 'credential-env', + routing: { matchDefaultBaseUrl: true, matchBaseUrlHosts: undefined }, + credentialEnvVars: [ + 'LLMTR_API_KEY', + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ], + }, + openaiShim: { + requiredApiFormat: 'chat_completions', + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + catalogSource: 'hybrid', + discovery: { requiresAuth: false }, + } satisfies GatewayCatalogExpectations, }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + expect(gateway.setup.dedicatedCredentialsOnly).not.toBe(true) + expect(getCatalogForGateway(routeId)).toBe(catalog) + }) }) - expect(catalog.source).toBe('hybrid') - expect(catalog.discovery?.requiresAuth).toBe(false) }) test('LLMTR preset uses the existing generic profile path', () => { @@ -174,14 +181,3 @@ test.each([ }), ).toBeNull() }) - -test('LLMTR curated models are a small tool-capable fallback catalog', () => { - expect(catalog.models?.map(model => model.apiName)).toEqual([ - 'deepseek/deepseek-v4-flash', - 'anthropic/claude-sonnet-4.6', - 'openai/gpt-5.4', - 'google/gemini-3.1-pro-preview', - 'deepseek/deepseek-v4-pro', - 'zai/glm-5.2', - ]) -}) diff --git a/src/integrations/gateways/merge-gateway.test.ts b/src/integrations/gateways/merge-gateway.test.ts new file mode 100644 index 0000000000..34ea58ddbb --- /dev/null +++ b/src/integrations/gateways/merge-gateway.test.ts @@ -0,0 +1,442 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, mock, test } from 'bun:test' + +import { getCatalogForGateway } from '../index.js' +import { + getRouteCredentialEnvVars, + resolveActiveRouteIdFromEnv, +} from '../routeMetadata.js' +import { clearManagedProfileEnv } from '../../utils/providerProfile.js' +import { applyProviderFlag } from '../../utils/providerFlag.js' +import { createOpenAIShimClient } from '../../services/api/openaiShim.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { asMockFetch } from '../../test/typedMocks.js' +import mergeGateway from './merge-gateway.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' + +const mapModel = mergeGateway.catalog?.discovery?.mapModel +type OpenAIShimClient = { + beta: { + messages: { + create: (params: Record) => Promise + } + } +} + +describe('Merge Gateway', () => { + describe.each([ + { + routeId: 'merge-gateway', + expected: { + baseUrl: 'https://api-gateway.merge.dev/v1/openai', + defaultModel: 'openai/gpt-5.5', + setup: { + credentialEnvVars: ['MERGE_GATEWAY_API_KEY'], + dedicatedCredentialsOnly: true, + }, + openaiShim: { supportsApiFormatSelection: true }, + catalogSource: 'hybrid', + discovery: { requiresAuth: true }, + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + expect( + getCatalogForGateway(routeId)?.models?.some( + model => model.apiName === 'default_routing', + ), + ).toBe(true) + }) + }) + + test('--provider selects the route and uses only its dedicated credential', async () => { + await acquireSharedMutationLock('merge-gateway.test.ts') + expect(getRouteCredentialEnvVars('merge-gateway')).toEqual([ + 'MERGE_GATEWAY_API_KEY', + ]) + + const previousEnv = { ...process.env } + const originalFetch = globalThis.fetch + try { + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + process.env.OPENAI_API_KEY = 'unrelated-openai-key' + delete process.env.OPENAI_BASE_URL + process.env.OPENAI_AUTH_HEADER = 'X-Stale-Key' + process.env.OPENAI_AUTH_SCHEME = 'Token' + process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-secret' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Stale-Secret: old-secret' + + expect( + applyProviderFlag('merge-gateway', [ + '--provider', + 'merge-gateway', + '--model', + 'default_routing', + ]), + ).toEqual({}) + expect(String(process.env.OPENAI_BASE_URL)).toBe( + 'https://api-gateway.merge.dev/v1/openai', + ) + expect(String(process.env.OPENAI_MODEL)).toBe('default_routing') + expect(resolveActiveRouteIdFromEnv(process.env)).toBe('merge-gateway') + + for (const name of ['OPENAI_AUTH_HEADER', 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', 'ANTHROPIC_CUSTOM_HEADERS']) { + expect(process.env[name]).toBeUndefined() + } + // A direct canonical selection must also ignore stale settings at request time. + process.env.OPENAI_AUTH_HEADER = 'X-Stale-Key' + process.env.OPENAI_AUTH_SCHEME = 'Token' + process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-secret' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Stale-Secret: old-secret' + let authorization: string | null = null + globalThis.fetch = asMockFetch(mock((_input, init) => { + const headers = new Headers(init?.headers) + expect(headers.get('x-stale-key')).toBeNull() + expect(headers.get('x-stale-secret')).toBeNull() + authorization = headers.get('authorization') + return Promise.resolve( + new Response( + JSON.stringify({ + id: 'chatcmpl-merge', + model: 'default_routing', + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ) + })) + + const client = createOpenAIShimClient({ defaultHeaders: { 'X-Stale-Secret': 'old-secret' } }) as OpenAIShimClient + await client.beta.messages.create({ + model: 'default_routing', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(authorization).toBe('Bearer merge-key') + } finally { + mock.restore() + globalThis.fetch = originalFetch + for (const name of Object.keys(process.env)) { + if (!(name in previousEnv)) delete process.env[name] + } + Object.assign(process.env, previousEnv) + releaseSharedMutationLock() + } + }) + + test('maps native and OpenAI-compatible model list shapes', () => { + if (!mapModel) throw new Error('mapModel missing') + + expect( + mapModel({ + model: 'anthropic/claude-sonnet-5', + display_name: 'Claude Sonnet 5', + }), + ).toEqual({ + id: 'merge-gateway-anthropic/claude-sonnet-5', + apiName: 'anthropic/claude-sonnet-5', + label: 'Claude Sonnet 5 (via Merge Gateway)', + }) + expect(mapModel({ id: 'openai/gpt-5.5' })).toEqual({ + id: 'merge-gateway-openai/gpt-5.5', + apiName: 'openai/gpt-5.5', + label: 'openai/gpt-5.5 (via Merge Gateway)', + }) + expect(mapModel({})).toBeNull() + expect(mapModel(null)).toBeNull() + }) +}) + +// Use separate processes so profile creation cannot mask a missing startup key. +test.each(['merge-gateway', 'openai', 'custom'])('%s saved profiles authorize Merge inference and discovery after a fresh startup', async provider => { + const configDir = mkdtempSync(join(tmpdir(), 'merge-profile-')) + const env = { ...process.env } + clearManagedProfileEnv(env) + env.CLAUDE_CONFIG_DIR = configDir + env.OPENCLAUDE_CONFIG_DIR = configDir + env.NODE_ENV = 'production' + const run = async (source: string) => { + const child = Bun.spawn([process.execPath, '--eval', "import { enableConfigs } from './src/utils/config.ts'; enableConfigs();\n" + source], { + cwd: join(import.meta.dir, '../../..'), + env, + stdout: 'pipe', + stderr: 'pipe', + }) + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + expect({ exit, stderr, stdout }).toEqual({ exit: 0, stderr: '', stdout: '' }) + } + try { + await run(` + import assert from 'node:assert/strict'; + import { addProviderProfile, applyProviderProfileToProcessEnv } from './src/utils/providerProfiles.ts'; + import { clearManagedProfileEnv } from './src/utils/providerProfile.ts'; + import { applyProviderFlag } from './src/utils/providerFlag.ts'; + globalThis.fetch = async () => new Response(JSON.stringify({ data: [] })); + const profile = addProviderProfile({ + provider: '${provider}', name: 'Merge', + baseUrl: 'https://api-gateway.merge.dev/v1/openai', + model: 'openai/gpt-5.5', apiKey: 'saved-merge-key', apiFormat: 'chat', + }); + assert.ok(profile); + assert.equal(process.env.MERGE_GATEWAY_API_KEY, 'saved-merge-key'); + applyProviderFlag('openai', ['--provider', 'openai']); + assert.equal(process.env.OPENAI_API_KEY, undefined); + for (const key of [undefined, 'undefined', 'null', 'SUA_CHAVE']) { + clearManagedProfileEnv(process.env); + applyProviderProfileToProcessEnv({ ...profile, apiKey: key }); + assert.equal(Object.hasOwn(process.env, 'MERGE_GATEWAY_API_KEY'), false); + } + process.env.MERGE_GATEWAY_API_KEY = 'ambient-merge-key'; + applyProviderProfileToProcessEnv({ ...profile, apiKey: undefined }); + assert.equal(process.env.MERGE_GATEWAY_API_KEY, '${provider}' === 'merge-gateway' ? 'ambient-merge-key' : undefined); + `) + for (const startup of ['legacy', 'plural']) await run(` + import assert from 'node:assert/strict'; + import { applyActiveProviderProfileFromConfig } from './src/utils/providerProfiles.ts'; + import { applyStartupEnvFromProfile } from './src/utils/providerProfile.ts'; + import { createOpenAIShimClient } from './src/services/api/openaiShim.ts'; + import { discoverModelsForRoute } from './src/integrations/discoveryService.ts'; + assert.equal(process.env.MERGE_GATEWAY_API_KEY, undefined); + if ('${startup}' === 'legacy') assert.equal(await applyStartupEnvFromProfile(), null); + else assert.ok(applyActiveProviderProfileFromConfig()); + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), auth: new Headers(init?.headers).get('authorization') }); + return new Response(JSON.stringify(String(url).endsWith('/models') + ? { data: [{ id: 'openai/gpt-5.5' }] } + : { id: 'chatcmpl-merge', model: 'openai/gpt-5.5', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }] }), + { headers: { 'Content-Type': 'application/json' } }); + }; + await createOpenAIShimClient({}).beta.messages.create({ + model: 'openai/gpt-5.5', messages: [{ role: 'user', content: 'hello' }], max_tokens: 32, stream: false, + }); + await discoverModelsForRoute('merge-gateway', { forceRefresh: true }); + assert.deepEqual(calls, [ + { url: 'https://api-gateway.merge.dev/v1/openai/chat/completions', auth: 'Bearer saved-merge-key' }, + { url: 'https://api-gateway.merge.dev/v1/models', auth: 'Bearer saved-merge-key' }, + ]); + `) + } finally { + rmSync(configDir, { recursive: true, force: true }) + } +}, 30_000) + +test.each(['commandcode', 'apismart', 'concentrate', 'api-route', 'llmtr', 'gmi-cloud'])('%s credentials are not repurposed when a profile is retargeted to Merge', async provider => { + const configDir = mkdtempSync(join(tmpdir(), 'merge-retarget-')) + const env = { ...process.env } + clearManagedProfileEnv(env) + env.CLAUDE_CONFIG_DIR = configDir + env.OPENCLAUDE_CONFIG_DIR = configDir + const source = ` + import assert from 'node:assert/strict'; + import { enableConfigs, getGlobalConfig } from './src/utils/config.ts'; + import { applyProviderProfileToProcessEnv, applyActiveProviderProfileFromConfig } from './src/utils/providerProfiles.ts'; + import { clearManagedProfileEnv } from './src/utils/providerProfile.ts'; + import { createOpenAIShimClient } from './src/services/api/openaiShim.ts'; + import { discoverModelsForRoute } from './src/integrations/discoveryService.ts'; + enableConfigs(); + const profile = { id: 'retargeted', name: 'Retargeted', provider: '${provider}', + baseUrl: 'https://api-gateway.merge.dev/v1/openai', model: 'openai/gpt-5.5', apiKey: 'source-provider-secret' }; + for (const startup of [false, true]) for (const ambientKey of [undefined, 'ambient-merge-key']) { + clearManagedProfileEnv(process.env); + process.env.OPENAI_API_KEY = 'unrelated-generic-key'; + if (ambientKey) process.env.MERGE_GATEWAY_API_KEY = ambientKey; + if (startup) applyActiveProviderProfileFromConfig({ ...getGlobalConfig(), + providerProfiles: [profile], activeProviderProfileId: profile.id }, { force: true }); + else applyProviderProfileToProcessEnv(profile); + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), auth: new Headers(init?.headers).get('authorization') }); + return new Response(JSON.stringify(String(url).endsWith('/models') + ? { data: [{ id: 'openai/gpt-5.5' }] } + : { id: 'chatcmpl-merge', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }] }), + { headers: { 'Content-Type': 'application/json' } }); + }; + await createOpenAIShimClient({}).beta.messages.create({ + model: 'openai/gpt-5.5', messages: [{ role: 'user', content: 'hello' }], max_tokens: 32, stream: false, + }); + await discoverModelsForRoute('merge-gateway', { forceRefresh: true }); + const auth = null; + assert.deepEqual(calls, [ + { url: 'https://api-gateway.merge.dev/v1/openai/chat/completions', auth }, + { url: 'https://api-gateway.merge.dev/v1/models', auth }, + ]); + } + ` + try { + const child = Bun.spawn([process.execPath, '--eval', source], { + cwd: join(import.meta.dir, '../../..'), env, stdout: 'pipe', stderr: 'pipe', + }) + expect(await Promise.all([child.exited, new Response(child.stderr).text(), new Response(child.stdout).text()])).toEqual([0, '', '']) + } finally { + rmSync(configDir, { recursive: true, force: true }) + } +}, 30_000) + +test('a retargeted profile cannot inherit the previous Merge session credential', async () => { + await acquireSharedMutationLock('merge-gateway.test.ts') + const previousEnv = { ...process.env } + try { + const { applyProviderProfileToProcessEnv } = await import('../../utils/providerProfiles.js') + if (!mergeGateway.defaultBaseUrl) throw new Error('defaultBaseUrl missing') + const profile = { + id: 'merge-session', name: 'Merge', provider: 'merge-gateway', + baseUrl: mergeGateway.defaultBaseUrl, model: 'default_routing', + apiKey: 'saved-merge-key', + } + applyProviderProfileToProcessEnv(profile) + expect(process.env.MERGE_GATEWAY_API_KEY).toBe('saved-merge-key') + + applyProviderProfileToProcessEnv({ + ...profile, id: 'retargeted', name: 'Retargeted', provider: 'apismart', + apiKey: 'source-provider-secret', + }) + expect(process.env.MERGE_GATEWAY_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBeUndefined() + } finally { + for (const name of Object.keys(process.env)) { + if (!(name in previousEnv)) delete process.env[name] + } + Object.assign(process.env, previousEnv) + releaseSharedMutationLock() + } +}) + +test('Merge credentials are withheld from retargeted inference and startup', async () => { + const { resolveRouteCredentialValue } = await import('../routeMetadata.js') + const { buildLaunchEnv, createProfileFile } = await import('../../utils/providerProfile.js') + for (const baseUrl of [ + 'https://proxy.example/v1', + 'http://api-gateway.merge.dev/v1/openai', + 'https://api-gateway.merge.dev:8443/v1/openai', + 'https://api-gateway.merge.dev/v1/openai?tenant=proxy', + 'https://api-gateway.merge.dev/v1/openai#proxy', + 'https://api-gateway.merge.dev/v1/other', + ]) { + expect(resolveRouteCredentialValue({ + routeId: 'merge-gateway', baseUrl, + processEnv: { MERGE_GATEWAY_API_KEY: 'merge-key' }, + })).toBeUndefined() + const env = await buildLaunchEnv({ + profile: 'openai', + goal: 'balanced', + persisted: createProfileFile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'merge-gateway', + OPENAI_BASE_URL: baseUrl, OPENAI_MODEL: 'openai/gpt-5.5', + OPENAI_API_KEY: 'merge-key', MERGE_GATEWAY_API_KEY: 'merge-key', + }), + processEnv: { MERGE_GATEWAY_API_KEY: 'ambient-merge-key', OPENAI_API_KEY: 'ambient-merge-key' }, + }) + expect(env.MERGE_GATEWAY_API_KEY).toBeUndefined() + expect(env.OPENAI_API_KEY).toBeUndefined() + } +}) + +test('a startup URL override cannot inherit a saved Merge credential', async () => { + const { buildLaunchEnv, createProfileFile } = await import('../../utils/providerProfile.js') + const persisted = createProfileFile('openai', { + OPENAI_BASE_URL: 'https://api-gateway.merge.dev/v1/openai', + OPENAI_MODEL: 'openai/gpt-5.5', OPENAI_API_KEY: 'saved-merge-key', + MERGE_GATEWAY_API_KEY: 'saved-merge-key', + }) + for (const customKey of [undefined, 'custom-proxy-key']) { + const env = await buildLaunchEnv({ + profile: 'openai', goal: 'balanced', persisted, + processEnv: { OPENAI_BASE_URL: 'https://proxy.example/v1', OPENAI_API_KEY: customKey }, + }) + expect(env.MERGE_GATEWAY_API_KEY).toBeUndefined() + expect(env.OPENAI_API_KEY).toBe(customKey) + } +}) + + +test('configured and inherited Merge teammates authenticate in a fresh process', async () => { + const configDir = mkdtempSync(join(tmpdir(), 'merge-teammate-')) + const env = { ...process.env } + clearManagedProfileEnv(env) + env.CLAUDE_CONFIG_DIR = configDir + env.OPENCLAUDE_CONFIG_DIR = configDir + const source = ` + import assert from 'node:assert/strict'; + import { quote } from 'shell-quote'; + import { resolveOutOfProcessTeammateProvider, applyAgentProviderOverrideToEnv } from './src/services/api/agentRouting.ts'; + import { buildInheritedEnvVars } from './src/utils/swarm/spawnUtils.ts'; + const request = \` import assert from 'node:assert/strict'; + import { createOpenAIShimClient } from './src/services/api/openaiShim.ts'; + import { enableConfigs, saveGlobalConfig } from './src/utils/config.ts'; + import { applySafeConfigEnvironmentVariables, applyConfigEnvironmentVariables } from './src/utils/managedEnv.ts'; + enableConfigs(); + saveGlobalConfig(current => ({ ...current, env: { MERGE_GATEWAY_API_KEY: 'settings-merge-key' } })); + let auth; + globalThis.fetch = async (url, init) => { + auth = new Headers(init?.headers).get('authorization'); + return new Response(JSON.stringify({ id: 'chatcmpl-child', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }] }), { headers: { 'Content-Type': 'application/json' } }); + }; + assert.equal(process.env.MERGE_GATEWAY_API_KEY, process.env.EXPECTED_KEY); + for (const applySettings of [applySafeConfigEnvironmentVariables, applyConfigEnvironmentVariables]) { + for (const hostManaged of [true, false]) { + process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = hostManaged ? '1' : '0'; + process.env.MERGE_GATEWAY_API_KEY = process.env.EXPECTED_KEY; + applySettings(); + await createOpenAIShimClient({}).beta.messages.create({ model: 'openai/gpt-5.5', messages: [{role: 'user', content: 'hello'}], stream: false, max_tokens: 32 }); + assert.equal(auth, 'Bearer ' + (hostManaged ? process.env.EXPECTED_KEY : 'settings-merge-key')); + } + } + \`; + const run = async (command, childEnv) => { + const child = Bun.spawn(command, { env: childEnv, stdout: 'pipe', stderr: 'pipe' }); + assert.deepEqual(await Promise.all([child.exited, new Response(child.stderr).text(), new Response(child.stdout).text()]), [0, '', '']); + }; + for (const parentKey of [undefined, 'parent-key']) { + const childEnv = { ...process.env, EXPECTED_KEY: 'configured-agent-key' }; + if (parentKey) childEnv.MERGE_GATEWAY_API_KEY = parentKey; + const override = resolveOutOfProcessTeammateProvider({ + cliModel: 'openai/gpt-5.5', agentName: 'worker', settings: { + agentModels: { 'openai/gpt-5.5': { base_url: 'https://api-gateway.merge.dev/v1/openai', api_key: 'configured-agent-key' } }, + }, + }); + assert.ok(override); + applyAgentProviderOverrideToEnv(override, childEnv); + await run([process.execPath, '--eval', request], childEnv); + applyAgentProviderOverrideToEnv({ ...override, baseURL: 'https://proxy.example/v1', apiKey: 'custom-key' }, childEnv); + assert.equal(childEnv.MERGE_GATEWAY_API_KEY, undefined); + } + Object.assign(process.env, { CLAUDE_CODE_USE_OPENAI: '1', OPENAI_BASE_URL: 'https://api-gateway.merge.dev/v1/openai', OPENAI_MODEL: 'openai/gpt-5.5', OPENAI_API_KEY: 'parent-key', MERGE_GATEWAY_API_KEY: 'parent-key' }); + const forwarded = buildInheritedEnvVars(); + // A pane's shell may not have inherited any of the leader's credentials. + if (process.platform !== 'win32') { + await run(['/bin/sh', '-c', 'env ' + forwarded + ' ' + quote([process.execPath, '--eval', request])], { + PATH: process.env.PATH, EXPECTED_KEY: 'parent-key', + }); + } + ` + try { + const child = Bun.spawn([process.execPath, '--eval', source], { + cwd: join(import.meta.dir, '../../..'), env, stdout: 'pipe', stderr: 'pipe', + }) + expect(await Promise.all([child.exited, new Response(child.stderr).text(), new Response(child.stdout).text()])).toEqual([0, '', '']) + } finally { + rmSync(configDir, { recursive: true, force: true }) + } +}, 30_000) diff --git a/src/integrations/gateways/merge-gateway.ts b/src/integrations/gateways/merge-gateway.ts new file mode 100644 index 0000000000..8f1b87771c --- /dev/null +++ b/src/integrations/gateways/merge-gateway.ts @@ -0,0 +1,128 @@ +import { defineGateway } from '../define.js' +import { isKnownNonCodingModelId, isRecord } from '../modelMapping.js' + +/** Convert Merge's native catalog object into an OpenClaude route entry. */ +function mapMergeGatewayModel(raw: unknown) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null + } + + const model = raw as Record + const apiName = + typeof model.id === 'string' + ? model.id.trim() + : typeof model.model === 'string' + ? model.model.trim() + : '' + if (!apiName) { + return null + } + + // Native entries describe modalities per vendor. Keep multimodal chat if + // any vendor supports text in and out; media/embedding endpoints cannot serve + // this conversation route. Sparse compatible entries use the existing ID filter. + const modalities = isRecord(model.vendors) + ? Object.values(model.vendors).flatMap(vendor => { + const capabilities = isRecord(vendor) ? vendor.capabilities : undefined + return isRecord(capabilities) && + Array.isArray(capabilities.input) && + Array.isArray(capabilities.output) + ? [{ input: capabilities.input, output: capabilities.output }] + : [] + }) + : [] + if ( + modalities.length > 0 + ? !modalities.some( + ({ input, output }) => input.includes('text') && output.includes('text'), + ) + : isKnownNonCodingModelId(apiName) || /(?:^|\/)veo[-_.]/i.test(apiName) + ) { + return null + } + + const label = + typeof model.display_name === 'string' && model.display_name.trim() + ? model.display_name.trim() + : apiName + + return { + id: `merge-gateway-${apiName}`, + apiName, + label: `${label} (via Merge Gateway)`, + } +} + +export default defineGateway({ + id: 'merge-gateway', + label: 'Merge Gateway', + category: 'aggregating', + defaultBaseUrl: 'https://api-gateway.merge.dev/v1/openai', + defaultModel: 'openai/gpt-5.5', + supportsModelRouting: true, + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['MERGE_GATEWAY_API_KEY'], + dedicatedCredentialsOnly: true, + }, + startup: { + probeReadiness: 'openai-compatible-models', + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + supportsApiFormatSelection: true, + supportsAuthHeaders: false, + ui: { + showAuthHeader: false, + showAuthHeaderValue: false, + showCustomHeaders: false, + }, + }, + }, + preset: { + id: 'merge-gateway', + description: 'Merge Gateway multi-provider model router', + apiKeyEnvVars: ['MERGE_GATEWAY_API_KEY'], + modelEnvVars: ['OPENAI_MODEL'], + vendorId: 'openai', + }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + matchBaseUrlHosts: ['api-gateway.merge.dev'], + }, + credentialEnvVars: ['MERGE_GATEWAY_API_KEY'], + missingCredentialMessage: + 'Merge Gateway auth is required. Set MERGE_GATEWAY_API_KEY.', + }, + catalog: { + source: 'hybrid', + discovery: { + kind: 'openai-compatible', + requiresAuth: true, + path: '/v1/models', + mapModel: mapMergeGatewayModel, + }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + models: [ + { + id: 'merge-gateway-default-routing', + apiName: 'default_routing', + label: 'Default routing policy', + notes: 'Uses the applicable Merge routing policy.', + }, + { + id: 'merge-gateway-gpt-5.5', + apiName: 'openai/gpt-5.5', + label: 'GPT-5.5 (via Merge Gateway)', + modelDescriptorId: 'gpt-5.5', + }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/integrations/gateways/nvidia-nim.test.ts b/src/integrations/gateways/nvidia-nim.test.ts index bb7b396293..96d5a2a3d6 100644 --- a/src/integrations/gateways/nvidia-nim.test.ts +++ b/src/integrations/gateways/nvidia-nim.test.ts @@ -8,6 +8,10 @@ import { describe, expect, test } from 'bun:test' import nvidiaNim from './nvidia-nim.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' const mapModel = nvidiaNim.catalog?.discovery?.mapModel @@ -16,13 +20,20 @@ function shape(id: string, extras: Record = {}) { } describe('nvidia-nim gateway mapModel filter', () => { - test('mapModel is wired and gateway uses hybrid catalog', () => { - expect(mapModel).toBeDefined() - expect(nvidiaNim.catalog?.source).toBe('hybrid') - expect(nvidiaNim.catalog?.discovery?.kind).toBe('openai-compatible') - expect(nvidiaNim.catalog?.discoveryCacheTtl).toBe('1d') - expect(nvidiaNim.catalog?.discoveryRefreshMode).toBe('background-if-stale') - expect(nvidiaNim.catalog?.allowManualRefresh).toBe(true) + describe.each([ + { + routeId: 'nvidia-nim', + expected: { + catalogSource: 'hybrid', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + expect(nvidiaNim.catalog?.discoveryCacheTtl).toBeDefined() + }) }) test('keeps chat / instruct / reasoning / code models', () => { diff --git a/src/integrations/gateways/opencode.test.ts b/src/integrations/gateways/opencode.test.ts index aecf6829a5..e3aa08913b 100644 --- a/src/integrations/gateways/opencode.test.ts +++ b/src/integrations/gateways/opencode.test.ts @@ -12,6 +12,10 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' beforeEach(async () => { await acquireSharedMutationLock('integrations/gateways/opencode.test.ts') @@ -28,6 +32,55 @@ afterEach(() => { } }) +describe('OpenCode gateway catalogs', () => { + describe.each([ + { + routeId: 'opencode', + expected: { + baseUrl: 'https://opencode.ai/zen/v1', + defaultModel: 'gpt-5.4', + category: 'aggregating', + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['OPENCODE_API_KEY'], + }, + preset: { + id: 'opencode', + vendorId: 'openai', + apiKeyEnvVars: ['OPENCODE_API_KEY'], + }, + validation: { kind: 'credential-env' }, + usage: { supported: false }, + catalogSource: 'static', + } satisfies GatewayCatalogExpectations, + }, + { + routeId: 'opencode-go', + expected: { + baseUrl: 'https://opencode.ai/zen/go/v1', + defaultModel: 'glm-5.1', + category: 'aggregating', + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['OPENCODE_API_KEY'], + }, + preset: { + id: 'opencode-go', + vendorId: 'openai', + apiKeyEnvVars: ['OPENCODE_API_KEY'], + }, + catalogSource: 'static', + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) + }) +}) + // --------------------------------------------------------------------------- // Zen Gateway Descriptor Tests // --------------------------------------------------------------------------- @@ -39,77 +92,11 @@ describe('OpenCode Zen gateway descriptor', () => { expect(gateway!.id).toBe('opencode') }) - test('has correct label', () => { - const gateway = getGateway('opencode') - expect(gateway!.label).toBe('OpenCode Zen') - }) - - test('has aggregating category', () => { - const gateway = getGateway('opencode') - expect(gateway!.category).toBe('aggregating') - }) - - test('has correct default base URL', () => { - const gateway = getGateway('opencode') - expect(gateway!.defaultBaseUrl).toBe('https://opencode.ai/zen/v1') - }) - - test('has correct default model', () => { - const gateway = getGateway('opencode') - expect(gateway!.defaultModel).toBe('gpt-5.4') - }) - - test('requires auth', () => { - const gateway = getGateway('opencode') - expect(gateway!.setup.requiresAuth).toBe(true) - }) - - test('uses api-key auth mode', () => { - const gateway = getGateway('opencode') - expect(gateway!.setup.authMode).toBe('api-key') - }) - - test('has OPENCODE_API_KEY in credential env vars', () => { - const gateway = getGateway('opencode') - expect(gateway!.setup.credentialEnvVars).toContain('OPENCODE_API_KEY') - }) - - test('has openai-compatible transport kind', () => { - const gateway = getGateway('opencode') - expect(gateway!.transportConfig.kind).toBe('openai-compatible') - }) - - test('has preset metadata', () => { - const gateway = getGateway('opencode') - expect(gateway!.preset).toBeDefined() - expect(gateway!.preset!.id).toBe('opencode') - expect(gateway!.preset!.vendorId).toBe('openai') - expect(gateway!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') - }) - - test('has validation metadata', () => { - const gateway = getGateway('opencode') - expect(gateway!.validation).toBeDefined() - expect(gateway!.validation!.kind).toBe('credential-env') - }) - - test('has catalog with static source', () => { - const gateway = getGateway('opencode') - expect(gateway!.catalog).toBeDefined() - expect(gateway!.catalog!.source).toBe('static') - }) - test('has static models in catalog', () => { const gateway = getGateway('opencode') expect(gateway!.catalog!.models).toBeDefined() expect(gateway!.catalog!.models!.length).toBeGreaterThan(0) }) - - test('has usage metadata', () => { - const gateway = getGateway('opencode') - expect(gateway!.usage).toBeDefined() - expect(gateway!.usage!.supported).toBe(false) - }) }) // --------------------------------------------------------------------------- @@ -123,60 +110,6 @@ describe('OpenCode Go gateway descriptor', () => { expect(gateway!.id).toBe('opencode-go') }) - test('has correct label', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.label).toBe('OpenCode Go') - }) - - test('has aggregating category', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.category).toBe('aggregating') - }) - - test('has correct default base URL', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.defaultBaseUrl).toBe('https://opencode.ai/zen/go/v1') - }) - - test('has correct default model', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.defaultModel).toBe('glm-5.1') - }) - - test('requires auth', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.setup.requiresAuth).toBe(true) - }) - - test('uses api-key auth mode', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.setup.authMode).toBe('api-key') - }) - - test('has OPENCODE_API_KEY in credential env vars', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.setup.credentialEnvVars).toContain('OPENCODE_API_KEY') - }) - - test('has openai-compatible transport kind', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.transportConfig.kind).toBe('openai-compatible') - }) - - test('has preset metadata with vendorId openai', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.preset).toBeDefined() - expect(gateway!.preset!.id).toBe('opencode-go') - expect(gateway!.preset!.vendorId).toBe('openai') - expect(gateway!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') - }) - - test('has catalog with static source', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.catalog).toBeDefined() - expect(gateway!.catalog!.source).toBe('static') - }) - test('has static models in catalog', () => { const gateway = getGateway('opencode-go') expect(gateway!.catalog!.models).toBeDefined() @@ -282,57 +215,6 @@ describe('OpenCode model catalog', () => { } }) - test('zen model count matches expected', () => { - const models = getCatalogEntriesForRoute('opencode') - expect(models.length).toBe(48) - }) - - test('go model count matches expected', () => { - const models = getCatalogEntriesForRoute('opencode-go') - expect(models.length).toBe(13) - }) - - test('go model set matches opencode.ai/go catalog exactly', () => { - const models = getCatalogEntriesForRoute('opencode-go') - const expectedIds = new Set([ - 'opencode-go-glm-5.2', - 'opencode-go-qwen3.7-max', - 'opencode-go-kimi-k2.7-code', - 'opencode-go-mimo-v2.5-pro', - 'opencode-go-deepseek-v4-pro', - 'opencode-go-qwen3.7-plus', - 'opencode-go-minimax-m3', - 'opencode-go-mimo-v2.5', - 'opencode-go-deepseek-v4-flash', - 'opencode-go-glm-5.1', - 'opencode-go-kimi-k2.6', - 'opencode-go-qwen3.6-plus', - 'opencode-go-minimax-m2.7', - ]) - const actualIds = new Set(models.map(m => m.id)) - // All expected IDs present - for (const id of expectedIds) { - expect(actualIds.has(id)).toBe(true) - } - // No removed or unexpected IDs remain - for (const id of actualIds) { - expect(expectedIds.has(id)).toBe(true) - } - // Explicitly verify removed models are not included - const removedIds = [ - 'opencode-go-minimax-m2.5', - 'opencode-go-kimi-k2.5', - 'opencode-go-glm-5', - 'opencode-go-qwen3.5-plus', - 'opencode-go-mimo-v2-pro', - 'opencode-go-mimo-v2-omni', - 'opencode-go-hy3-preview', - ]; - for (const id of removedIds) { - expect(actualIds.has(id)).toBe(false); - } - }) - test('all zen gpt models have modelDescriptorId', () => { const models = getCatalogEntriesForRoute('opencode') const gptModels = models.filter(m => m.apiName.startsWith('gpt-')) @@ -359,6 +241,23 @@ describe('OpenCode model catalog', () => { } }) + test('go catalog excludes removed model ids', () => { + const models = getCatalogEntriesForRoute('opencode-go') + const ids = models.map(m => m.id) + const removedModelIds = [ + 'opencode-go-minimax-m2.5', + 'opencode-go-kimi-k2.5', + 'opencode-go-glm-5', + 'opencode-go-qwen3.5-plus', + 'opencode-go-mimo-v2-pro', + 'opencode-go-mimo-v2-omni', + 'opencode-go-hy3-preview', + ] + for (const id of removedModelIds) { + expect(ids).not.toContain(id) + } + }) + test('go Anthropic-format models use the messages endpoint with x-api-key auth', () => { const models = getCatalogEntriesForRoute('opencode-go') const messagesModelIds = [ diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index 11f56c7473..f53a64f0a1 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -75,6 +75,9 @@ const zenModels: OpenCodeCatalogSpec[] = [ { id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' }, { id: 'glm-5.1', label: 'GLM 5.1', zaiGlm: true }, { id: 'glm-5', label: 'GLM 5', zaiGlm: true }, + // Retain historical identity for saved selections; suggestion policy hides it. + { id: 'minimax-m3.1-flash-free', label: 'MiniMax M3.1 Flash Free' }, + { id: 'minimax-m3', label: 'MiniMax M3' }, { id: 'minimax-m2.7', label: 'MiniMax M2.7' }, { id: 'minimax-m2.5', label: 'MiniMax M2.5' }, { id: 'kimi-k2.6', label: 'Kimi K2.6' }, @@ -110,7 +113,7 @@ export default defineGateway({ preset: { id: 'opencode', vendorId: 'openai', - description: 'OpenCode Zen - pay-as-you-go AI gateway (48 models)', + description: 'OpenCode Zen - pay-as-you-go AI gateway', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], }, diff --git a/src/integrations/gateways/openrouter.test.ts b/src/integrations/gateways/openrouter.test.ts index 70e6a9ee97..3bf2ffb124 100644 --- a/src/integrations/gateways/openrouter.test.ts +++ b/src/integrations/gateways/openrouter.test.ts @@ -1,16 +1,23 @@ import { describe, expect, test } from 'bun:test' -import openrouter, { mapOpenRouterModel } from './openrouter.js' +import { mapOpenRouterModel } from './openrouter.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' describe('openrouter gateway live model mapping', () => { - test('uses hybrid discovery against the public models list', () => { - expect(openrouter.catalog?.source).toBe('hybrid') - expect(openrouter.catalog?.discovery).toEqual( - expect.objectContaining({ - kind: 'openai-compatible', - requiresAuth: false, - }), - ) - expect(openrouter.catalog?.discovery?.mapModel).toBe(mapOpenRouterModel) + describe.each([ + { + routeId: 'openrouter', + expected: { + catalogSource: 'hybrid', + discovery: { requiresAuth: false, mapModel: mapOpenRouterModel }, + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + }) }) test('maps coding models with context length and free labels', () => { diff --git a/src/integrations/gateways/xiaomi-mimo-token.test.ts b/src/integrations/gateways/xiaomi-mimo-token.test.ts index 385f6960a9..5772021baa 100644 --- a/src/integrations/gateways/xiaomi-mimo-token.test.ts +++ b/src/integrations/gateways/xiaomi-mimo-token.test.ts @@ -6,24 +6,38 @@ import { getRouteDefaultModel, resolveRouteIdFromBaseUrl, } from '../routeMetadata.js' +import { + expectGatewayCatalog, + type GatewayCatalogExpectations, +} from '../../test/gatewayCatalogTestHelpers.js' describe('xiaomi-mimo-token gateway', () => { - test('default base URL is the token-plan SGP endpoint', () => { - expect(getRouteDefaultBaseUrl('xiaomi-mimo-token')).toBe( - 'https://token-plan-sgp.xiaomimimo.com/v1', - ) - }) - - test('default model is mimo-v2.5-pro', () => { - expect(getRouteDefaultModel('xiaomi-mimo-token')).toBe('mimo-v2.5-pro') + describe.each([ + { + routeId: 'xiaomi-mimo-token', + expected: { + baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1', + defaultModel: 'mimo-v2.5-pro', + setup: { credentialEnvVars: ['MIMO_API_KEY'] }, + preset: { apiKeyEnvVars: ['MIMO_API_KEY'] }, + validation: { + kind: 'credential-env', + credentialEnvVars: ['MIMO_API_KEY', 'OPENAI_API_KEY'], + }, + catalogSource: 'static', + } satisfies GatewayCatalogExpectations, + }, + ])('$routeId catalog', ({ routeId, expected }) => { + test('satisfies the registered gateway catalog contract', () => { + expectGatewayCatalog(routeId, expected) + expect(getRouteDefaultBaseUrl(routeId)).toBe(expected.baseUrl) + expect(getRouteDefaultModel(routeId)).toBe(expected.defaultModel) + }) }) - test('catalog is limited to supported v2.5 chat models with OpenAI-compatible reasoning effort', () => { + test('catalog models use OpenAI-compatible reasoning effort', () => { const entries = getCatalogEntriesForRoute('xiaomi-mimo-token') - expect(entries.map(model => model.apiName)).toEqual([ - 'mimo-v2.5-pro', - 'mimo-v2.5', - ]) + expect(entries.length).toBeGreaterThan(0) for (const entry of entries) { expect(entry.reasoning).toEqual({ mode: 'levels', diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 61d9a90170..cae1ada873 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -9,6 +9,7 @@ import vendorAnthropic from '../vendors/anthropic.js' import vendorBankr from '../vendors/bankr.js' import vendorDeepseek from '../vendors/deepseek.js' import vendorFireworks from '../vendors/fireworks.js' +import vendorGeminiVertex from '../vendors/gemini-vertex.js' import vendorGemini from '../vendors/gemini.js' import vendorLongcat from '../vendors/longcat.js' import vendorMinimax from '../vendors/minimax.js' @@ -42,6 +43,7 @@ import gatewayHicap from '../gateways/hicap.js' import gatewayKimiCode from '../gateways/kimi-code.js' import gatewayLlmtr from '../gateways/llmtr.js' import gatewayLmstudio from '../gateways/lmstudio.js' +import gatewayMergeGateway from '../gateways/merge-gateway.js' import gatewayMistral from '../gateways/mistral.js' import gatewayNvidiaNim from '../gateways/nvidia-nim.js' import gatewayOllama from '../gateways/ollama.js' @@ -94,8 +96,8 @@ import modelTencent from '../models/tencent.js' import modelXai from '../models/xai.js' import modelXiaomiMimo from '../models/xiaomi-mimo.js' -export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGemini, vendorLongcat, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] -export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApiRoute, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCommandcode, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGmiCloud, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[] +export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGeminiVertex, vendorGemini, vendorLongcat, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] +export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApiRoute, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCommandcode, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGmiCloud, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMergeGateway, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[] export const ANTHROPIC_PROXY_DESCRIPTORS = [anthropicproxyCustom] as const satisfies readonly AnthropicProxyDescriptor[] export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLing, brandLlama, brandLongcat, brandMacaron, brandMinimax, brandMistral, brandNearai, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandTencent, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[] export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelFireworksMerged, modelGemini, modelGlm, modelGpt, modelKimi, modelLing, modelLlama, modelLongcat, modelMacaron, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelTencent, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index 47f720c89d..3259ff19a7 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -361,6 +361,20 @@ export const PROVIDER_PRESET_MANIFEST = [ "OPENAI_MODEL" ] }, + { + "preset": "merge-gateway", + "routeKind": "gateway", + "routeId": "merge-gateway", + "vendorId": "openai", + "gatewayId": "merge-gateway", + "description": "Merge Gateway multi-provider model router", + "apiKeyEnvVars": [ + "MERGE_GATEWAY_API_KEY" + ], + "modelEnvVars": [ + "OPENAI_MODEL" + ] + }, { "preset": "minimax", "routeKind": "vendor", @@ -460,7 +474,7 @@ export const PROVIDER_PRESET_MANIFEST = [ "routeId": "opencode", "vendorId": "openai", "gatewayId": "opencode", - "description": "OpenCode Zen - pay-as-you-go AI gateway (48 models)", + "description": "OpenCode Zen - pay-as-you-go AI gateway", "apiKeyEnvVars": [ "OPENCODE_API_KEY" ], @@ -641,6 +655,7 @@ export const ORDERED_PROVIDER_PRESETS = [ "atomic-chat", "ollama", "longcat", + "merge-gateway", "minimax", "mistral", "moonshotai", diff --git a/src/integrations/gpt56Catalog.test.ts b/src/integrations/gpt56Catalog.test.ts index 6212185e1a..2bbad628e7 100644 --- a/src/integrations/gpt56Catalog.test.ts +++ b/src/integrations/gpt56Catalog.test.ts @@ -1,32 +1,46 @@ -import { expect, test } from 'bun:test' +import { describe, expect, test } from 'bun:test' -import gptModels from './models/gpt.js' -import openaiVendor from './vendors/openai.js' +import { + getCatalogEntriesForRoute, + getModel, + getRouteDefaultBaseUrl, + getRouteDefaultModel, + getVendor, + resolveRouteIdFromBaseUrl, +} from './index.js' const GPT56_IDS = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'] as const -test('gpt-5.6 model descriptors carry the verified limits', () => { - for (const id of GPT56_IDS) { - const descriptor = gptModels.find(model => model.id === id) - expect(descriptor).toBeDefined() - // Descriptor and catalog intentionally diverge: the descriptor is the - // fallback for catalog-less routes — notably the Codex transport - // (chatgpt.com/backend-api/codex), which enforces a ~272k effective - // input cap (issue #1118 precedent, same as gpt-5.5). The direct-OpenAI - // route reads the vendor catalog entry below, which keeps the true - // 1.05M window. - expect(descriptor?.contextWindow).toBe(272_000) - expect(descriptor?.maxOutputTokens).toBe(128_000) - } -}) +describe('OpenAI GPT-5.6 catalog', () => { + test('links descriptors while keeping route-specific limits and reasoning', () => { + const baseUrl = 'https://api.openai.com/v1' + expect(resolveRouteIdFromBaseUrl(baseUrl)).toBe('openai') + expect(getRouteDefaultBaseUrl('openai')).toBe(baseUrl) + expect(getRouteDefaultModel('openai')).toBe('gpt-5.4') + expect(getVendor('openai')?.setup.credentialEnvVars).toEqual([ + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ]) -test('openai vendor gpt-5.6 catalog entries carry limits and reasoning metadata', () => { - for (const id of GPT56_IDS) { - const entry = openaiVendor.catalog?.models?.find(model => model.id === id) - expect(entry).toBeDefined() - expect(entry?.contextWindow).toBe(1_050_000) - expect(entry?.maxOutputTokens).toBe(128_000) - expect(entry?.reasoning?.wireFormat).toBe('reasoning_effort') - expect(entry?.reasoning?.levels).toContain('xhigh') - } + for (const id of GPT56_IDS) { + const descriptor = getModel(id) + // Catalog-less routes (including Codex) use the 272k descriptor fallback; + // direct OpenAI requests use the catalog's 1.05M context window. + expect(descriptor).toMatchObject({ + id, + defaultModel: id, + contextWindow: 272_000, + maxOutputTokens: 128_000, + }) + const entry = getCatalogEntriesForRoute('openai').find(model => model.id === id) + expect(entry).toMatchObject({ + apiName: id, + modelDescriptorId: descriptor?.id, + contextWindow: 1_050_000, + maxOutputTokens: 128_000, + }) + expect(entry?.reasoning?.wireFormat).toBe('reasoning_effort') + expect(entry?.reasoning?.levels).toContain('xhigh') + } + }) }) diff --git a/src/integrations/index.test.ts b/src/integrations/index.test.ts index 0248f27a96..be562035f7 100644 --- a/src/integrations/index.test.ts +++ b/src/integrations/index.test.ts @@ -67,6 +67,7 @@ describe('loaded registry validation', () => { // Virtual model — the gateway's smart router resolves it server-side, // so there is no concrete model descriptor to reference. 'gitlawb-opengateway:opengateway-auto', + 'merge-gateway:merge-gateway-default-routing', // Cloudflare Workers AI serves provider-specific quantized builds // (`@cf/...`) with no shared cross-provider model descriptor, the same // situation as azure-deployment above. See gateways/cloudflare.ts (#1100). diff --git a/src/integrations/ling-tiny.test.ts b/src/integrations/ling-tiny.test.ts index b947f9c001..0b203550a1 100644 --- a/src/integrations/ling-tiny.test.ts +++ b/src/integrations/ling-tiny.test.ts @@ -4,6 +4,8 @@ import { getCatalogEntriesForRoute, getModel, getModelsForBrand, + getRouteDefaultBaseUrl, + resolveRouteIdFromBaseUrl, } from './index.js' import { resolveModelRuntimeLimits } from './runtimeMetadata.js' @@ -16,46 +18,46 @@ const AT_CUTOFF = new Date('2026-08-13T10:00:00Z') const AFTER_WINDOW = new Date('2026-08-13T10:00:01Z') describe('Ling 3.0 Tiny :free descriptor', () => { - test('exposes the Tiny capabilities and limits to gateway catalogs', () => { - const model = getModel('inclusionai/ling-3.0-tiny:free') + test('links gateway wire models to descriptors and resolves limits by base URL', () => { + const routeId = 'gitlawb-opengateway' + const baseUrl = 'https://opengateway.gitlawb.com/v1' + expect(resolveRouteIdFromBaseUrl(baseUrl)).toBe(routeId) + expect(getRouteDefaultBaseUrl(routeId)).toBe(baseUrl) - expect(model).toBeDefined() - expect(model).toMatchObject({ - id: 'inclusionai/ling-3.0-tiny:free', - brandId: 'ling', - classification: ['chat', 'reasoning', 'coding'], - contextWindow: 262_144, - maxOutputTokens: 32_768, - capabilities: { - supportsVision: false, - supportsStreaming: true, - supportsFunctionCalling: true, - supportsJsonMode: false, - supportsReasoning: true, - supportsPreciseTokenCount: false, + for (const { id, catalogId, limits, classification, capabilities } of [ + { + id: 'inclusionai/ling-3.0-tiny:free', + catalogId: 'opengateway-ling-3.0-tiny-free', + limits: { contextWindow: 262_144, maxOutputTokens: 32_768 }, + classification: ['chat', 'reasoning', 'coding'], + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: false, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, }, - }) - expect(getModelsForBrand('ling').map(m => m.id)).toContain( - 'inclusionai/ling-3.0-tiny:free', - ) - - // The gateway entry must map BOTH the wire id (apiName) and the picker - // descriptor to the :free id — a mismatch would send a different model - // upstream than the picker advertises. - const catalogEntry = getCatalogEntriesForRoute( - 'gitlawb-opengateway', - DURING_WINDOW, - ).find(entry => entry.apiName === 'inclusionai/ling-3.0-tiny:free') - expect(catalogEntry?.id).toBe('opengateway-ling-3.0-tiny-free') - expect(catalogEntry?.modelDescriptorId).toBe(model?.id) - - expect( - resolveModelRuntimeLimits({ - model: 'inclusionai/ling-3.0-tiny:free', - baseUrl: 'https://opengateway.gitlawb.com/v1', - processEnv: {}, - }), - ).toEqual({ contextWindow: 262_144, maxOutputTokens: 32_768 }) + ]) { + const model = getModel(id) + expect(model).toMatchObject({ + id, + brandId: 'ling', + defaultModel: id, + ...limits, + classification, + capabilities, + }) + expect(getModelsForBrand('ling').map(m => m.id)).toContain(id) + const entry = getCatalogEntriesForRoute(routeId, DURING_WINDOW).find( + e => e.id === catalogId, + ) + expect(entry).toMatchObject({ apiName: id, modelDescriptorId: model?.id }) + expect( + resolveModelRuntimeLimits({ model: id, baseUrl, processEnv: {} }), + ).toEqual(limits) + } }) test('availableUntil drops the entry from catalog resolution after the window', () => { diff --git a/src/integrations/macaron.test.ts b/src/integrations/macaron.test.ts index cbb16127dc..e659956fbe 100644 --- a/src/integrations/macaron.test.ts +++ b/src/integrations/macaron.test.ts @@ -4,44 +4,48 @@ import { getCatalogEntriesForRoute, getModel, getModelsForBrand, + getRouteDefaultBaseUrl, + resolveRouteIdFromBaseUrl, } from './index.js' import { resolveModelRuntimeLimits } from './runtimeMetadata.js' describe('Macaron V1 Tall descriptor', () => { - test('exposes the verified Macaron capabilities and limits to gateway catalogs', () => { - const model = getModel('mindai/macaron-v1-tall') + test('links gateway wire models to descriptors and resolves limits by base URL', () => { + const routeId = 'gitlawb-opengateway' + const baseUrl = 'https://opengateway.gitlawb.com/v1' + expect(resolveRouteIdFromBaseUrl(baseUrl)).toBe(routeId) + expect(getRouteDefaultBaseUrl(routeId)).toBe(baseUrl) - expect(model).toBeDefined() - expect(model).toMatchObject({ - id: 'mindai/macaron-v1-tall', - brandId: 'macaron', - classification: ['chat', 'reasoning', 'coding'], - contextWindow: 262_144, - maxOutputTokens: 32_768, - capabilities: { - supportsVision: false, - supportsStreaming: true, - supportsFunctionCalling: true, - supportsJsonMode: false, - supportsReasoning: true, + for (const { id, catalogId, limits, classification, capabilities } of [ + { + id: 'mindai/macaron-v1-tall', + catalogId: 'opengateway-macaron-v1-tall', + limits: { contextWindow: 262_144, maxOutputTokens: 32_768 }, + classification: ['chat', 'reasoning', 'coding'], + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: false, + supportsReasoning: true, + }, }, - }) - expect(getModelsForBrand('macaron').map(m => m.id)).toContain( - 'mindai/macaron-v1-tall', - ) - - const catalogEntry = getCatalogEntriesForRoute('gitlawb-opengateway').find( - entry => entry.apiName === 'mindai/macaron-v1-tall', - ) - expect(catalogEntry?.id).toBe('opengateway-macaron-v1-tall') - expect(catalogEntry?.modelDescriptorId).toBe(model?.id) - - expect( - resolveModelRuntimeLimits({ - model: 'mindai/macaron-v1-tall', - baseUrl: 'https://opengateway.gitlawb.com/v1', - processEnv: {}, - }), - ).toEqual({ contextWindow: 262_144, maxOutputTokens: 32_768 }) + ]) { + const model = getModel(id) + expect(model).toMatchObject({ + id, + brandId: 'macaron', + defaultModel: id, + ...limits, + classification, + capabilities, + }) + expect(getModelsForBrand('macaron').map(m => m.id)).toContain(id) + const entry = getCatalogEntriesForRoute(routeId).find(e => e.id === catalogId) + expect(entry).toMatchObject({ apiName: id, modelDescriptorId: model?.id }) + expect( + resolveModelRuntimeLimits({ model: id, baseUrl, processEnv: {} }), + ).toEqual(limits) + } }) }) diff --git a/src/integrations/models/minimax.ts b/src/integrations/models/minimax.ts index e7930b1bcf..f92feccccc 100644 --- a/src/integrations/models/minimax.ts +++ b/src/integrations/models/minimax.ts @@ -10,6 +10,17 @@ const minimaxM2Capabilities = { } export default [ + defineModel({ + id: 'minimax-m3.1-flash', + label: 'MiniMax M3.1 Flash', + brandId: 'minimax', + vendorId: 'minimax', + classification: ['chat', 'reasoning', 'vision', 'coding'], + defaultModel: 'MiniMax-M3.1-Flash', + capabilities: minimaxM2Capabilities, + contextWindow: 1_000_000, + maxOutputTokens: 524_288, + }), defineModel({ id: 'minimax-m2', label: 'MiniMax M2', @@ -99,7 +110,7 @@ export default [ classification: ['chat', 'reasoning', 'coding'], defaultModel: 'MiniMax-M3', capabilities: minimaxM2Capabilities, - contextWindow: 1_048_576, + contextWindow: 1_000_000, maxOutputTokens: 131_072, }), defineModel({ diff --git a/src/integrations/models/opencode.ts b/src/integrations/models/opencode.ts index 57806a850f..4d2ef684c1 100644 --- a/src/integrations/models/opencode.ts +++ b/src/integrations/models/opencode.ts @@ -11,8 +11,8 @@ type OpenCodeModelSpec = { id: string label: string defaultModel: string - contextWindow: number - maxOutputTokens: number + contextWindow?: number + maxOutputTokens?: number reasoning?: boolean vision?: boolean coding?: boolean @@ -45,6 +45,9 @@ function openCodeModel(spec: OpenCodeModelSpec) { } const zenModels: OpenCodeModelSpec[] = [ + // Zen publishes no token limits. Historical descriptors do not imply availability. + { id: 'opencode-minimax-m3.1-flash-free', label: 'MiniMax M3.1 Flash Free', defaultModel: 'minimax-m3.1-flash-free', reasoning: true, coding: true }, + { id: 'opencode-minimax-m3', label: 'MiniMax M3', defaultModel: 'minimax-m3', reasoning: true, coding: true }, { id: 'opencode-claude-fable-5', label: 'Claude Fable 5', defaultModel: 'claude-fable-5', contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true, vision: true }, { id: 'opencode-claude-opus-4-8', label: 'Claude Opus 4.8', defaultModel: 'claude-opus-4-8', contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true, vision: true }, { id: 'opencode-claude-opus-4-7', label: 'Claude Opus 4.7', defaultModel: 'claude-opus-4-7', contextWindow: 1_000_000, maxOutputTokens: 128_000, reasoning: true, vision: true }, diff --git a/src/integrations/nearai.test.ts b/src/integrations/nearai.test.ts index 927059d553..59ae04a819 100644 --- a/src/integrations/nearai.test.ts +++ b/src/integrations/nearai.test.ts @@ -14,7 +14,12 @@ import { getCatalogEntriesForRoute, getModelsForVendor, } from './registry.js' -import { getRouteDefaultBaseUrl } from './routeMetadata.js' +import { + getRouteDefaultBaseUrl, + getRouteDefaultModel, + getRouteDescriptor, + resolveRouteIdFromBaseUrl, +} from './routeMetadata.js' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -33,30 +38,36 @@ afterEach(() => { }) describe('NearAI Claude Opus 4.8 route', () => { - test('catalog exposes the anthropic/claude-opus-4-8 entry', () => { - const entry = getCatalogEntriesForRoute('nearai').find( - e => e.id === OPUS_48_ID, - ) - expect(entry).toBeDefined() - expect(entry!.apiName).toBe(OPUS_48_ID) - // The entry must link to a model descriptor so the route resolves it. - expect(entry!.modelDescriptorId).toBe(OPUS_48_ID) - }) - - test('catalog entry resolves to a registered NearAI model descriptor', () => { - const model = getModelsForVendor('nearai').find(m => m.id === OPUS_48_ID) - // getModelsForVendor drops entries whose modelDescriptorId has no - // matching descriptor, so a defined result proves the route is wired. - expect(model).toBeDefined() - expect(model!.vendorId).toBe('nearai') - expect(model!.brandId).toBe('nearai') - expect(model!.defaultModel).toBe(OPUS_48_ID) - expect(model!.label).toBe('Claude Opus 4.8') - }) - - test('NearAI route points at the NearAI cloud gateway base URL', () => { - expect(getRouteDefaultBaseUrl('nearai')).toBe( - 'https://cloud-api.near.ai/v1', - ) + test('resolves route defaults, credentials, and catalog descriptor links', () => { + for (const { routeId, baseUrl, id } of [ + { routeId: 'nearai', baseUrl: 'https://cloud-api.near.ai/v1', id: OPUS_48_ID }, + ]) { + expect(resolveRouteIdFromBaseUrl(baseUrl)).toBe(routeId) + expect(getRouteDefaultBaseUrl(routeId)).toBe(baseUrl) + expect(getRouteDefaultModel(routeId)).toBe('anthropic/claude-sonnet-4-6') + expect(getRouteDescriptor(routeId)?.setup.credentialEnvVars).toEqual([ + 'NEARAI_API_KEY', + ]) + const entry = getCatalogEntriesForRoute(routeId).find(e => e.id === id) + expect(entry).toMatchObject({ apiName: id, modelDescriptorId: id }) + const model = getModelsForVendor(routeId).find(m => m.id === entry?.modelDescriptorId) + expect(model).toMatchObject({ + id, + vendorId: routeId, + brandId: 'nearai', + defaultModel: id, + label: 'Claude Opus 4.8', + contextWindow: 1_000_000, + maxOutputTokens: 65_536, + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + }) + } }) }) diff --git a/src/integrations/providerUiMetadata.ts b/src/integrations/providerUiMetadata.ts index 2340f45293..d32ea93686 100644 --- a/src/integrations/providerUiMetadata.ts +++ b/src/integrations/providerUiMetadata.ts @@ -45,6 +45,7 @@ function hasUsableEnvValue(envVar: string, value: string | undefined): boolean { envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY' || envVar === 'LLMTR_API_KEY' || + envVar === 'MERGE_GATEWAY_API_KEY' || envVar === 'CMD_API_KEY' || envVar === 'COMMANDCODE_API_KEY' || envVar === 'COMMAND_CODE_API_KEY' || diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 3d0687e403..0d2df22d88 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -102,6 +102,9 @@ test('resolveActiveRouteIdFromEnv keeps generic OpenAI credentials ahead of env- test('getRouteProviderTypeLabel uses descriptor transport kinds for provider labels', () => { expect(getRouteProviderTypeLabel('anthropic')).toBe('Anthropic native API') expect(getRouteProviderTypeLabel('gemini')).toBe('Gemini API') + expect(getRouteProviderTypeLabel('gemini-vertex')).toBe( + 'Google Vertex AI Gemini API', + ) expect(getRouteProviderTypeLabel('bedrock')).toBe( 'AWS Bedrock Claude API', ) @@ -602,6 +605,24 @@ test('Xiaomi MiMo route metadata uses official OpenAI-compatible defaults', () = expect(resolveRouteIdFromBaseUrl('https://api.mimo-v2.com/v1')).toBe('xiaomi-mimo') }) +test('resolveActiveRouteIdFromEnv resolves gemini-vertex when CLAUDE_CODE_USE_GEMINI_VERTEX is enabled', () => { + expect( + resolveActiveRouteIdFromEnv({ + CLAUDE_CODE_USE_GEMINI_VERTEX: '1', + }), + ).toBe('gemini-vertex') +}) + +test('resolveActiveRouteIdFromEnv gives gemini-vertex precedence over gemini and vertex flags', () => { + expect( + resolveActiveRouteIdFromEnv({ + CLAUDE_CODE_USE_GEMINI_VERTEX: '1', + CLAUDE_CODE_USE_GEMINI: '1', + CLAUDE_CODE_USE_VERTEX: '1', + }), + ).toBe('gemini-vertex') +}) + test('resolveActiveRouteIdFromEnv treats Xiaomi MiMo credential-only env as Xiaomi MiMo', () => { expect( resolveActiveRouteIdFromEnv({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index f249269b18..b45db807d1 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -26,6 +26,7 @@ const TRANSPORT_KIND_PROVIDER_TYPE_LABELS: Partial< > = { 'anthropic-native': 'Anthropic native API', 'gemini-native': 'Gemini API', + 'gemini-vertex': 'Google Vertex AI Gemini API', bedrock: 'AWS Bedrock Claude API', vertex: 'Google Vertex Claude API', 'anthropic-proxy': 'Anthropic-compatible API', @@ -244,6 +245,7 @@ function hasUsableEnvCredentialValue( envVar === 'APISMART_API_KEY' || envVar === 'CONCENTRATE_API_KEY' || envVar === 'LLMTR_API_KEY' || + envVar === 'MERGE_GATEWAY_API_KEY' || envVar === 'CMD_API_KEY' || envVar === 'COMMANDCODE_API_KEY' || envVar === 'COMMAND_CODE_API_KEY' || @@ -558,6 +560,36 @@ export function isCanonicalLlmtrInferenceBaseUrl( } } +const MERGE_GATEWAY_CANONICAL_INFERENCE_BASE_URL = 'https://api-gateway.merge.dev/v1/openai' + +export function isCanonicalMergeGatewayInferenceBaseUrl( + value: string | undefined, +): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + + try { + const canonical = new URL(MERGE_GATEWAY_CANONICAL_INFERENCE_BASE_URL) + const candidate = new URL(trimmed) + const normalizePath = (pathname: string): string => + pathname.replace(/\/+$/, '') || '/' + return ( + candidate.protocol === 'https:' && + !candidate.port && + !candidate.username && + !candidate.password && + !candidate.search && + !candidate.hash && + candidate.hostname.toLowerCase() === canonical.hostname.toLowerCase() && + normalizePath(candidate.pathname) === normalizePath(canonical.pathname) + ) + } catch { + return false + } +} + const COMMANDCODE_CANONICAL_INFERENCE_BASE_URL = 'https://api.commandcode.ai/provider/v1' @@ -933,6 +965,7 @@ function hasNoExplicitNonOpenAICompatibleProvider( return ( !isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) && !isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB) && + !isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI_VERTEX) && !isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI) && !isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL) && !isEnvTruthy(processEnv.CLAUDE_CODE_USE_BEDROCK) && @@ -1319,6 +1352,13 @@ export function resolveRouteCredentialValue( ) { return undefined } + if ( + routeId === 'merge-gateway' && + options?.baseUrl !== undefined && + !isCanonicalMergeGatewayInferenceBaseUrl(options.baseUrl) + ) { + return undefined + } if ( routeId === 'commandcode' && options?.baseUrl !== undefined && @@ -1451,6 +1491,12 @@ export function resolveRouteIdFromBaseUrl( ) { continue } + if ( + route.id === 'merge-gateway' && + !isCanonicalMergeGatewayInferenceBaseUrl(baseUrl) + ) { + continue + } if ( route.id === 'commandcode' && !isCanonicalCommandcodeInferenceBaseUrl(baseUrl) @@ -1482,6 +1528,8 @@ export function resolveRouteIdFromBaseUrl( !isCanonicalConcentrateInferenceBaseUrl(baseUrl)) || (route.id === 'llmtr' && !isCanonicalLlmtrInferenceBaseUrl(baseUrl)) || + (route.id === 'merge-gateway' && + !isCanonicalMergeGatewayInferenceBaseUrl(baseUrl)) || (route.id === 'commandcode' && !isCanonicalCommandcodeInferenceBaseUrl(baseUrl)) || (route.id === 'api-route' && @@ -1528,6 +1576,9 @@ function profileRouteHonorsBaseUrlBoundary( if (routeId === 'llmtr') { return isCanonicalLlmtrInferenceBaseUrl(baseUrl) } + if (routeId === 'merge-gateway') { + return isCanonicalMergeGatewayInferenceBaseUrl(baseUrl) + } if (routeId === 'commandcode') { return isCanonicalCommandcodeInferenceBaseUrl(baseUrl) } @@ -1547,6 +1598,9 @@ export function resolveActiveRouteIdFromEnv( activeProfileBaseUrl?: string }, ): string | null { + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI_VERTEX)) { + return 'gemini-vertex' + } if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) { return 'gemini' } @@ -1604,6 +1658,7 @@ export function resolveActiveRouteIdFromEnv( const route = resolveProfileRoute(options.activeProfileProvider) if ( route.routeId !== 'unknown-fallback' && + route.routeId !== 'gemini-vertex' && route.routeId !== 'openai' && route.routeId !== 'custom' && profileRouteHonorsBaseUrlBoundary( @@ -1639,6 +1694,7 @@ export function resolveActiveRouteIdFromEnv( const route = resolveProfileRoute(options.activeProfileProvider) if ( route.routeId !== 'unknown-fallback' && + route.routeId !== 'gemini-vertex' && route.routeId !== 'openai' && route.routeId !== 'custom' && profileRouteHonorsBaseUrlBoundary( diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 58b5ba5861..ee89f4d9a2 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, it, expect, spyOn } from 'bun:test' +import { describe, it, expect, spyOn, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -26,93 +26,98 @@ import glmModels from './models/glm.js' import zaiVendor from './vendors/zai.js' describe('Z.AI GLM-5.3 descriptor contract', () => { - it('wires the verified GLM-5.3-Flash descriptor and direct catalog contract', () => { - const model = glmModels.find(candidate => candidate.id === 'glm-5.3-flash') - expect(model).toMatchObject({ - id: 'glm-5.3-flash', - label: 'GLM 5.3 Flash', - vendorId: 'zai', - brandId: 'glm', - classification: ['chat', 'reasoning', 'vision', 'coding'], - defaultModel: 'glm-5.3-flash', - contextWindow: 1_000_000, - maxOutputTokens: 131_072, - runtimeMetadataScope: 'catalog', - capabilities: { - supportsVision: true, - supportsStreaming: true, - supportsFunctionCalling: true, - supportsJsonMode: true, - supportsReasoning: true, - supportsPreciseTokenCount: false, - }, - }) + const golden = { + zai: { + defaultModel: 'glm-5.2', + catalogSource: 'static' as const, + models: [ + { + id: 'glm-5.3-flash', + label: 'GLM 5.3 Flash', + catalogLabel: 'GLM-5.3-Flash', + vendorId: 'zai', + brandId: 'glm', + classification: ['chat', 'reasoning', 'vision', 'coding'], + defaultModel: 'glm-5.3-flash', + contextWindow: 1_000_000, + maxOutputTokens: 131_072, + runtimeMetadataScope: 'catalog', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + }, + { + id: 'glm-5.3', + label: 'GLM 5.3', + catalogLabel: 'GLM-5.3', + vendorId: 'zai', + brandId: 'glm', + classification: ['chat', 'reasoning', 'coding'], + defaultModel: 'glm-5.3', + contextWindow: 1_000_000, + maxOutputTokens: 131_072, + runtimeMetadataScope: 'catalog', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + }, + ], + }, + } - expect(glmBrand.modelIds?.[0]).toBe('glm-5.3-flash') - expect(glmBrand.modelIds?.[1]).toBe('glm-5.3') - - const catalogEntries = zaiVendor.catalog?.models ?? [] - const flashEntries = catalogEntries.filter(entry => entry.id === 'glm-5.3-flash') - expect(flashEntries).toHaveLength(1) - expect(catalogEntries[0]).toMatchObject({ - id: 'glm-5.3-flash', - apiName: 'glm-5.3-flash', - label: 'GLM-5.3-Flash', - modelDescriptorId: 'glm-5.3-flash', - reasoning: { - mode: 'levels', - levels: ['low', 'high', 'xhigh'], - wireFormat: 'zai_compatible', - }, - transportOverrides: { - openaiShim: { enableToolStreaming: true }, - }, - }) - expect(catalogEntries[1]?.id).toBe('glm-5.3') - expect(zaiVendor.catalog?.source).toBe('static') - expect(zaiVendor.defaultModel).toBe('glm-5.2') - }) + for (const [routeId, route] of Object.entries(golden)) { + it(`preserves the golden model descriptor and catalog contracts for ${routeId}`, () => { + expect(zaiVendor.id).toBe(routeId) + expect(zaiVendor.defaultModel).toBe(route.defaultModel) + expect(zaiVendor.catalog?.source).toBe(route.catalogSource) - it('wires the verified shared model, brand, and direct catalog entry without changing the default', () => { - const model = glmModels.find(candidate => candidate.id === 'glm-5.3') - expect(model).toMatchObject({ - id: 'glm-5.3', - label: 'GLM 5.3', - vendorId: 'zai', - brandId: 'glm', - classification: ['chat', 'reasoning', 'coding'], - defaultModel: 'glm-5.3', - contextWindow: 1_000_000, - maxOutputTokens: 131_072, - runtimeMetadataScope: 'catalog', - capabilities: { - supportsVision: false, - supportsStreaming: true, - supportsFunctionCalling: true, - supportsJsonMode: true, - supportsReasoning: true, - supportsPreciseTokenCount: false, - }, - }) - expect(glmBrand.modelIds).toContain('glm-5.3') - - const catalogEntry = zaiVendor.catalog?.models?.find(entry => entry.id === 'glm-5.3') - expect(catalogEntry).toMatchObject({ - id: 'glm-5.3', - apiName: 'glm-5.3', - label: 'GLM-5.3', - modelDescriptorId: 'glm-5.3', - reasoning: { - mode: 'levels', - levels: ['low', 'high', 'xhigh'], - wireFormat: 'zai_compatible', - }, - transportOverrides: { - openaiShim: { enableToolStreaming: true }, - }, + for (const expected of route.models) { + const { catalogLabel, ...descriptor } = expected + const model = glmModels.find(candidate => candidate.id === expected.id) + expect(model).toMatchObject(descriptor) + expect(glmBrand.modelIds).toContain(expected.id) + + const catalogEntry = zaiVendor.catalog?.models?.find( + entry => entry.id === expected.id, + ) + expect(catalogEntry).toMatchObject({ + id: expected.id, + apiName: expected.id, + label: catalogLabel, + modelDescriptorId: expected.id, + reasoning: { + mode: 'levels', + levels: ['low', 'high', 'xhigh'], + wireFormat: 'zai_compatible', + }, + transportOverrides: { + openaiShim: { enableToolStreaming: true }, + }, + }) + + expect( + resolveModelRuntimeLimits({ + model: expected.id, + baseUrl: zaiVendor.defaultBaseUrl, + processEnv: { CLAUDE_CODE_USE_OPENAI: '1' }, + }), + ).toEqual({ + contextWindow: expected.contextWindow, + maxOutputTokens: expected.maxOutputTokens, + }) + } }) - expect(zaiVendor.defaultModel).toBe('glm-5.2') - }) + } }) // The discovery cache resolves its path through getClaudeConfigHomeDir(), which @@ -143,6 +148,21 @@ async function withTempConfigDir(fn: () => Promise): Promise { } describe('resolveModelRuntimeLimits', () => { + test('uses Gemini 3.5 Flash Vertex metadata instead of fallback limits', () => { + const limits = resolveModelRuntimeLimits({ + model: 'gemini-3.5-flash', + processEnv: { + CLAUDE_CODE_USE_GEMINI_VERTEX: '1', + }, + activeProfileProvider: 'gemini-vertex', + }) + + expect(limits).toEqual({ + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }) + }) + it('uses discovered custom route context windows from the discovery cache', async () => { await withTempConfigDir(async () => { const baseUrl = 'http://localhost:4000/v1' diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index ad02872fb7..47d4160cb8 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -592,11 +592,14 @@ export function usesAnthropicNativeMessageFormat(options?: { | 'foundry' | 'openai' | 'gemini' + | 'gemini-vertex' | 'github' | 'codex' | 'nvidia-nim' | 'minimax' | 'mistral' + | 'xai' + | 'xiaomi-mimo' }): boolean { const processEnv = options?.processEnv ?? process.env const providerCategory = options?.providerCategory diff --git a/src/integrations/tencent.test.ts b/src/integrations/tencent.test.ts index ed94753c7e..d55679fbb4 100644 --- a/src/integrations/tencent.test.ts +++ b/src/integrations/tencent.test.ts @@ -3,39 +3,46 @@ import { describe, expect, test } from 'bun:test' import { getCatalogEntriesForRoute, getModel, + getRouteDefaultBaseUrl, + resolveRouteIdFromBaseUrl, } from './index.js' import { resolveModelRuntimeLimits } from './runtimeMetadata.js' describe('Tencent HY3 descriptor', () => { - test('exposes the verified HY3 capabilities and limits to gateway catalogs', () => { - const model = getModel('tencent/hy3') + test('links gateway wire models to descriptors and resolves limits by base URL', () => { + const routeId = 'gitlawb-opengateway' + const baseUrl = 'https://opengateway.gitlawb.com/v1' + expect(resolveRouteIdFromBaseUrl(baseUrl)).toBe(routeId) + expect(getRouteDefaultBaseUrl(routeId)).toBe(baseUrl) - expect(model).toBeDefined() - expect(model).toMatchObject({ - id: 'tencent/hy3', - classification: ['chat', 'reasoning', 'coding'], - contextWindow: 262_144, - maxOutputTokens: 131_072, - capabilities: { - supportsVision: false, - supportsStreaming: true, - supportsFunctionCalling: true, - supportsJsonMode: true, - supportsReasoning: true, + for (const { id, catalogId, limits, classification, capabilities } of [ + { + id: 'tencent/hy3', + catalogId: 'opengateway-tencent-hy3', + limits: { contextWindow: 262_144, maxOutputTokens: 131_072 }, + classification: ['chat', 'reasoning', 'coding'], + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + }, }, - }) - - const catalogEntry = getCatalogEntriesForRoute('gitlawb-opengateway').find( - entry => entry.apiName === 'tencent/hy3', - ) - expect(catalogEntry?.modelDescriptorId).toBe(model?.id) - - expect( - resolveModelRuntimeLimits({ - model: 'tencent/hy3', - baseUrl: 'https://opengateway.gitlawb.com/v1', - processEnv: {}, - }), - ).toEqual({ contextWindow: 262_144, maxOutputTokens: 131_072 }) + ]) { + const model = getModel(id) + expect(model).toMatchObject({ + id, + defaultModel: id, + ...limits, + classification, + capabilities, + }) + const entry = getCatalogEntriesForRoute(routeId).find(e => e.id === catalogId) + expect(entry).toMatchObject({ apiName: id, modelDescriptorId: model?.id }) + expect( + resolveModelRuntimeLimits({ model: id, baseUrl, processEnv: {} }), + ).toEqual(limits) + } }) }) diff --git a/src/integrations/vendors/gemini-vertex.ts b/src/integrations/vendors/gemini-vertex.ts new file mode 100644 index 0000000000..b084adc734 --- /dev/null +++ b/src/integrations/vendors/gemini-vertex.ts @@ -0,0 +1,53 @@ +import { defineVendor } from '../define.js' + +export default defineVendor({ + id: 'gemini-vertex', + label: 'Google Vertex AI Gemini', + classification: 'native', + defaultBaseUrl: 'https://aiplatform.googleapis.com', + defaultModel: 'gemini-2.5-flash', + setup: { + requiresAuth: true, + authMode: 'adc', + credentialEnvVars: ['GEMINI_ACCESS_TOKEN', 'GOOGLE_APPLICATION_CREDENTIALS'], + }, + transportConfig: { + kind: 'gemini-vertex', + }, + validation: { + kind: 'gemini-credential', + routing: { + enablementEnvVar: 'CLAUDE_CODE_USE_GEMINI_VERTEX', + }, + missingCredentialMessage: + 'Gemini Vertex requires GEMINI_VERTEX_PROJECT, GOOGLE_CLOUD_PROJECT, GCLOUD_PROJECT, or GOOGLE_PROJECT_ID.', + }, + catalog: { + source: 'static', + models: [ + { + id: 'gemini-2.5-flash', + apiName: 'gemini-2.5-flash', + label: 'Gemini 2.5 Flash', + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }, + { + id: 'gemini-2.5-pro', + apiName: 'gemini-2.5-pro', + label: 'Gemini 2.5 Pro', + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }, + // Thinking model — available on global endpoint only + { + id: 'gemini-3.5-flash', + apiName: 'gemini-3.5-flash', + label: 'Gemini 3.5 Flash', + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/integrations/vendors/longcat.test.ts b/src/integrations/vendors/longcat.test.ts index eee2b6fdc7..f3ed056b40 100644 --- a/src/integrations/vendors/longcat.test.ts +++ b/src/integrations/vendors/longcat.test.ts @@ -17,10 +17,22 @@ describe('longcat vendor', () => { test('registers route defaults, catalog, and preset metadata', () => { ensureIntegrationsLoaded() + for (const baseUrl of [ + 'https://api.longcat.chat/openai', + 'https://api.longcat.chat/openai/v1', + ]) { + expect(resolveRouteIdFromBaseUrl(baseUrl)).toBe('longcat') + } + expect(getRouteDefaultBaseUrl('longcat')).toBe( + 'https://api.longcat.chat/openai/v1', + ) + expect(getRouteDefaultModel('longcat')).toBe('LongCat-2.0') + const vendor = getVendor('longcat') - expect(vendor).toBeDefined() - expect(vendor?.defaultBaseUrl).toBe('https://api.longcat.chat/openai/v1') - expect(vendor?.defaultModel).toBe('LongCat-2.0') + expect(vendor).toMatchObject({ + defaultBaseUrl: 'https://api.longcat.chat/openai/v1', + defaultModel: 'LongCat-2.0', + }) expect(vendor?.setup.credentialEnvVars).toEqual(['LONGCAT_API_KEY']) expect(vendor?.transportConfig.openaiShim?.thinkingRequestFormat).toBe( 'zai-compatible', @@ -32,27 +44,13 @@ describe('longcat vendor', () => { 'stream_options', ) - expect(getRouteDefaultBaseUrl('longcat')).toBe( - 'https://api.longcat.chat/openai/v1', - ) - expect(getRouteDefaultModel('longcat')).toBe('LongCat-2.0') - expect(resolveRouteIdFromBaseUrl('https://api.longcat.chat/openai')).toBe( - 'longcat', - ) - expect( - resolveRouteIdFromBaseUrl('https://api.longcat.chat/openai/v1'), - ).toBe('longcat') - - const catalog = getCatalogEntriesForRoute('longcat') - expect(catalog).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'LongCat-2.0', - apiName: 'LongCat-2.0', - modelDescriptorId: 'LongCat-2.0', - }), - ]), + const entry = getCatalogEntriesForRoute('longcat').find( + entry => entry.id === 'LongCat-2.0', ) + expect(entry).toMatchObject({ + apiName: 'LongCat-2.0', + modelDescriptorId: 'LongCat-2.0', + }) const model = getModel('LongCat-2.0') expect(model).toMatchObject({ @@ -60,7 +58,13 @@ describe('longcat vendor', () => { vendorId: 'longcat', contextWindow: 1_048_576, maxOutputTokens: 131_072, - capabilities: expect.objectContaining({ supportsFunctionCalling: false }), + capabilities: { + supportsStreaming: true, + supportsFunctionCalling: false, + supportsJsonMode: false, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, }) const preset = getProviderPresetUiMetadata('longcat') diff --git a/src/integrations/vendors/minimax.ts b/src/integrations/vendors/minimax.ts index 712061d64d..512766490e 100644 --- a/src/integrations/vendors/minimax.ts +++ b/src/integrations/vendors/minimax.ts @@ -8,7 +8,7 @@ export default defineVendor({ label: 'MiniMax', classification: 'native', defaultBaseUrl: MINIMAX_GLOBAL_ANTHROPIC_BASE_URL, - defaultModel: 'MiniMax-M3', + defaultModel: 'MiniMax-M3.1-Flash', requiredEnvVars: ['MINIMAX_API_KEY'], setup: { requiresAuth: true, @@ -37,6 +37,7 @@ export default defineVendor({ catalog: { source: 'static', models: [ + { id: 'minimax-m3.1-flash', apiName: 'MiniMax-M3.1-Flash', label: 'MiniMax M3.1 Flash', modelDescriptorId: 'minimax-m3.1-flash', contextWindow: 1_000_000 }, { id: 'minimax-m2', apiName: 'MiniMax-M2', label: 'MiniMax M2', modelDescriptorId: 'minimax-m2', contextWindow: 204_800 }, { id: 'minimax-m2.1', apiName: 'MiniMax-M2.1', label: 'MiniMax M2.1', modelDescriptorId: 'minimax-m2.1', contextWindow: 204_800 }, { id: 'minimax-m2.1-highspeed', apiName: 'MiniMax-M2.1-highspeed', label: 'MiniMax M2.1 Highspeed', modelDescriptorId: 'minimax-m2.1-highspeed', contextWindow: 204_800 }, @@ -44,7 +45,7 @@ export default defineVendor({ { id: 'minimax-m2.5-highspeed', apiName: 'MiniMax-M2.5-highspeed', label: 'MiniMax M2.5 Highspeed', modelDescriptorId: 'minimax-m2.5-highspeed', contextWindow: 204_800 }, { id: 'minimax-m2.7', apiName: 'MiniMax-M2.7', label: 'MiniMax M2.7', modelDescriptorId: 'minimax-m2.7', contextWindow: 204_800 }, { id: 'minimax-m2.7-highspeed', apiName: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7 Highspeed', modelDescriptorId: 'minimax-m2.7-highspeed', contextWindow: 204_800 }, - { id: 'minimax-m3', apiName: 'MiniMax-M3', label: 'MiniMax M3', modelDescriptorId: 'minimax-m3', contextWindow: 1_048_576 }, + { id: 'minimax-m3', apiName: 'MiniMax-M3', label: 'MiniMax M3', modelDescriptorId: 'minimax-m3', contextWindow: 1_000_000 }, { id: 'minimax-text-01', apiName: 'MiniMax-Text-01', label: 'MiniMax Text 01', modelDescriptorId: 'minimax-text-01' }, { id: 'minimax-text-01-preview', apiName: 'MiniMax-Text-01-Preview', label: 'MiniMax Text 01 Preview', modelDescriptorId: 'minimax-text-01-preview' }, { id: 'minimax-vision-01', apiName: 'MiniMax-Vision-01', label: 'MiniMax Vision 01', modelDescriptorId: 'minimax-vision-01' }, diff --git a/src/integrations/vendors/xai.test.ts b/src/integrations/vendors/xai.test.ts index fd055f4153..2a88fcdbda 100644 --- a/src/integrations/vendors/xai.test.ts +++ b/src/integrations/vendors/xai.test.ts @@ -1,110 +1,85 @@ import { describe, expect, test } from 'bun:test' +import type { ModelCatalogEntry } from '../descriptors.js' +import { + getCatalogEntriesForRoute, + getModel, + getRouteDefaultBaseUrl, + getRouteDefaultModel, + getVendor, + resolveRouteIdFromBaseUrl, +} from '../index.js' import xai from './xai.js' const mapModel = xai.catalog?.discovery?.mapModel -function shape(id: string, extras: Record = {}) { - return { id, ...extras } -} - describe('xAI vendor hybrid catalog', () => { - test('uses hybrid discovery with curated Grok 4.6 as the default', () => { + test('registers route defaults, credentials, and descriptor links', () => { expect(mapModel).toBeDefined() expect(xai.catalog?.source).toBe('hybrid') expect(xai.catalog?.discovery?.kind).toBe('openai-compatible') - expect(xai.catalog?.discoveryCacheTtl).toBe('1d') - expect(xai.catalog?.discoveryRefreshMode).toBe('background-if-stale') - expect(xai.catalog?.allowManualRefresh).toBe(true) - expect(xai.defaultModel).toBe('grok-4.6') - expect(xai.catalog?.models?.map(model => model.apiName)).toEqual([ - 'grok-4.6', - 'grok-4.5', - 'grok-4.3', - 'grok-build-0.1', - 'grok-4.20-0309-reasoning', - 'grok-4.20-0309-non-reasoning', - ]) - }) - - test('keeps chat Grok IDs including later uncataloged releases', () => { - if (!mapModel) throw new Error('mapModel missing') - const keep = [ - 'grok-4.6', - 'grok-4.5', - 'grok-4.3', - 'grok-4.7', - 'grok-build-0.1', - 'grok-4.20-0309-reasoning', - ] - for (const id of keep) { - expect(mapModel(shape(id))).toEqual({ - id, - apiName: id, - label: id, - }) - } - }) - - test('drops Imagine, voice, STT/TTS, and embedding models', () => { - if (!mapModel) throw new Error('mapModel missing') - const drop = [ - 'grok-imagine-image', - 'grok-imagine-image-quality', - 'grok-imagine-video-1.5', - 'grok-voice-think-fast-1.0', - 'grok-voice-think-fast-2.0', - 'grok-stt-1.0', - 'grok-tts-1.0', - // This model requires xAI's Responses API and does not support the - // generic OpenAI-compatible chat transport used by live discovery. - 'grok-4.20-multi-agent-0309', - ] - for (const id of drop) { - expect(mapModel(shape(id))).toBeNull() + for (const { routeId, baseUrl, model } of [ + { routeId: 'xai', baseUrl: 'https://api.x.ai/v1', model: 'grok-4.6' }, + ]) { + expect(resolveRouteIdFromBaseUrl(baseUrl)).toBe(routeId) + expect(getRouteDefaultBaseUrl(routeId)).toBe(baseUrl) + expect(getRouteDefaultModel(routeId)).toBe(model) + expect(getVendor(routeId)?.setup.credentialEnvVars).toEqual(['XAI_API_KEY']) + const catalog = getCatalogEntriesForRoute(routeId) + expect(catalog.some(entry => entry.apiName === model)).toBe(true) + for (const entry of catalog) { + const descriptor = getModel(entry.modelDescriptorId ?? '') + expect(descriptor).toBeDefined() + expect(descriptor?.defaultModel).toBe(entry.apiName) + } } + expect(getModel('grok-4.6')).toMatchObject({ + contextWindow: 500_000, + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + }) }) - test('drops curated aliases so hybrid merge does not duplicate them', () => { + test('maps chat discovery entries and rejects unsupported or invalid entries', () => { if (!mapModel) throw new Error('mapModel missing') - const drop = [ - 'latest', - 'grok-4.6-latest', - 'grok-4.5-latest', - 'grok-build-latest', - 'grok-latest', - 'grok-code-fast-1', + const chat = (id: string): ModelCatalogEntry => ({ id, apiName: id, label: id }) + const cases: Array<{ raw: unknown, expected: ModelCatalogEntry | null }> = [ + { raw: { id: 'grok-4.6' }, expected: chat('grok-4.6') }, + { raw: { id: 'grok-4.7' }, expected: chat('grok-4.7') }, + { raw: { id: 'grok-build-0.1' }, expected: chat('grok-build-0.1') }, + { raw: { id: 'grok-4.20-0309-reasoning' }, expected: chat('grok-4.20-0309-reasoning') }, + { raw: { id: 'grok-imagine-image' }, expected: null }, + { raw: { id: 'grok-imagine-video-1.5' }, expected: null }, + { raw: { id: 'grok-voice-think-fast-1.0' }, expected: null }, + { raw: { id: 'grok-stt-1.0' }, expected: null }, + { raw: { id: 'grok-tts-1.0' }, expected: null }, + { raw: { id: 'grok-embed-1.0' }, expected: null }, + { raw: { id: 'grok-4.20-multi-agent-0309' }, expected: null }, + { raw: { id: 'latest' }, expected: null }, + { raw: { id: 'grok-4.6-latest' }, expected: null }, + { raw: { id: 'grok-build-latest' }, expected: null }, + { raw: { id: 'grok-code-fast-1' }, expected: null }, + { raw: { id: 'grok-4.6', active: false }, expected: null }, + { raw: null, expected: null }, + { raw: {}, expected: null }, + { raw: { id: 1 }, expected: null }, + { raw: { id: '' }, expected: null }, + { raw: { id: 'other-model' }, expected: null }, + { raw: { id: 'grok-4.7', context_length: 0 }, expected: chat('grok-4.7') }, + { raw: { id: 'grok-4.7', context_length: -1 }, expected: chat('grok-4.7') }, + { raw: { id: 'grok-4.7', context_length: Number.NaN }, expected: chat('grok-4.7') }, + { + raw: { id: 'grok-4.6', context_length: 500_000 }, + expected: { ...chat('grok-4.6'), contextWindow: 500_000 }, + }, ] - for (const id of drop) { - expect(mapModel(shape(id))).toBeNull() + for (const { raw, expected } of cases) { + expect(mapModel(raw)).toEqual(expected) } }) - - test('drops inactive entries, missing ids, and non-positive context limits', () => { - if (!mapModel) throw new Error('mapModel missing') - expect(mapModel(shape('grok-4.6', { active: false }))).toBeNull() - expect(mapModel(null)).toBeNull() - expect(mapModel({})).toBeNull() - expect(mapModel({ id: 1 })).toBeNull() - expect(mapModel({ id: '' })).toBeNull() - expect(mapModel(shape('grok-4.7', { context_length: 0 }))).toEqual({ - id: 'grok-4.7', - apiName: 'grok-4.7', - label: 'grok-4.7', - }) - expect(mapModel(shape('grok-4.7', { context_length: Number.NaN }))).toEqual({ - id: 'grok-4.7', - apiName: 'grok-4.7', - label: 'grok-4.7', - }) - }) - - test('forwards the /v1/models context_length when present', () => { - if (!mapModel) throw new Error('mapModel missing') - expect(mapModel(shape('grok-4.6', { context_length: 500000 }))).toEqual({ - id: 'grok-4.6', - apiName: 'grok-4.6', - label: 'grok-4.6', - contextWindow: 500000, - }) - }) }) diff --git a/src/keybindings/defaultBindings.ts b/src/keybindings/defaultBindings.ts index 886b1a92bb..2f964aa6f3 100644 --- a/src/keybindings/defaultBindings.ts +++ b/src/keybindings/defaultBindings.ts @@ -16,13 +16,12 @@ const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v' // Modifier-only chords (like shift+tab) may fail on Windows Terminal without VT mode // See: https://github.com/microsoft/terminal/issues/879#issuecomment-618801651 -// Node enabled VT mode in 24.2.0 / 22.17.0: https://github.com/nodejs/node/pull/58358 +// Every supported Node version enables VT mode. // Bun enabled VT mode in 1.2.23: https://github.com/oven-sh/bun/pull/21161 const SUPPORTS_TERMINAL_VT_MODE = getPlatform() !== 'windows' || - (isRunningWithBun() - ? satisfies(process.versions.bun, '>=1.2.23') - : satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0')) + !isRunningWithBun() || + satisfies(process.versions.bun, '>=1.2.23') // Platform-specific mode cycle shortcut: // - Windows without VT mode: meta+m (shift+tab doesn't work reliably) diff --git a/src/keybindings/loadUserBindings.ts b/src/keybindings/loadUserBindings.ts index 05d9856df2..8d68a05d80 100644 --- a/src/keybindings/loadUserBindings.ts +++ b/src/keybindings/loadUserBindings.ts @@ -391,7 +391,7 @@ export async function initializeKeybindingWatcher(): Promise { pollInterval: FILE_STABILITY_POLL_INTERVAL_MS, }, ignorePermissionErrors: true, - usePolling: false, + backend: 'auto', atomic: true, }) diff --git a/src/main.tsx b/src/main.tsx index 1e1cc2888f..45e72a6179 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -443,7 +443,7 @@ export function startDeferredPrefetches(): void { if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) { void prefetchAwsCredentialsAndBedRockInfoIfSafe(); } - if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { + if ((isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX)) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { void prefetchGcpCredentialsIfSafe(); } const { signal: countFilesSignal, cleanup: cleanupCountFilesSignal } = @@ -897,7 +897,7 @@ async function run(): Promise { // If not provided but flag is present, value will be true // The actual filtering is handled in debug.ts by parsing process.argv return true; - }).addOption(new Option('-d2e, --debug-to-stderr', 'Enable debug mode (to stderr)').argParser(Boolean).hideHelp()).option('--debug-file ', 'Write debug logs to a specific file path (implicitly enables debug mode)', () => true).option('--verbose', 'Override verbose mode setting from config', () => true).option('-p, --print', 'Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust.', () => true).addOption(new Option('--heartbeat ', 'Emit a headless liveness heartbeat while output is quiet (only works with --print, e.g. 30s, 2m; pre-stream startup heartbeats use stderr)').argParser(value => { + }).addOption(new Option('--debug-to-stderr', 'Enable debug mode (to stderr)').argParser(Boolean).hideHelp()).option('--debug-file ', 'Write debug logs to a specific file path (implicitly enables debug mode)', () => true).option('--verbose', 'Override verbose mode setting from config', () => true).option('-p, --print', 'Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust.', () => true).addOption(new Option('--heartbeat ', 'Emit a headless liveness heartbeat while output is quiet (only works with --print, e.g. 30s, 2m; pre-stream startup heartbeats use stderr)').argParser(value => { try { return parseHeadlessHeartbeatDuration(value); } catch (error) { diff --git a/src/native-ts/color-diff/index.ts b/src/native-ts/color-diff/index.ts index 2d81f27387..e582e54490 100644 --- a/src/native-ts/color-diff/index.ts +++ b/src/native-ts/color-diff/index.ts @@ -2,8 +2,8 @@ * Pure TypeScript port of vendor/color-diff-src. * * The Rust version uses syntect+bat for syntax highlighting and the similar - * crate for word diffing. This port uses highlight.js (already a dep via - * cli-highlight) and the diff npm package's diffArrays. + * crate for word diffing. This port uses highlight.js and the diff npm + * package's diffArrays. * * API matches vendor/color-diff-src/index.d.ts exactly so callers don't change. * diff --git a/src/plugins/bundled/aimlapiMcp.test.ts b/src/plugins/bundled/aimlapiMcp.test.ts new file mode 100644 index 0000000000..80d8af85a5 --- /dev/null +++ b/src/plugins/bundled/aimlapiMcp.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' + +import { + AIMLAPI_MCP_URL, + DEFAULT_PARTNER_ID, +} from '../../integrations/aimlapi/config.js' +import { getMcpServerHeaders } from '../../services/mcp/headersHelper.js' +import type { McpHTTPServerConfig } from '../../services/mcp/types.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { getPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js' +import { applyProviderProfileToProcessEnv } from '../../utils/providerProfiles.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from '../../utils/settings/settingsCache.js' +import type { SettingsJson } from '../../utils/settings/types.js' +import { clearBuiltinPlugins, getBuiltinPlugins } from '../builtinPlugins.js' +import { + AIMLAPI_MCP_PLUGIN_NAME, + hasAimlapiMcpCredential, + registerAimlapiMcpPlugin, +} from './aimlapiMcp.js' + +const PLUGIN_ID = `${AIMLAPI_MCP_PLUGIN_NAME}@builtin` +const TEST_KEY = 'aimlapi-test-key-not-real' +const envNames = [ + 'AIMLAPI_API_KEY', + 'OPENAI_BASE_URL', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', +] as const +const originalEnv = Object.fromEntries( + envNames.map(name => [name, process.env[name]]), +) + +// Test files share a process, and an earlier file can leave its own +// settings.js mock registered. Built-in plugins read their enabled state +// through getSettings_DEPRECATED, so pin it to this file's settings and put +// the real module back after each test. +const realSettings = { + ...await import(`../../utils/settings/settings.js?real=${Date.now()}-${Math.random()}`), +} as typeof import('../../utils/settings/settings.js') + +let settingsForTest: SettingsJson = {} + +function useEnabledPlugins(enabledPlugins?: Record): void { + settingsForTest = enabledPlugins ? { enabledPlugins } : {} + setSessionSettingsCache({ settings: settingsForTest, errors: [] }) +} + +beforeEach(async () => { + await acquireSharedMutationLock('plugins/bundled/aimlapiMcp.test.ts') + settingsForTest = {} + mock.module('../../utils/settings/settings.js', () => ({ + ...realSettings, + getSettings_DEPRECATED: () => settingsForTest, + })) + for (const name of envNames) delete process.env[name] + registerAimlapiMcpPlugin() +}) + +afterEach(() => { + try { + clearBuiltinPlugins() + mock.module('../../utils/settings/settings.js', () => realSettings) + resetSettingsCache() + settingsForTest = {} + for (const name of envNames) { + const value = originalEnv[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } finally { + releaseSharedMutationLock() + } +}) + +test('stays disabled when an AIMLAPI key is present but the user has not opted in', () => { + process.env.AIMLAPI_API_KEY = TEST_KEY + useEnabledPlugins() + + const { enabled, disabled } = getBuiltinPlugins() + + expect(enabled.map(plugin => plugin.name)).not.toContain( + AIMLAPI_MCP_PLUGIN_NAME, + ) + expect(disabled.map(plugin => plugin.name)).toContain(AIMLAPI_MCP_PLUGIN_NAME) +}) + +test('enabling the plugin exposes the MCP server with the configured key and attribution', async () => { + process.env.AIMLAPI_API_KEY = TEST_KEY + useEnabledPlugins({ [PLUGIN_ID]: true }) + + const plugin = getBuiltinPlugins().enabled.find( + candidate => candidate.name === AIMLAPI_MCP_PLUGIN_NAME, + ) + expect(plugin).toBeDefined() + + const servers = await getPluginMcpServers(plugin!) + const server = servers?.[`plugin:${AIMLAPI_MCP_PLUGIN_NAME}:aimlapi`] as + | McpHTTPServerConfig + | undefined + expect(server?.type).toBe('http') + expect(server?.url).toBe(AIMLAPI_MCP_URL) + + expect(await getMcpServerHeaders('plugin:aimlapi:aimlapi', server!)).toEqual({ + Authorization: `Bearer ${TEST_KEY}`, + 'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID, + 'X-AIMLAPI-Source': 'agent/openclaude', + }) +}) + +test('the stored server config references the key instead of embedding it', () => { + process.env.AIMLAPI_API_KEY = TEST_KEY + useEnabledPlugins({ [PLUGIN_ID]: true }) + + const plugin = getBuiltinPlugins().enabled.find( + candidate => candidate.name === AIMLAPI_MCP_PLUGIN_NAME, + ) + const serialized = JSON.stringify(plugin?.mcpServers) + + expect(serialized).toContain('${AIMLAPI_API_KEY}') + expect(serialized).not.toContain(TEST_KEY) +}) + +test('is hidden without an AIMLAPI key even when enabled', () => { + useEnabledPlugins({ [PLUGIN_ID]: true }) + + const { enabled, disabled } = getBuiltinPlugins() + + expect( + [...enabled, ...disabled].map(plugin => plugin.name), + ).not.toContain(AIMLAPI_MCP_PLUGIN_NAME) +}) + +test('withholds a credential the aimlapi route exported for a proxy base URL', () => { + expect(hasAimlapiMcpCredential({ AIMLAPI_API_KEY: TEST_KEY })).toBe(true) + expect(hasAimlapiMcpCredential({ AIMLAPI_API_KEY: ' ' })).toBe(false) + expect( + hasAimlapiMcpCredential({ + AIMLAPI_API_KEY: TEST_KEY, + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi', + OPENAI_BASE_URL: 'https://api.aimlapi.com/v1', + }), + ).toBe(true) + expect( + hasAimlapiMcpCredential({ + AIMLAPI_API_KEY: TEST_KEY, + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi', + OPENAI_BASE_URL: 'https://proxy.example.test/v1', + }), + ).toBe(false) + // Another provider's base URL does not make an exported AIMLAPI key a proxy one. + expect( + hasAimlapiMcpCredential({ + AIMLAPI_API_KEY: TEST_KEY, + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'openrouter', + OPENAI_BASE_URL: 'https://openrouter.ai/api/v1', + }), + ).toBe(true) +}) + +function withRestoredProcessEnv(run: () => void): void { + const snapshot = { ...process.env } + try { + run() + } finally { + for (const name of Object.keys(process.env)) { + if (!(name in snapshot)) delete process.env[name] + } + Object.assign(process.env, snapshot) + } +} + +test('a saved aimlapi.com provider profile supplies the key without exporting it', () => { + withRestoredProcessEnv(() => { + applyProviderProfileToProcessEnv({ + id: 'provider_aimlapi', + name: 'AI/ML API', + provider: 'aimlapi', + baseUrl: 'https://api.aimlapi.com/v1', + model: 'gpt-4o', + apiKey: TEST_KEY, + }) + + expect(process.env.AIMLAPI_API_KEY).toBe(TEST_KEY) + expect(hasAimlapiMcpCredential()).toBe(true) + }) +}) + +test('a saved aimlapi profile retargeted to a proxy never offers its credential', () => { + withRestoredProcessEnv(() => { + applyProviderProfileToProcessEnv({ + id: 'provider_aimlapi_proxy', + name: 'AI/ML API Proxy', + provider: 'aimlapi', + baseUrl: 'https://proxy.example.test/v1', + model: 'gpt-4o', + apiKey: 'proxy-credential', + }) + + expect(process.env.AIMLAPI_API_KEY).toBe('proxy-credential') + expect(hasAimlapiMcpCredential()).toBe(false) + }) +}) diff --git a/src/plugins/bundled/aimlapiMcp.ts b/src/plugins/bundled/aimlapiMcp.ts new file mode 100644 index 0000000000..83dfa12cf1 --- /dev/null +++ b/src/plugins/bundled/aimlapiMcp.ts @@ -0,0 +1,64 @@ +import { + AIMLAPI_MCP_URL, + isCanonicalAimlapiInferenceBaseUrl, +} from '../../integrations/aimlapi/config.js' +import { registerBuiltinPlugin } from '../builtinPlugins.js' + +export const AIMLAPI_MCP_PLUGIN_NAME = 'aimlapi' +export const AIMLAPI_MCP_SERVER_NAME = 'aimlapi' + +/** + * The plugin authenticates with the AI/ML API key the user already configured. + * It is an environment reference, expanded when the server config is loaded, + * so the key itself is never copied into settings or an MCP config file. An + * active aimlapi.com provider profile exports its saved key as + * AIMLAPI_API_KEY for the session, and a user can export it directly. + */ +const AIMLAPI_MCP_AUTHORIZATION = 'Bearer ${AIMLAPI_API_KEY}' + +/** + * Whether the session holds an AI/ML API key this plugin may send to the MCP + * host. When the aimlapi route is retargeted to a non-canonical base URL (a + * user proxy), the profile exports that proxy's credential under the same + * variable name, so it is withheld rather than forwarded to aimlapi.com. + */ +export function hasAimlapiMcpCredential( + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (!env.AIMLAPI_API_KEY?.trim()) return false + const baseUrl = env.OPENAI_BASE_URL?.trim() + if ( + env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'aimlapi' && + baseUrl && + !isCanonicalAimlapiInferenceBaseUrl(baseUrl) + ) { + return false + } + return true +} + +/** + * Opt-in built-in plugin exposing the AI/ML API MCP server (Toolbox `tools_*`, + * image/video/audio generation, model and account tools). Disabled by default: + * Toolbox runs and generations are billed to the user's AI/ML API balance, so + * a key being present must never connect it on its own. Enable it with + * `openclaude plugin enable aimlapi@builtin` or from the /plugin UI. Attribution + * headers are added for the MCP host by `getMcpServerHeaders`. + */ +export function registerAimlapiMcpPlugin(): void { + registerBuiltinPlugin({ + name: AIMLAPI_MCP_PLUGIN_NAME, + description: + 'AI/ML API MCP server: Toolbox tools, image/video/audio generation, model and account tools. Uses your AIMLAPI_API_KEY; tool runs and generations are billed to your AI/ML API balance.', + version: '1.0.0', + defaultEnabled: false, + isAvailable: () => hasAimlapiMcpCredential(), + mcpServers: { + [AIMLAPI_MCP_SERVER_NAME]: { + type: 'http', + url: AIMLAPI_MCP_URL, + headers: { Authorization: AIMLAPI_MCP_AUTHORIZATION }, + }, + }, + }) +} diff --git a/src/plugins/bundled/index.ts b/src/plugins/bundled/index.ts index c27876ceb1..daae3d3b26 100644 --- a/src/plugins/bundled/index.ts +++ b/src/plugins/bundled/index.ts @@ -14,6 +14,7 @@ * 2. Call that helper from initBuiltinPlugins() */ +import { registerAimlapiMcpPlugin } from './aimlapiMcp.js' import { registerKarpathyGuidelinesPlugin } from './karpathyGuidelines.js' /** @@ -21,4 +22,5 @@ import { registerKarpathyGuidelinesPlugin } from './karpathyGuidelines.js' */ export function initBuiltinPlugins(): void { registerKarpathyGuidelinesPlugin() + registerAimlapiMcpPlugin() } diff --git a/src/schemas/hooks.test.ts b/src/schemas/hooks.test.ts new file mode 100644 index 0000000000..5a706b3a0b --- /dev/null +++ b/src/schemas/hooks.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + BashCommandHook, + HookCommandSchema, + HooksSchema, +} from './hooks.js' +import { PluginHooksSchema } from '../utils/plugins/schemas.js' +import { SettingsSchema } from '../utils/settings/types.js' +import { parseSettingsFile } from '../utils/settings/settings.js' +import { validateSettingsFileContent } from '../utils/settings/validation.js' + +const validCommandHook = { + type: 'command', + command: "sh -c 'printf HANDLER_RAN; cat'", + timeout: 30, +} as const + +/** Command hooks do not support a separate `args` field (gh-2304). */ +describe('command hook schema rejects a supplied args field', () => { + test('rejects an args array instead of silently stripping it', () => { + const result = HookCommandSchema().safeParse({ + type: 'command', + command: 'sh', + args: ['-c', 'printf HANDLER_RAN; cat'], + }) + expect(result.success).toBe(false) + if (!result.success) { + const issue = result.error.issues.find(i => i.path.includes('args')) + expect(issue).toBeDefined() + // The error must be actionable: it names the unsupported field and + // says where the complete invocation belongs. + expect(issue?.message).toContain('args') + expect(issue?.message).toContain('command') + } + }) + + test('rejects every JSON-expressible non-undefined args value', () => { + const supplied: unknown[] = [ + null, + [], + ['--flag'], + '', + 'value', + 0, + false, + {}, + ] + for (const args of supplied) { + const result = HookCommandSchema().safeParse({ + type: 'command', + command: 'sh', + args, + }) + expect(result.success).toBe(false) + } + }) + + test('accepts an absent args key and an explicit undefined args', () => { + // JSON cannot express undefined: an absent key (or an explicit undefined + // in programmatically constructed objects) means the field was not + // supplied and must keep parsing. + const absent = HookCommandSchema().safeParse({ + type: 'command', + command: 'sh -c "cat"', + }) + expect(absent.success).toBe(true) + + const explicitUndefined = HookCommandSchema().safeParse({ + type: 'command', + command: 'sh -c "cat"', + args: undefined, + }) + expect(explicitUndefined.success).toBe(true) + }) + + test('valid command hooks parse unchanged, including optional fields', () => { + const result = HookCommandSchema().safeParse(validCommandHook) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual(validCommandHook) + } + }) + + test('unknown fields other than args are still tolerated for compatibility', () => { + const result = HookCommandSchema().safeParse({ + type: 'command', + command: 'echo ok', + description: 'author-facing note, not executed', + }) + expect(result.success).toBe(true) + if (result.success) { + expect((result.data as Record).description).toBeUndefined() + expect((result.data as BashCommandHook).command).toBe('echo ok') + } + }) + + test('non-command hook types keep parsing via the discriminated union', () => { + const prompt = HookCommandSchema().safeParse({ + type: 'prompt', + prompt: 'Summarize: $ARGUMENTS', + }) + expect(prompt.success).toBe(true) + + // prompt hooks have no execution-affecting args contract either; a + // supplied args on them is not execution-relevant and stays out of scope. + const http = HookCommandSchema().safeParse({ + type: 'http', + url: 'https://example.invalid/hook', + }) + expect(http.success).toBe(true) + }) +}) + +describe('settings diagnostics for unsupported command hook args', () => { + const settingsContent = (hook: Record) => JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [hook] }] }, + }) + + test('settings loading preserves the explanation and rejects the file', () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'hook-args-diagnostic-')) + const settingsPath = join(fixtureDir, 'settings.json') + try { + writeFileSync(settingsPath, settingsContent({ + type: 'command', command: 'sh', args: ['-c', 'cat'], + })) + const result = parseSettingsFile(settingsPath) + expect(result.settings).toBeNull() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.path).toBe('hooks.UserPromptSubmit.0.hooks.0.args') + expect(result.errors[0]?.message).toContain('do not support an "args" field') + expect(result.errors[0]?.message).toContain('complete invocation in "command"') + } finally { + rmSync(fixtureDir, { recursive: true, force: true }) + } + }) + + test('settings edit validation explains every supplied JSON args value', () => { + for (const args of [null, [], ['--flag'], '', 'value', 0, false, {}]) { + const result = validateSettingsFileContent(settingsContent({ + type: 'command', command: 'sh', args, + })) + expect(result.isValid).toBe(false) + if (!result.isValid) { + expect(result.error).toContain('hooks.UserPromptSubmit.0.hooks.0.args') + expect(result.error).toContain('do not support an "args" field') + expect(result.error).toContain('complete invocation in "command"') + } + } + }) + + test('a complete command remains valid through both settings paths', () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'hook-command-valid-')) + const settingsPath = join(fixtureDir, 'settings.json') + const content = settingsContent(validCommandHook) + try { + writeFileSync(settingsPath, content) + const result = parseSettingsFile(settingsPath) + expect(result.errors).toEqual([]) + expect(result.settings?.hooks?.UserPromptSubmit?.[0]?.hooks[0]) + .toEqual(validCommandHook) + expect(validateSettingsFileContent(content)).toEqual({ isValid: true }) + } finally { + rmSync(fixtureDir, { recursive: true, force: true }) + } + }) +}) + +describe('args rejection propagates through every hook entry point', () => { + test('HooksSchema (settings and frontmatter) rejects the hook', () => { + const result = HooksSchema().safeParse({ + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sh', + args: ['-c', 'cat'], + }, + ], + }, + ], + }) + expect(result.success).toBe(false) + }) + + test('SettingsSchema reports the hook so the settings file fails visibly', () => { + const result = SettingsSchema().safeParse({ + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sh', + args: ['-c', 'cat'], + }, + ], + }, + ], + }, + }) + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map(i => i.message).join(' ') + expect(messages).toContain('args') + } + + // The same file without args still parses and keeps the hook. + const ok = SettingsSchema().safeParse({ + hooks: { + UserPromptSubmit: [ + { + hooks: [{ type: 'command', command: 'cat', timeout: 5 }], + }, + ], + }, + }) + expect(ok.success).toBe(true) + }) + + test('PluginHooksSchema rejects the hook and keeps valid plugin hooks', () => { + const bad = PluginHooksSchema().safeParse({ + description: 'probe plugin', + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: 'command', + command: 'sh', + args: ['-c', 'printf HANDLER_RAN; cat'], + }, + ], + }, + ], + }, + }) + expect(bad.success).toBe(false) + + const good = PluginHooksSchema().safeParse({ + description: 'probe plugin', + hooks: { + UserPromptSubmit: [ + { + hooks: [validCommandHook], + }, + ], + }, + }) + expect(good.success).toBe(true) + if (good.success) { + expect(good.data.hooks.UserPromptSubmit?.[0]?.hooks[0]).toEqual({ + ...validCommandHook, + }) + } + }) +}) diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts index 280bcb1c3d..7920057ae2 100644 --- a/src/schemas/hooks.ts +++ b/src/schemas/hooks.ts @@ -32,6 +32,28 @@ function buildHookSchemas() { const BashCommandHookSchema = z.object({ type: z.literal('command').describe('Shell command hook type'), command: z.string().describe('Shell command to execute'), + // Command hooks have no separate `args` field: the executor spawns the + // `command` string through a shell with an empty argument array. zod's + // default key stripping would silently drop a supplied `args`, turning + // e.g. { command: "sh", args: ["-c", handler] } into a bare `sh` that + // executes its JSON stdin as shell input (gh-2304). Reject the field + // with an actionable error instead, while all other unknown keys keep + // being tolerated for forward compatibility. JSON cannot express + // undefined, so `.optional()` only admits a programmatically-constructed + // explicit `undefined`, which means "not supplied" — every JSON-parsed + // value (null, array, string, …) is rejected. + args: z + .never({ + error: + 'Command hooks do not support an "args" field. Put the complete ' + + 'invocation in "command" (e.g. "command": "sh -c \'printf HANDLER_RAN; cat\'"); ' + + 'a supplied "args" would otherwise be silently discarded and a ' + + 'different command would run.', + }) + .optional() + .describe( + 'Not supported: put the complete invocation in "command" instead', + ), if: IfConditionSchema(), shell: z .enum(SHELL_TYPES) @@ -96,7 +118,7 @@ function buildHookSchemas() { const HttpHookSchema = z.object({ type: z.literal('http').describe('HTTP hook type'), - url: z.string().url().describe('URL to POST the hook input JSON to'), + url: z.url().describe('URL to POST the hook input JSON to'), if: IfConditionSchema(), timeout: z .number() diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index be7a3a4306..280d4b959a 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -4219,11 +4219,15 @@ export function REPL({ // Callback for when user submits input while viewing a teammate's transcript const onAgentSubmit = useCallback(async (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => { if (isLocalAgentTask(task)) { - appendMessageToLocalAgent(task.id, createUserMessage({ - content: input - }), setAppState); if (task.status === 'running') { - queuePendingMessage(task.id, input, setAppState); + if (!queuePendingMessage(task.id, input, setAppState)) { + addNotification({ + key: `agent-finishing-${task.id}`, + jsx: Agent is finishing its output. Please retry after it completes., + priority: 'low' + }); + return; // Keep the typed input for retry; it has not been delivered. + } } else { void resumeAgentBackground({ agentId: task.id, @@ -4241,6 +4245,9 @@ export function REPL({ }); }); } + appendMessageToLocalAgent(task.id, createUserMessage({ + content: input + }), setAppState); } else { injectUserMessageToTeammate(task.id, input, setAppState); } diff --git a/src/services/api/agentRouting.test.ts b/src/services/api/agentRouting.test.ts index b346f7b42f..47869220e7 100644 --- a/src/services/api/agentRouting.test.ts +++ b/src/services/api/agentRouting.test.ts @@ -735,6 +735,7 @@ describe('applyAgentProviderOverrideToEnv', () => { test('switches a spawned teammate process to OpenAI-compatible routing', () => { const env: Record = { CLAUDE_CODE_USE_GEMINI: '1', + CLAUDE_CODE_USE_GEMINI_VERTEX: '1', CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1', CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'saved-gemini', GEMINI_MODEL: 'gemini-parent', @@ -760,6 +761,7 @@ describe('applyAgentProviderOverrideToEnv', () => { expect(env.OPENAI_BASE_URL).toBe('https://api.deepseek.com/v1') expect(env.OPENAI_API_KEY).toBe('sk-ds') expect(env.CLAUDE_CODE_USE_GEMINI).toBeUndefined() + expect(env.CLAUDE_CODE_USE_GEMINI_VERTEX).toBeUndefined() expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined() expect(env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined() expect(env.GEMINI_MODEL).toBeUndefined() diff --git a/src/services/api/agentRouting.ts b/src/services/api/agentRouting.ts index b5e46c1edb..d97646b864 100644 --- a/src/services/api/agentRouting.ts +++ b/src/services/api/agentRouting.ts @@ -5,6 +5,8 @@ import { isModelAlias } from '../../utils/model/aliases.js' import { parseModelFlagValue } from '../../utils/cliArgs.js' import { argsBeforeModelOwningSubcommand } from '../../utils/printFlag.js' import { resolveRouteIdFromBaseUrl } from '../../integrations/routeMetadata.js' +import { sanitizeApiKey } from '../../utils/providerSecrets.js' +import { PROVIDER_SELECTION_FLAGS } from '../../utils/providerSelectionFlags.js' /** * Provider override resolved from agent routing config. @@ -38,13 +40,7 @@ export interface AgentRunModelRouting { type AgentModelConfig = NonNullable[string] const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [ - 'CLAUDE_CODE_USE_OPENAI', - 'CLAUDE_CODE_USE_BEDROCK', - 'CLAUDE_CODE_USE_VERTEX', - 'CLAUDE_CODE_USE_FOUNDRY', - 'CLAUDE_CODE_USE_GITHUB', - 'CLAUDE_CODE_USE_GEMINI', - 'CLAUDE_CODE_USE_MISTRAL', + ...PROVIDER_SELECTION_FLAGS, 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', 'NVIDIA_NIM', @@ -60,6 +56,7 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [ 'OPENAI_AUTH_HEADER', 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', + 'MERGE_GATEWAY_API_KEY', 'CMD_API_KEY', 'COMMANDCODE_API_KEY', 'COMMAND_CODE_API_KEY', @@ -368,6 +365,10 @@ export function applyAgentProviderOverrideToEnv( env.OPENAI_MODEL = providerOverride.model env.OPENAI_BASE_URL = providerOverride.baseURL env.OPENAI_API_KEY = providerOverride.apiKey + if (resolveRouteIdFromBaseUrl(providerOverride.baseURL) === 'merge-gateway') { + const key = sanitizeApiKey(providerOverride.apiKey) + if (key) env.MERGE_GATEWAY_API_KEY = key + } if (resolveRouteIdFromBaseUrl(providerOverride.baseURL) === 'commandcode') { env.CMD_API_KEY = providerOverride.apiKey } diff --git a/src/services/api/authRouting.attribution.test.ts b/src/services/api/authRouting.attribution.test.ts index 4dba7b97d7..c38ac77be2 100644 --- a/src/services/api/authRouting.attribution.test.ts +++ b/src/services/api/authRouting.attribution.test.ts @@ -1,182 +1,185 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' -import { applyAnthropicAttributionPolicy } from '../../utils/anthropicAttribution.js' import { - providerModuleIsMocked, - REAL_PROVIDER_TEST_CHILD_ENV, + attributionModulesNeedIsolation, REAL_PROVIDER_TEST_TIMEOUT_MS, runTestFileWithRealProviders, } from '../../test/providerModuleIsolation.js' -// Bun keeps mock.module() registrations process-global across test files. -// Bind request modules only when the canonical provider module is real. When a -// prior file replaced it, run this file in a clean child process instead. -const _realProvidersModule = await import( - `../../utils/model/providers.js?attributionReal=${Date.now()}-${Math.random()}` -) -const _loadedProvidersModule = await import('src/utils/model/providers.js') -const runInProviderIsolatedChild = - process.env[REAL_PROVIDER_TEST_CHILD_ENV] !== '1' && - providerModuleIsMocked(_loadedProvidersModule, _realProvidersModule) -type AuthRoutingModule = typeof import('./authRouting.js') -let resolveAnthropicAttributionAuthFromSources!: AuthRoutingModule['resolveAnthropicAttributionAuthFromSources'] -let resolveCurrentAnthropicAttributionPolicy!: AuthRoutingModule['resolveCurrentAnthropicAttributionPolicy'] -if (!runInProviderIsolatedChild) { - ;({ +// Inspect canonical dependencies before binding the subject or suite setup. +const needsChild = await attributionModulesNeedIsolation() +if (needsChild) { + test('runs attribution route cases with the real provider module', async () => { + await runTestFileWithRealProviders(import.meta.path) + }, { timeout: REAL_PROVIDER_TEST_TIMEOUT_MS + 5_000 }) +} else { + const { acquireSharedMutationLock, releaseSharedMutationLock } = + await import('../../test/sharedMutationLock.js') + const { applyAnthropicAttributionPolicy } = + await import('../../utils/anthropicAttribution.js') + const { resolveAnthropicAttributionAuthFromSources, resolveCurrentAnthropicAttributionPolicy, - } = await import( + }: typeof import('./authRouting.js') = await import( `./authRouting.js?attributionReal=${Date.now()}-${Math.random()}` - )) -} + ) -const routeEnvKeys = [ - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - 'ANTHROPIC_BASE_URL', - 'ANTHROPIC_MODEL', - 'CLAUDE_CODE_ENTRYPOINT', - 'CLAUDE_CODE_OAUTH_TOKEN', - 'CLAUDE_CODE_REMOTE', - 'CLAUDE_CODE_USE_BEDROCK', - 'CLAUDE_CODE_USE_FOUNDRY', - 'CLAUDE_CODE_USE_GEMINI', - 'CLAUDE_CODE_USE_GITHUB', - 'CLAUDE_CODE_USE_MISTRAL', - 'CLAUDE_CODE_USE_OPENAI', - 'CLAUDE_CODE_USE_VERTEX', - 'MINIMAX_API_KEY', - 'OPENAI_BASE_URL', - 'OPENAI_MODEL', -] as const -const originalEnv = { ...process.env } - -beforeEach(async () => { - await acquireSharedMutationLock('authRouting.attribution.test.ts') - for (const key of routeEnvKeys) delete process.env[key] - process.env.ANTHROPIC_API_KEY = 'sk-test-attribution-routing' -}) - -afterEach(() => { - try { - for (const key of routeEnvKeys) { - if (originalEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = originalEnv[key] + const routeEnvKeys = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_ENTRYPOINT', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLAUDE_CODE_REMOTE', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_VERTEX', + 'MINIMAX_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', + ] as const + const originalEnv = { ...process.env } + + beforeEach(async () => { + await acquireSharedMutationLock('authRouting.attribution.test.ts') + for (const key of routeEnvKeys) delete process.env[key] + process.env.ANTHROPIC_API_KEY = 'sk-test-attribution-routing' + }) + + afterEach(() => { + try { + for (const key of routeEnvKeys) { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } } + } finally { + releaseSharedMutationLock() } - } finally { - releaseSharedMutationLock() - } -}) + }) -if (runInProviderIsolatedChild) { - test('runs attribution route cases with the real provider module', async () => { - await runTestFileWithRealProviders(import.meta.path) - }, { timeout: REAL_PROVIDER_TEST_TIMEOUT_MS + 5_000 }) -} + describe('current Anthropic attribution route resolution', () => { + for (const apiKeySource of [ + 'ANTHROPIC_API_KEY', + 'apiKeyHelper', + ] as const) { + test(`ignores ${apiKeySource} when managed OAuth is effective`, () => { + expect( + resolveAnthropicAttributionAuthFromSources({ + apiKeySource, + authTokenSource: 'CLAUDE_CODE_OAUTH_TOKEN', + bareMode: false, + isSubscriber: true, + managedOAuthContext: true, + }), + ).toBe('oauth_subscription') + }) + } -const describeAttribution = runInProviderIsolatedChild - ? describe.skip - : describe + for (const [apiKeySource, authTokenSource] of [ + ['ANTHROPIC_API_KEY', 'none'], + ['apiKeyHelper', 'apiKeyHelper'], + ] as const) { + test(`preserves bare-mode ${apiKeySource} in a managed context`, () => { + expect( + resolveAnthropicAttributionAuthFromSources({ + apiKeySource, + authTokenSource, + bareMode: true, + isSubscriber: false, + managedOAuthContext: true, + }), + ).toBe('api_key') + }) + } -describeAttribution('current Anthropic attribution route resolution', () => { - for (const apiKeySource of [ - 'ANTHROPIC_API_KEY', - 'apiKeyHelper', - ] as const) { - test(`ignores ${apiKeySource} when managed OAuth is effective`, () => { + test('preserves a managed API key when managed OAuth is not effective', () => { expect( resolveAnthropicAttributionAuthFromSources({ - apiKeySource, - authTokenSource: 'CLAUDE_CODE_OAUTH_TOKEN', + apiKeySource: '/login managed key', + authTokenSource: 'none', bareMode: false, - isSubscriber: true, + isSubscriber: false, managedOAuthContext: true, }), - ).toBe('oauth_subscription') + ).toBe('api_key') }) - } - for (const [apiKeySource, authTokenSource] of [ - ['ANTHROPIC_API_KEY', 'none'], - ['apiKeyHelper', 'apiKeyHelper'], - ] as const) { - test(`preserves bare-mode ${apiKeySource} in a managed context`, () => { + test('does not force managed OAuth past an explicit non-subscriber result', () => { expect( resolveAnthropicAttributionAuthFromSources({ - apiKeySource, - authTokenSource, - bareMode: true, + apiKeySource: 'none', + authTokenSource: 'CLAUDE_CODE_OAUTH_TOKEN', + bareMode: false, isSubscriber: false, managedOAuthContext: true, }), - ).toBe('api_key') + ).toBe('unknown') }) - } - - test('preserves a managed API key when managed OAuth is not effective', () => { - expect( - resolveAnthropicAttributionAuthFromSources({ - apiKeySource: '/login managed key', - authTokenSource: 'none', - bareMode: false, - isSubscriber: false, - managedOAuthContext: true, - }), - ).toBe('api_key') - }) - - test('does not force managed OAuth past an explicit non-subscriber result', () => { - expect( - resolveAnthropicAttributionAuthFromSources({ - apiKeySource: 'none', - authTokenSource: 'CLAUDE_CODE_OAUTH_TOKEN', - bareMode: false, - isSubscriber: false, - managedOAuthContext: true, - }), - ).toBe('unknown') - }) - - for (const [label, envKey, envValue] of [ - ['remote', 'CLAUDE_CODE_REMOTE', '1'], - ['Claude Desktop', 'CLAUDE_CODE_ENTRYPOINT', 'claude-desktop'], - ] as const) { - test(`uses the effective OAuth credential for ${label} sessions`, () => { - process.env[envKey] = envValue - process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-test-token' - expect( - resolveCurrentAnthropicAttributionPolicy({ - attributionEnabled: false, - }), - ).toEqual({ - generate: true, - retain: true, - reason: 'official_oauth_required', + for (const [label, envKey, envValue] of [ + ['remote', 'CLAUDE_CODE_REMOTE', '1'], + ['Claude Desktop', 'CLAUDE_CODE_ENTRYPOINT', 'claude-desktop'], + ] as const) { + test(`uses the effective OAuth credential for ${label} sessions`, () => { + process.env[envKey] = envValue + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-test-token' + + expect( + resolveCurrentAnthropicAttributionPolicy({ + attributionEnabled: false, + }), + ).toEqual({ + generate: true, + retain: true, + reason: 'official_oauth_required', + }) }) - }) - } + } - for (const [label, envKey] of [ - ['Bedrock', 'CLAUDE_CODE_USE_BEDROCK'], - ['Vertex', 'CLAUDE_CODE_USE_VERTEX'], - ['Foundry', 'CLAUDE_CODE_USE_FOUNDRY'], - ] as const) { - test(`does not emit first-party metadata on ${label}`, () => { - process.env[envKey] = '1' + for (const [label, envKey] of [ + ['Bedrock', 'CLAUDE_CODE_USE_BEDROCK'], + ['Vertex', 'CLAUDE_CODE_USE_VERTEX'], + ['Foundry', 'CLAUDE_CODE_USE_FOUNDRY'], + ] as const) { + test(`does not emit first-party metadata on ${label}`, () => { + process.env[envKey] = '1' + + const policy = resolveCurrentAnthropicAttributionPolicy({ + attributionEnabled: true, + }) + + expect(policy).toEqual({ + generate: false, + retain: false, + reason: 'non_official_route', + }) + expect( + applyAnthropicAttributionPolicy( + ['x-anthropic-billing-header: stale', 'stable route prompt'], + policy, + ), + ).toEqual(['stable route prompt']) + }) + } + test('does not emit through a provider override', () => { const policy = resolveCurrentAnthropicAttributionPolicy({ attributionEnabled: true, + providerOverride: { + model: 'third-party-model', + baseURL: 'https://provider.example/v1', + apiKey: 'provider-test-key', + }, }) - expect(policy).toEqual({ - generate: false, - retain: false, - reason: 'non_official_route', - }) + expect(policy).toMatchObject({ generate: false, retain: false }) expect( applyAnthropicAttributionPolicy( ['x-anthropic-billing-header: stale', 'stable route prompt'], @@ -184,35 +187,16 @@ describeAttribution('current Anthropic attribution route resolution', () => { ), ).toEqual(['stable route prompt']) }) - } - - test('does not emit through a provider override', () => { - const policy = resolveCurrentAnthropicAttributionPolicy({ - attributionEnabled: true, - providerOverride: { - model: 'third-party-model', - baseURL: 'https://provider.example/v1', - apiKey: 'provider-test-key', - }, - }) - expect(policy).toMatchObject({ generate: false, retain: false }) - expect( - applyAnthropicAttributionPolicy( - ['x-anthropic-billing-header: stale', 'stable route prompt'], - policy, - ), - ).toEqual(['stable route prompt']) - }) - - test('treats a custom Anthropic Messages route as non-official', () => { - process.env.ANTHROPIC_BASE_URL = 'https://messages.example/v1' - process.env.ANTHROPIC_MODEL = 'claude-compatible' + test('treats a custom Anthropic Messages route as non-official', () => { + process.env.ANTHROPIC_BASE_URL = 'https://messages.example/v1' + process.env.ANTHROPIC_MODEL = 'claude-compatible' - expect( - resolveCurrentAnthropicAttributionPolicy({ - attributionEnabled: true, - }), - ).toMatchObject({ generate: false, retain: false }) + expect( + resolveCurrentAnthropicAttributionPolicy({ + attributionEnabled: true, + }), + ).toMatchObject({ generate: false, retain: false }) + }) }) -}) +} diff --git a/src/services/api/bootstrap.ts b/src/services/api/bootstrap.ts index 69f62843f5..da3709858a 100644 --- a/src/services/api/bootstrap.ts +++ b/src/services/api/bootstrap.ts @@ -21,7 +21,7 @@ import { getClaudeAIOAuthTokens, hasProfileScope, } from 'src/utils/auth.js' -import { z } from 'zod' +import { z } from 'zod/v4' import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { logForDebugging } from '../../utils/debug.js' @@ -47,7 +47,7 @@ import { const bootstrapResponseSchema = lazySchema(() => z.object({ - client_data: z.record(z.unknown()).nullish(), + client_data: z.record(z.string(), z.unknown()).nullish(), additional_model_options: z .array( z diff --git a/src/services/api/claude.lifecycle.test.ts b/src/services/api/claude.lifecycle.test.ts index 61f12c72e7..27b216748f 100644 --- a/src/services/api/claude.lifecycle.test.ts +++ b/src/services/api/claude.lifecycle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { BetaMessage, BetaMessageStreamParams, @@ -7,6 +7,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { + resetModelStringsForTestingOnly, setAllowedSettingSources, setFlagSettingsInline, setFlagSettingsPath, @@ -22,6 +23,8 @@ import { QueryLifecycleOperationTracker } from '../../utils/queryLifecycle.js' import { __getInterruptionTraceSnapshotForTests, __resetInterruptionTraceForTests, + __waitForInterruptionTraceFlushForTests, + isInterruptionTraceEnabled, } from '../../utils/interruptionTrace.js' import { asSystemPrompt } from '../../utils/systemPromptType.js' import { getClaudeAIOAuthTokens } from '../../utils/auth.js' @@ -104,6 +107,9 @@ const hadSavedMacro = Object.hasOwn(globalThis, 'MACRO') const savedMacro = (globalThis as Record).MACRO const originalNodeEnv = process.env.NODE_ENV let fixturesRoot: string | undefined +const unexpectedInterruptionTraces: ReturnType< + typeof __getInterruptionTraceSnapshotForTests +>[] = [] type FetchOverride = NonNullable type LifecycleSnapshot = ReturnType @@ -143,6 +149,7 @@ function makeBetaMessage(): BetaMessage { content: [], container: null, context_management: null, + diagnostics: null, stop_details: null, stop_reason: 'end_turn', stop_sequence: null, @@ -502,6 +509,7 @@ function setIgnoredApiKeyHelper(): void { beforeEach(async () => { await acquireSharedMutationLock('claude.lifecycle.test.ts') + resetModelStringsForTestingOnly() setFlagSettingsPath(undefined) setFlagSettingsInline(null) setAllowedSettingSources([...SETTING_SOURCES]) @@ -509,8 +517,22 @@ beforeEach(async () => { getClaudeAIOAuthTokens.cache?.clear?.() }) -afterEach(() => { +afterEach(async () => { + let flushTimeout: ReturnType | undefined try { + // Match graceful shutdown's budget so a stuck append cannot hold the lock. + await Promise.race([ + __waitForInterruptionTraceFlushForTests(), + new Promise(resolve => { + flushTimeout = setTimeout(resolve, 500) + }), + ]) + // Only cases that enabled tracing may leave entries before cleanup. + if (!isInterruptionTraceEnabled()) { + const trace = __getInterruptionTraceSnapshotForTests() + if (trace.length > 0) unexpectedInterruptionTraces.push(trace) + } + __resetInterruptionTraceForTests() for (const key of envKeys) { if (originalEnv[key] === undefined) { delete process.env[key] @@ -532,6 +554,7 @@ afterEach(() => { setFlagSettingsPath(undefined) setFlagSettingsInline(null) setAllowedSettingSources([...SETTING_SOURCES]) + resetModelStringsForTestingOnly() resetSettingsCache() getClaudeAIOAuthTokens.cache?.clear?.() resetGrowthBook() @@ -540,10 +563,15 @@ afterEach(() => { fixturesRoot = undefined } } finally { + if (flushTimeout !== undefined) clearTimeout(flushTimeout) releaseSharedMutationLock() } }) +afterAll(() => { + expect(unexpectedInterruptionTraces).toEqual([]) +}) + if (runInProviderIsolatedChild) { test('runs Claude lifecycle cases with the real provider module', async () => { await runTestFileWithRealProviders(import.meta.path) diff --git a/src/services/api/claude.streamWatchdog.test.ts b/src/services/api/claude.streamWatchdog.test.ts index 38ecf5e50c..c403a2c26f 100644 --- a/src/services/api/claude.streamWatchdog.test.ts +++ b/src/services/api/claude.streamWatchdog.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, + jest, spyOn, test, } from 'bun:test' @@ -91,6 +92,7 @@ function makeBetaMessage( content, container: null, context_management: null, + diagnostics: null, stop_details: null, stop_reason: 'end_turn', stop_sequence: null, @@ -138,6 +140,8 @@ function makeCompleteStreamEvents(): BetaRawMessageStreamEvent[] { input_tokens: null, iterations: null, output_tokens: 1, + output_tokens_details: null, + fallback_credit: null, server_tool_use: null, }, }, @@ -201,9 +205,8 @@ function makeWedgedStream(): { } } -function makeCompleteStream(): Stream { +function makeCompleteStream(events = makeCompleteStreamEvents()): Stream { const controller = new AbortController() - const events = makeCompleteStreamEvents() const iterator: AsyncIterator = { next() { const value = events.shift() @@ -279,14 +282,16 @@ async function collectStreamingMessages( return messages } -function delay(ms: number): Promise<'timeout'> { - return new Promise(resolve => { - setTimeout(() => resolve('timeout'), ms) - }) +function fireStreamWatchdog(signal: AbortSignal): void { + // Drive the idle deadline independently of event-loop latency. + jest.advanceTimersByTime(24) + expect(signal.aborted).toBe(false) + jest.advanceTimersByTime(1) + expect(signal.aborted).toBe(true) } async function settleForCleanup(promise: Promise): Promise { - await Promise.race([promise.catch(() => undefined), delay(50)]) + await promise.catch(() => undefined) } function setTestMacro(): void { @@ -315,6 +320,7 @@ beforeEach(async () => { process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS = '25' process.env.OPENCLAUDE_MAX_RETRIES = '0' process.env.VCR_RECORD = '1' + jest.useFakeTimers() }) afterEach(async () => { @@ -350,11 +356,38 @@ afterEach(async () => { fixturesRoot = undefined } } finally { + jest.useRealTimers() releaseSharedMutationLock() } }) describe('Claude stream watchdog', () => { + test('streamed assistant usage retains cumulative thinking token details', async () => { + const events = makeCompleteStreamEvents() + for (const event of events) { + if (event.type === 'message_start') { + event.message.usage.output_tokens_details = { thinking_tokens: 2 } + } else if (event.type === 'message_delta') { + event.usage.output_tokens = 7 + event.usage.output_tokens_details = { thinking_tokens: 5 } + } + } + createHandler = () => makeWithResponse(makeCompleteStream(events)) + const messages = await collectStreamingMessages( + new AbortController().signal, + makeOptions(), + ) + expect(messages).toContainEqual(expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + usage: expect.objectContaining({ + output_tokens: 7, + output_tokens_details: { thinking_tokens: 5 }, + }), + }), + })) + }) + test('falls back when the top-level stream iterator never settles', async () => { const traceFile = join(fixturesRoot!, 'interruption-trace.jsonl') process.env.OPENCLAUDE_INTERRUPT_TRACE = '1' @@ -380,8 +413,8 @@ describe('Claude stream watchdog', () => { await wedged.nextStarted try { - const result = await Promise.race([request, delay(250)]) - expect(result).not.toBe('timeout') + fireStreamWatchdog(wedged.abortSignal) + const result = await request expect(streamModes).toEqual([true, undefined]) expect(wedged.abortSignal.aborted).toBe(true) expect(wedged.returnCalled()).toBe(true) @@ -482,13 +515,15 @@ describe('Claude stream watchdog', () => { error instanceof Error ? error.name : String(error), ) await wedged.nextStarted - requestAbort(controller, undefined, { - source: 'cancel_keybinding', - controllerRole: 'query-root', - }) - try { - const result = await Promise.race([request, delay(150)]) + jest.advanceTimersByTime(249) + expect(wedged.abortSignal.aborted).toBe(false) + requestAbort(controller, undefined, { + source: 'cancel_keybinding', + controllerRole: 'query-root', + }) + + const result = await request expect(result).toBe('resolved') expect(streamModes).toEqual([true]) expect(wedged.abortSignal.aborted).toBe(true) @@ -536,7 +571,8 @@ describe('Claude stream watchdog', () => { ) await wedged.nextStarted try { - await Promise.race([request, delay(250)]) + fireStreamWatchdog(wedged.abortSignal) + await request const trace = __getInterruptionTraceSnapshotForTests() const started = trace.find( entry => entry.event === 'claude_stream.fallback_started', @@ -571,14 +607,11 @@ describe('Claude stream watchdog', () => { ) await wedged.nextStarted try { - const result = await Promise.race([ - request.then( - () => 'resolved', - error => error, - ), - delay(250), - ]) - expect(result).not.toBe('timeout') + fireStreamWatchdog(wedged.abortSignal) + await request.then( + () => 'resolved', + error => error, + ) const settlements = __getInterruptionTraceSnapshotForTests().filter( entry => entry.event === 'claude_stream.fallback_settled', ) @@ -649,8 +682,9 @@ describe('Claude stream watchdog', () => { await wedged.nextStarted try { - const result = await Promise.race([request, delay(250)]) - expect(result).not.toBe('timeout') + fireStreamWatchdog(wedged.abortSignal) + await request + jest.useRealTimers() wedged.rejectPendingNext(new Error('late iterator failure')) await new Promise(resolve => setTimeout(resolve, 0)) expect(unhandledRejections).toEqual([]) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index ed12d01cab..745e560801 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -3508,6 +3508,9 @@ export function updateUsage( ? partUsage.cache_read_input_tokens : usage.cache_read_input_tokens, output_tokens: partUsage.output_tokens ?? usage.output_tokens, + output_tokens_details: + partUsage.output_tokens_details ?? usage.output_tokens_details, + fallback_credit: partUsage.fallback_credit ?? usage.fallback_credit, server_tool_use: { web_search_requests: partUsage.server_tool_use?.web_search_requests ?? @@ -3566,6 +3569,11 @@ export function accumulateUsage( cache_read_input_tokens: totalUsage.cache_read_input_tokens + messageUsage.cache_read_input_tokens, output_tokens: totalUsage.output_tokens + messageUsage.output_tokens, + output_tokens_details: { + thinking_tokens: + totalUsage.output_tokens_details.thinking_tokens + + messageUsage.output_tokens_details.thinking_tokens, + }, server_tool_use: { web_search_requests: totalUsage.server_tool_use.web_search_requests + @@ -3575,6 +3583,7 @@ export function accumulateUsage( messageUsage.server_tool_use.web_fetch_requests, }, service_tier: messageUsage.service_tier, // Use the most recent service tier + fallback_credit: messageUsage.fallback_credit, // Use the most recent outcome cache_creation: { ephemeral_1h_input_tokens: totalUsage.cache_creation.ephemeral_1h_input_tokens + diff --git a/src/services/api/claude.usage.test.ts b/src/services/api/claude.usage.test.ts new file mode 100644 index 0000000000..1e12b42e85 --- /dev/null +++ b/src/services/api/claude.usage.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from 'bun:test' +import { accumulateUsage, updateUsage } from './claude.js' +import { EMPTY_USAGE } from './emptyUsage.js' +import type { BetaFallbackCreditUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' + +test('stream usage preserves fallback-credit outcomes across cumulative updates and legacy events', () => { + const redeemed: BetaFallbackCreditUsage = { status: { type: 'redeemed' } } + const declined: BetaFallbackCreditUsage = { + status: { type: 'not_applied', reason: 'expired' }, + } + const start = updateUsage(EMPTY_USAGE, { + ...EMPTY_USAGE, output_tokens: 4, fallback_credit: redeemed, + }) + expect(EMPTY_USAGE.fallback_credit).toBeNull() + expect(start.fallback_credit).toEqual(redeemed) + const delta = updateUsage(start, { + ...EMPTY_USAGE, output_tokens: 7, fallback_credit: null, + }) + expect(delta.fallback_credit).toEqual(redeemed) + const { fallback_credit: _credit, ...legacy } = { ...EMPTY_USAGE, output_tokens: 8 } + expect(updateUsage(delta, legacy as typeof EMPTY_USAGE).fallback_credit).toEqual(redeemed) + expect(updateUsage(delta, { + ...EMPTY_USAGE, output_tokens: 9, fallback_credit: declined, + }).fallback_credit).toEqual(declined) + expect(delta.output_tokens).toBe(7) +}) + +test('turn accumulation keeps the latest fallback-credit metadata without changing billed tokens', () => { + const first = { ...EMPTY_USAGE, output_tokens: 7, fallback_credit: { status: { type: 'redeemed' as const } } } + const second = { ...EMPTY_USAGE, output_tokens: 4 } + const total = accumulateUsage(first, second) + expect(total.fallback_credit).toBeNull() + expect(total.output_tokens).toBe(11) + expect(accumulateUsage(second, first).fallback_credit).toEqual(first.fallback_credit) +}) + +test('stream usage preserves cumulative thinking details through null and legacy updates', () => { + const start = updateUsage(EMPTY_USAGE, { + ...EMPTY_USAGE, + input_tokens: 10, + output_tokens: 4, + output_tokens_details: { thinking_tokens: 3 }, + }) + const delta = updateUsage(start, { + ...EMPTY_USAGE, + output_tokens: 9, + output_tokens_details: { thinking_tokens: 6 }, + }) + expect(delta.output_tokens_details).toEqual({ thinking_tokens: 6 }) + expect(delta.output_tokens).toBe(9) + expect(delta.input_tokens).toBe(10) + + const nullable = { ...EMPTY_USAGE, output_tokens: 10, output_tokens_details: null } + expect(updateUsage(delta, nullable).output_tokens_details).toEqual({ thinking_tokens: 6 }) + const { output_tokens_details: _details, ...legacy } = nullable + expect(updateUsage(delta, legacy as typeof nullable).output_tokens_details).toEqual({ thinking_tokens: 6 }) + expect(updateUsage(delta, undefined)).toEqual(delta) + expect(updateUsage(delta, { + ...nullable, + output_tokens_details: { thinking_tokens: 0 }, + }).output_tokens_details).toEqual({ thinking_tokens: 0 }) +}) + +test('turn accumulation sums thinking details without adding them to billed output', () => { + const first = updateUsage(EMPTY_USAGE, { + ...EMPTY_USAGE, + output_tokens: 9, + output_tokens_details: { thinking_tokens: 6 }, + }) + const second = updateUsage(EMPTY_USAGE, { + ...EMPTY_USAGE, + output_tokens: 5, + output_tokens_details: { thinking_tokens: 2 }, + }) + const total = accumulateUsage(accumulateUsage(EMPTY_USAGE, first), second) + expect(total.output_tokens_details).toEqual({ thinking_tokens: 8 }) + expect(total.output_tokens).toBe(14) + expect(EMPTY_USAGE.output_tokens_details).toEqual({ thinking_tokens: 0 }) +}) diff --git a/src/services/api/client.bedrockStreaming.test.ts b/src/services/api/client.bedrockStreaming.test.ts new file mode 100644 index 0000000000..a2d78c69a7 --- /dev/null +++ b/src/services/api/client.bedrockStreaming.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { crc32 } from 'node:zlib' +import { snapshotEnv } from '../../test/envSnapshot.js' +import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +import { asMockFetch } from '../../test/typedMocks.js' + +const envKeys = [ + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_SKIP_BEDROCK_AUTH', + 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_MISTRAL', + 'AWS_BEARER_TOKEN_BEDROCK', 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_BEDROCK_BASE_URL', 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', + 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION', 'ANTHROPIC_CUSTOM_HEADERS', +] as const +let restoreEnv: () => void +let originalMacro: PropertyDescriptor | undefined + +beforeEach(async () => { + await acquireSharedMutationLock('client.bedrockStreaming.test.ts') + restoreEnv = snapshotEnv(envKeys, { clear: true }) + originalMacro = Object.getOwnPropertyDescriptor(globalThis, 'MACRO') + ;(globalThis as Record).MACRO = { VERSION: 'test-version' } + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH = '1' + process.env.AWS_REGION = 'us-east-1' + process.env.ANTHROPIC_BEDROCK_BASE_URL = 'https://bedrock.example.test' +}) + +afterEach(() => { + try { + restoreEnv() + if (originalMacro) Object.defineProperty(globalThis, 'MACRO', originalMacro) + else delete (globalThis as Record).MACRO + } finally { + releaseSharedMutationLock() + } +}) + +// Encode the public AWS event-stream wire format, including both CRC checks. +// The real Bedrock SDK must decode these frames before the Anthropic SSE parser. +function bedrockFrame(event: object): Buffer { + const headers = Buffer.concat(Object.entries({ + ':message-type': 'event', ':event-type': 'chunk', + ':content-type': 'application/json', + }).map(([name, value]) => { + const key = Buffer.from(name) + const text = Buffer.from(value) + const prefix = Buffer.from([key.length, ...key, 7, 0, 0]) + prefix.writeUInt16BE(text.length, prefix.length - 2) + return Buffer.concat([prefix, text]) + })) + const body = Buffer.from(JSON.stringify({ + bytes: Buffer.from(JSON.stringify(event)).toString('base64'), + })) + const frame = Buffer.alloc(16 + headers.length + body.length) + frame.writeUInt32BE(frame.length, 0) + frame.writeUInt32BE(headers.length, 4) + frame.writeUInt32BE(crc32(frame.subarray(0, 8)), 8) + headers.copy(frame, 12) + body.copy(frame, 12 + headers.length) + frame.writeUInt32BE(crc32(frame.subarray(0, -4)), frame.length - 4) + return frame +} + +for (const auth of ['skip', 'bearer', 'sigv4'] as const) { + test(`the real Bedrock client decodes AWS streaming frames with ${auth} auth`, async () => { + if (auth === 'bearer') process.env.AWS_BEARER_TOKEN_BEDROCK = 'bedrock-test-token' + if (auth === 'sigv4') { + delete process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH + process.env.AWS_ACCESS_KEY_ID = 'test-access-key' + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key' + process.env.AWS_SESSION_TOKEN = 'test-session-token' + } + const events = [ + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello' } }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 7, output_tokens_details: { thinking_tokens: 5 } } }, + { type: 'message_stop' }, + ] + let requestUrl = '' + let requestHeaders = new Headers() + let requestBody: unknown + const fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = String(input) + requestHeaders = new Headers(init?.headers) + requestBody = JSON.parse(String(init?.body)) + return new Response(Buffer.concat(events.map(bedrockFrame)), { + headers: { 'content-type': 'application/vnd.amazon.eventstream' }, + }) + }) + const { getAnthropicClient } = await import( + `./client.js?bedrock-stream=${Date.now()}-${Math.random()}` + ) as typeof import('./client.js') + const client = await getAnthropicClient({ + maxRetries: 0, + model: 'anthropic.claude-sonnet-4-6', + fetchOverride: asMockFetch(fetch), + }) + expect(client.constructor.name).toBe('AnthropicBedrock') + const received: unknown[] = [] + for await (const event of await client.beta.messages.create({ + model: 'anthropic.claude-sonnet-4-6', max_tokens: 16, + messages: [{ role: 'user', content: 'hello' }], stream: true, + })) received.push(event) + expect(fetch).toHaveBeenCalledTimes(1) + expect(received).toEqual(events) + expect(requestUrl).toBe('https://bedrock.example.test/model/anthropic.claude-sonnet-4-6/invoke-with-response-stream') + expect(requestBody).toMatchObject({ + anthropic_version: 'bedrock-2023-05-31', max_tokens: 16, + messages: [{ role: 'user', content: 'hello' }], + }) + if (auth === 'bearer') expect(requestHeaders.get('authorization')).toBe('Bearer bedrock-test-token') + else if (auth === 'sigv4') { + expect(requestHeaders.get('authorization')).toContain('AWS4-HMAC-SHA256 Credential=test-access-key/') + expect(requestHeaders.get('authorization')).toContain('/us-east-1/bedrock/aws4_request') + expect(requestHeaders.get('x-amz-security-token')).toBe('test-session-token') + } else expect(requestHeaders.get('authorization')).toBeNull() + }) +} diff --git a/src/services/api/client.foundryStreaming.test.ts b/src/services/api/client.foundryStreaming.test.ts new file mode 100644 index 0000000000..2f09279384 --- /dev/null +++ b/src/services/api/client.foundryStreaming.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { snapshotEnv } from '../../test/envSnapshot.js' +import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +import { asMockFetch } from '../../test/typedMocks.js' +import { importOptionalRuntimeModule } from '../../utils/optionalRuntimeModule.js' +import { updateUsage } from './claude.js' +import { EMPTY_USAGE } from './emptyUsage.js' +import type { BetaMessage, BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' + +const envKeys = [ + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH', 'ANTHROPIC_FOUNDRY_API_KEY', + 'ANTHROPIC_FOUNDRY_BASE_URL', 'ANTHROPIC_FOUNDRY_RESOURCE', + 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_CUSTOM_HEADERS', +] as const +let restoreEnv: () => void +let originalMacro: PropertyDescriptor | undefined + +beforeEach(async () => { + await acquireSharedMutationLock('client.foundryStreaming.test.ts') + restoreEnv = snapshotEnv(envKeys, { clear: true }) + originalMacro = Object.getOwnPropertyDescriptor(globalThis, 'MACRO') + ;(globalThis as Record).MACRO = { VERSION: 'test-version' } + process.env.CLAUDE_CODE_USE_FOUNDRY = '1' + process.env.ANTHROPIC_FOUNDRY_RESOURCE = 'test-resource' +}) + +afterEach(() => { + try { + restoreEnv() + if (originalMacro) Object.defineProperty(globalThis, 'MACRO', originalMacro) + else delete (globalThis as Record).MACRO + } finally { + releaseSharedMutationLock() + } +}) + +for (const auth of ['api-key', 'entra'] as const) { + test(`the real Foundry client constructs and decodes beta streams with ${auth} auth`, async () => { + if (auth === 'api-key') process.env.ANTHROPIC_FOUNDRY_API_KEY = 'foundry-test-key' + else { + delete process.env.ANTHROPIC_FOUNDRY_RESOURCE + process.env.ANTHROPIC_FOUNDRY_BASE_URL = 'https://foundry.example.test/anthropic/' + } + const message: BetaMessage = { + id: 'msg_foundry', type: 'message', role: 'assistant', + model: 'claude-sonnet-4-6', content: [], + usage: { ...EMPTY_USAGE, input_tokens: 4 }, + stop_reason: null, stop_sequence: null, container: null, + context_management: null, diagnostics: null, stop_details: null, + } + const events: BetaRawMessageStreamEvent[] = [ + { type: 'message_start', message }, + { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'tool_lookup', name: 'lookup', input: {} } }, + { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"query":' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '"hello"}' } }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', context_management: null, + delta: { container: null, stop_reason: 'tool_use', stop_sequence: null, stop_details: null }, + usage: { ...message.usage, output_tokens: 7, output_tokens_details: { thinking_tokens: 3 }, fallback_credit: { status: { type: 'redeemed' } } }, + }, + { type: 'message_stop' }, + ] + const requests: { url: string; headers: Headers; body: unknown }[] = [] + const fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }) + return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''), { + headers: { 'content-type': 'text/event-stream' }, + }) + }) + const tokenProvider = mock(async () => `entra-test-token-${tokenProvider.mock.calls.length}`) + const getBearerTokenProvider = mock((_credential: unknown, _scope: string) => tokenProvider) + const { getAnthropicClient, _setOptionalRuntimeModuleImporterForTesting } = await import( + `./client.js?foundry-stream=${Date.now()}-${Math.random()}` + ) as typeof import('./client.js') + // Keep the installed Foundry SDK and its stream implementation real; replace + // only Azure credential acquisition so no cloud account is needed. + _setOptionalRuntimeModuleImporterForTesting(async (specifier: string, feature: string): Promise => { + if (specifier === '@azure/identity') return { + DefaultAzureCredential: class {}, getBearerTokenProvider, + } as T + return importOptionalRuntimeModule(specifier, feature) + }) + try { + const client = await getAnthropicClient({ maxRetries: 0, model: 'claude-sonnet-4-6', fetchOverride: asMockFetch(fetch) }) + expect(client.constructor.name).toBe('AnthropicFoundry') + // Two requests prove the Entra token callback is invoked for each request. + for (let i = 0; i < 2; i++) { + const received: unknown[] = [] + let usage = { ...EMPTY_USAGE } + for await (const event of await client.beta.messages.create({ + model: 'claude-sonnet-4-6', max_tokens: 16, stream: true, + messages: [{ role: 'user', content: 'hello' }], + tools: [{ name: 'lookup', input_schema: { type: 'object' } }], + })) { + received.push(event) + if (event.type === 'message_delta') usage = updateUsage(usage, event.usage) + } + expect(received).toEqual(events) + expect(usage).toMatchObject({ + output_tokens: 7, output_tokens_details: { thinking_tokens: 3 }, + fallback_credit: { status: { type: 'redeemed' } }, + }) + } + expect(requests).toHaveLength(2) + for (const [i, request] of requests.entries()) { + expect(request.url).toBe(auth === 'api-key' + ? 'https://test-resource.services.ai.azure.com/anthropic/v1/messages?beta=true' + : 'https://foundry.example.test/anthropic/v1/messages?beta=true') + expect(request.body).toMatchObject({ model: 'claude-sonnet-4-6', max_tokens: 16, stream: true, tools: [{ name: 'lookup' }] }) + expect(request.headers.get('x-api-key')).toBe(auth === 'api-key' ? 'foundry-test-key' : null) + expect(request.headers.get('authorization')).toBe(auth === 'entra' ? `Bearer entra-test-token-${i + 1}` : null) + } + if (auth === 'entra') { + expect(getBearerTokenProvider).toHaveBeenCalledWith(expect.anything(), 'https://cognitiveservices.azure.com/.default') + expect(tokenProvider).toHaveBeenCalledTimes(2) + } else expect(getBearerTokenProvider).not.toHaveBeenCalled() + } finally { + _setOptionalRuntimeModuleImporterForTesting() + } + }) +} diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 28a75d1372..675f666f97 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -1,3 +1,6 @@ +import { getMainLoopModelOverride, setMainLoopModelOverride } from '../../bootstrap/state.js' +import { spyOn } from 'bun:test' +import * as geminiAuthModule from '../../utils/geminiAuth.js' import Anthropic from '@anthropic-ai/sdk' import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' @@ -7,6 +10,13 @@ import { registerGateway, } from '../../integrations/index.js' import { publicBuildVersion } from '../../utils/version.js' +import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js' +import { snapshotEnv } from '../../test/envSnapshot.js' +import { + buildLaunchEnv, + buildStartupEnvFromProfile, + createProfileFile, +} from '../../utils/providerProfile.js' // bun:test keeps mock.module() registrations process-global across test files. // Load and re-register the real module before importing the client so a prior @@ -64,12 +74,20 @@ const originalEnv = { CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX, CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY, CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, + CLAUDE_CODE_USE_GEMINI_VERTEX: process.env.CLAUDE_CODE_USE_GEMINI_VERTEX, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL, GEMINI_API_KEY: process.env.GEMINI_API_KEY, GEMINI_MODEL: process.env.GEMINI_MODEL, GEMINI_BASE_URL: process.env.GEMINI_BASE_URL, GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE, + GEMINI_VERTEX_PROJECT: process.env.GEMINI_VERTEX_PROJECT, + GEMINI_VERTEX_LOCATION: process.env.GEMINI_VERTEX_LOCATION, + GEMINI_VERTEX_MODEL: process.env.GEMINI_VERTEX_MODEL, + GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT, + GEMINI_ACCESS_TOKEN: process.env.GEMINI_ACCESS_TOKEN, + GEMINI_VERTEX_AUTH_MODE: process.env.GEMINI_VERTEX_AUTH_MODE, + GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, @@ -195,7 +213,15 @@ beforeEach(async () => { delete process.env.CLAUDE_CODE_USE_VERTEX delete process.env.CLAUDE_CODE_USE_FOUNDRY delete process.env.CLAUDE_CODE_USE_GITHUB + delete process.env.CLAUDE_CODE_USE_GEMINI_VERTEX delete process.env.CLAUDE_CODE_USE_MISTRAL + delete process.env.GEMINI_VERTEX_PROJECT + delete process.env.GEMINI_VERTEX_LOCATION + delete process.env.GEMINI_VERTEX_MODEL + delete process.env.GOOGLE_CLOUD_PROJECT + delete process.env.GEMINI_ACCESS_TOKEN + delete process.env.GEMINI_VERTEX_AUTH_MODE + delete process.env.GOOGLE_APPLICATION_CREDENTIALS delete process.env.GOOGLE_API_KEY delete process.env.OPENAI_API_KEY delete process.env.OPENAI_BASE_URL @@ -254,12 +280,20 @@ afterEach(() => { restoreEnv('CLAUDE_CODE_USE_VERTEX', originalEnv.CLAUDE_CODE_USE_VERTEX) restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalEnv.CLAUDE_CODE_USE_FOUNDRY) restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI) + restoreEnv('CLAUDE_CODE_USE_GEMINI_VERTEX', originalEnv.CLAUDE_CODE_USE_GEMINI_VERTEX) restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB) restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL) restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY) restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL) restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL) restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE) + restoreEnv('GEMINI_VERTEX_PROJECT', originalEnv.GEMINI_VERTEX_PROJECT) + restoreEnv('GEMINI_VERTEX_LOCATION', originalEnv.GEMINI_VERTEX_LOCATION) + restoreEnv('GEMINI_VERTEX_MODEL', originalEnv.GEMINI_VERTEX_MODEL) + restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT) + restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN) + restoreEnv('GEMINI_VERTEX_AUTH_MODE', originalEnv.GEMINI_VERTEX_AUTH_MODE) + restoreEnv('GOOGLE_APPLICATION_CREDENTIALS', originalEnv.GOOGLE_APPLICATION_CREDENTIALS) restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) @@ -328,6 +362,7 @@ afterEach(() => { originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, ) globalThis.fetch = originalFetch + resetModelStringsForTestingOnly() } finally { releaseSharedMutationLock() } @@ -346,6 +381,7 @@ test('first-party Anthropic requests execute the configured fetch wrapper withou delete process.env.CLAUDE_CODE_USE_VERTEX delete process.env.CLAUDE_CODE_USE_FOUNDRY delete process.env.CLAUDE_CODE_USE_GITHUB + delete process.env.CLAUDE_CODE_USE_GEMINI_VERTEX delete process.env.CLAUDE_CODE_USE_MISTRAL delete process.env.OPENAI_API_KEY delete process.env.OPENAI_BASE_URL @@ -684,6 +720,118 @@ test('routes Gemini provider requests through the OpenAI-compatible shim', async }) }) +test('routes Gemini Vertex provider requests through the native client', async () => { + let capturedUrl: string | undefined + let capturedHeaders: Headers | undefined + let capturedBody: Record | undefined + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_VERTEX + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_VERTEX_PROJECT = 'project-test-1234' + process.env.GEMINI_VERTEX_LOCATION = 'us-central1' + // A regional location requires a regionally-available model (gemini-3.5-flash + // is global-only and would be rejected — see the dedicated test below). + process.env.GEMINI_VERTEX_MODEL = 'gemini-2.5-flash' + process.env.GEMINI_ACCESS_TOKEN = 'vertex-access-token' + process.env.OPENAI_MODEL = 'gpt-5.5' + + globalThis.fetch = (async (input, init) => { + capturedUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + capturedHeaders = new Headers(init?.headers) + capturedBody = JSON.parse(String(init?.body)) as Record + + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'vertex ok' }] } }], + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as FetchType + + const client = (await getAnthropicClient({ + maxRetries: 0, + model: 'gemini-2.5-flash', + })) as unknown as ShimClient + + const response = await client.beta.messages.create({ + model: 'gemini-2.5-flash', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + expect(capturedUrl).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/project-test-1234/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent', + ) + expect(capturedHeaders?.get('authorization')).toBe('Bearer vertex-access-token') + expect(capturedHeaders?.get('x-goog-user-project')).toBe('project-test-1234') + expect(capturedBody?.contents).toEqual([ + { role: 'user', parts: [{ text: 'hello' }] }, + ]) + expect(response).toMatchObject({ + role: 'assistant', + model: 'gemini-2.5-flash', + }) +}) + +test('Gemini Vertex provider uses GEMINI_VERTEX_MODEL instead of generic OPENAI_MODEL', async () => { + let capturedUrl: string | undefined + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_VERTEX + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_VERTEX_PROJECT = 'project-test-1234' + process.env.GEMINI_VERTEX_LOCATION = 'us-central1' + process.env.GEMINI_VERTEX_MODEL = 'gemini-2.5-flash' + process.env.GEMINI_ACCESS_TOKEN = 'vertex-access-token' + process.env.OPENAI_MODEL = 'gemini-3.0-flash' + + globalThis.fetch = (async (input, _init) => { + capturedUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'vertex ok' }] } }], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const previousModel = getMainLoopModelOverride() + try { + setMainLoopModelOverride(undefined) + const { getUserSpecifiedModelSetting } = await import('../../utils/model/model.js') + const model = getUserSpecifiedModelSetting() + expect(model).toBe('gemini-2.5-flash') + const client = await getAnthropicClient({ maxRetries: 0, model: model ?? undefined }) + await client.beta.messages.create({ + model: model!, messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, + }) + expect(capturedUrl).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/project-test-1234/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent', + ) + } finally { + setMainLoopModelOverride(previousModel) + } +}) + test('routes env-only MiniMax requests through the Anthropic-compatible API', async () => { let capturedUrl: string | undefined let capturedHeaders: Headers | undefined @@ -753,6 +901,66 @@ test('routes env-only MiniMax requests through the Anthropic-compatible API', as }) }) +for (const host of ['api.minimax.io', 'api.minimaxi.com']) { + for (const protocol of ['anthropic', 'openai'] as const) { + for (const savedModel of [undefined, 'MiniMax-M3', 'MiniMax-M2.7', 'custom-minimax-deployment']) { + test(`MiniMax ${host} ${protocol} serializes ${savedModel ?? 'Flash default'} unchanged`, async () => { + clearEnvForMiniMaxOnlyTest() + process.env.MINIMAX_API_KEY = 'minimax-test-key' + if (protocol === 'anthropic') { + process.env.ANTHROPIC_BASE_URL = `https://${host}/anthropic` + if (savedModel) process.env.ANTHROPIC_MODEL = savedModel + } else { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = `https://${host}/v1` + if (savedModel) process.env.OPENAI_MODEL = savedModel + } + const { getDefaultMainLoopModel } = await import('../../utils/model/model.js') + const model = getDefaultMainLoopModel() + expect(model).toBe(savedModel ?? 'MiniMax-M3.1-Flash') + let requestURL = '' + let requestHeaders = new Headers() + let requestBody: Record = {} + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + requestURL = request.url + requestHeaders = request.headers + requestBody = await request.json() as Record + const response = protocol === 'anthropic' + ? { id: 'msg-minimax', type: 'message', role: 'assistant', model, content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 } } + : { id: 'chat-minimax', object: 'chat.completion', model, choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } } + return new Response(JSON.stringify(response), { headers: { 'content-type': 'application/json' } }) + }) as FetchType + const client = await getAnthropicClient({ maxRetries: 0, model }) + await client.beta.messages.create({ + model, max_tokens: 16, + messages: [{ role: 'user', content: savedModel ? 'hello' : [ + { type: 'text', text: 'describe this image' }, + { type: 'image', source: { type: 'url', url: 'https://example.com/fixture.png' } }, + ] }], + }) + if (!savedModel) { + expect(requestBody.messages).toEqual([{ + role: 'user', content: [ + { type: 'text', text: 'describe this image' }, + protocol === 'anthropic' + ? { type: 'image', source: { type: 'url', url: 'https://example.com/fixture.png' } } + : { type: 'image_url', image_url: { url: 'https://example.com/fixture.png' } }, + ], + }]) + } + expect(requestURL).toContain(`https://${host}/${protocol === 'anthropic' ? 'anthropic/v1/messages' : 'v1/chat/completions'}`) + expect(requestHeaders.get(protocol === 'anthropic' ? 'x-api-key' : 'authorization')).toBe(protocol === 'anthropic' ? 'minimax-test-key' : 'Bearer minimax-test-key') + expect(requestBody.model).toBe(model) + expect(requestBody.service_tier).toBeUndefined() + expect(requestBody.thinking).not.toEqual({ type: 'disabled' }) + expect(requestBody.reasoning_effort).not.toBe('none') + expect(requestBody.reasoning_split).not.toBe(false) + }) + } + } +} + for (const credentialEnv of ['ANTHROPIC_API_KEY', 'MINIMAX_API_KEY']) { for (const dormantBaseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) { for (const host of ['api.minimax.io', 'api.minimaxi.com', 'api.minimax.chat']) { @@ -831,6 +1039,77 @@ for (const baseEnv of ['OPENAI_BASE_URL', 'OPENAI_API_BASE']) { } } +for (const source of ['persisted', 'ambient'] as const) { + test(`profile launch uses the Venice credential despite a ${source} MiniMax alias`, async () => { + clearEnvForMiniMaxOnlyTest() + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: createProfileFile('openai', { + OPENAI_BASE_URL: 'https://api.minimax.io/v1', + OPENAI_MODEL: 'MiniMax-M3', + OPENAI_API_KEY: 'saved-minimax-key', + MINIMAX_API_KEY: 'saved-minimax-key', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://api.venice.ai/api/v1', + OPENAI_MODEL: 'venice-uncensored', + VENICE_API_KEY: 'selected-venice-key', + ...(source === 'ambient' ? { + OPENAI_API_KEY: 'ambient-minimax-key', + MINIMAX_API_KEY: 'ambient-minimax-key', + } : {}), + }, + }) + const restore = snapshotEnv(Object.keys(env)) + try { + Object.assign(process.env, env) + let capturedUrl: string | undefined + let capturedHeaders: Headers | undefined + globalThis.fetch = (async (input, init) => { + capturedUrl = String(input) + capturedHeaders = new Headers(init?.headers) + return new Response(JSON.stringify({ + id: 'chat-venice', object: 'chat.completion', model: 'venice-uncensored', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { headers: { 'Content-Type': 'application/json' } }) + }) as FetchType + const client = await getAnthropicClient({ maxRetries: 0, model: 'venice-uncensored' }) + await client.beta.messages.create({ + model: 'venice-uncensored', max_tokens: 16, + messages: [{ role: 'user', content: 'hello' }], + }) + expect(capturedUrl).toBe('https://api.venice.ai/api/v1/chat/completions') + expect(capturedHeaders?.get('authorization')).toBe('Bearer selected-venice-key') + expect(env.MINIMAX_API_KEY).toBeUndefined() + } finally { + restore() + } + }) +} + +test('saved OpenAI startup keeps its provider label despite an ambient MiniMax key', async () => { + clearEnvForMiniMaxOnlyTest() + const env = await buildStartupEnvFromProfile({ + persisted: createProfileFile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEY: 'saved-openai-key', + }), + processEnv: { MINIMAX_API_KEY: 'ambient-minimax-key' }, + }) + const restore = snapshotEnv(Object.keys(env)) + try { + Object.assign(process.env, env) + const { detectProvider } = await import('../../components/StartupScreen.js') + expect(detectProvider().name).toBe('OpenAI') + expect(env.MINIMAX_API_KEY).toBeUndefined() + } finally { + restore() + } +}) + test('selected Gemini ignores dormant native MiniMax transport settings', async () => { clearEnvForMiniMaxOnlyTest() process.env.CLAUDE_CODE_USE_GEMINI = '1' @@ -3552,3 +3831,86 @@ test('rejects CRLF-injected custom headers before sending OpenAI-compatible shim expect(capturedHeaders?.get('x-injected')).toBeNull() expect(capturedHeaders?.get('authorization')).toBe('Bearer openai-test-key') }) + +test('native Vertex discovers fileless ADC through client refresh and validation', async () => { + const fs = await import('node:fs') + const files = spyOn(fs, 'existsSync').mockReturnValue(false) + const resolveCredential = geminiAuthModule.resolveGeminiCredential + let discoveries = 0 + const createGoogleAuth = async () => ({ + getClient: async () => ({ getAccessToken: async () => ({ token: `metadata-token-${++discoveries}` }) }), + getProjectId: async () => 'metadata-project', + }) + const resolver = spyOn(geminiAuthModule, 'resolveGeminiCredential').mockImplementation( + (env, options) => resolveCredential(env, { ...options, createGoogleAuth }), + ) + const restore = snapshotEnv(['CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI_VERTEX', 'GEMINI_VERTEX_AUTH_MODE'], { clear: true }) + try { + // Direct Gemini retains its prior policy; native callers opt into discovery. + expect(await resolveCredential({ GEMINI_AUTH_MODE: 'adc' }, { createGoogleAuth })).toEqual({ kind: 'none' }) + expect(discoveries).toBe(0) + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_VERTEX_AUTH_MODE = 'adc' + let authorization: string | null = null + let url = '' + const client = await getAnthropicClient({ + maxRetries: 0, model: 'gemini-2.5-flash', + fetchOverride: async (input, init) => { + url = String(input) + authorization = new Headers(init?.headers).get('authorization') + return new Response(JSON.stringify({ candidates: [{ content: { parts: [{ text: 'ok' }] } }] })) + }, + }) + await client.beta.messages.create({ model: 'gemini-2.5-flash', messages: [{ role: 'user', content: 'hello' }], max_tokens: 16 }) + expect(url).toContain('/projects/metadata-project/') + expect(authorization as string | null).toBe('Bearer metadata-token-2') + const { getProviderValidationError } = await import('../../utils/providerValidation.js') + expect(await getProviderValidationError()).toBeNull() + expect(discoveries).toBe(3) + } finally { + resolver.mockRestore() + files.mockRestore() + restore() + } +}) + + +test.each([false, true])('native factory preserves requested model, refreshed bearer and response metadata (stream=%s)', async stream => { + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_VERTEX_PROJECT = 'test-project' + process.env.GEMINI_VERTEX_LOCATION = 'global' + process.env.GEMINI_VERTEX_MODEL = 'gemini-2.5-flash' + process.env.GEMINI_ACCESS_TOKEN = 'initial-token' + let url = '' + let authorization: string | null = null + const client = await getAnthropicClient({ + maxRetries: 0, model: 'gemini-2.5-pro', + fetchOverride: async (input, init) => { + url = String(input) + authorization = new Headers(init?.headers).get('authorization') + return Response.json({ + candidates: [{ content: { parts: [{ text: 'partial answer' }] }, finishReason: 'MAX_TOKENS' }], + usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 2, thoughtsTokenCount: 31 }, + }) + }, + }) + process.env.GEMINI_ACCESS_TOKEN = 'refreshed-token' + const params = { model: 'gemini-2.5-pro', max_tokens: 32, messages: [{ role: 'user' as const, content: 'hello' }] } + if (stream) { + const { data: response } = await client.beta.messages.create({ ...params, stream: true }).withResponse() + let observed = false + for await (const event of response) { + if (event.type !== 'message_delta') continue + observed = true + expect(event.delta.stop_reason).toBe('max_tokens') + expect(event.usage.output_tokens).toBe(33) + } + expect(observed).toBe(true) + } else { + const response = await client.beta.messages.create({ ...params, stream: false }) + expect(response.stop_reason).toBe('max_tokens') + expect(response.usage).toMatchObject({ input_tokens: 7, output_tokens: 33 }) + } + expect(url).toContain('/models/gemini-2.5-pro:generateContent') + expect(authorization as string | null).toBe('Bearer refreshed-token') +}) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 46e61a6cf0..1012ecfe07 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -55,6 +55,16 @@ import { resolveEnvOnlyProviderRouteId, } from '../../integrations/routeMetadata.js' import { resolveOpenAIShimRuntimeContext } from '../../integrations/runtimeMetadata.js' +import { + DEFAULT_GEMINI_VERTEX_MODEL, + getGeminiVertexEndpointError, + getGeminiVertexGlobalEndpointError, + resolveGeminiVertexAuthMode, + getGeminiVertexLocation, + getGeminiVertexModel, + getGeminiVertexProjectId, + resolveGeminiCredential, +} from '../../utils/geminiAuth.js' import { shouldUseCustomAnthropicBearerAuth, shouldUseFirstPartyAnthropicAuthForProvider, @@ -772,6 +782,47 @@ export async function getAnthropicClient({ } return new Anthropic(nativeArgs) } + + // This routing layer is env/CLI-only; saved native profiles are deferred. + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX)) { + const project = getGeminiVertexProjectId(process.env) + const location = getGeminiVertexLocation(process.env) + const geminiVertexModel = model?.trim() || getGeminiVertexModel(process.env) || DEFAULT_GEMINI_VERTEX_MODEL + const resolveVertexCredential = () => resolveGeminiCredential({ + ...process.env, + GEMINI_AUTH_MODE: resolveGeminiVertexAuthMode(process.env), + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, + }, { allowAmbientAdc: true }) + const credential = await resolveVertexCredential() + if (credential.kind === 'none') { + throw new Error('Gemini Vertex authentication requires GEMINI_ACCESS_TOKEN or Google ADC credentials') + } + const vertexProject = project ?? (credential.kind === 'adc' ? credential.projectId : undefined) + if (!vertexProject) { + throw new Error('Gemini Vertex project is required via GEMINI_VERTEX_PROJECT or GOOGLE_CLOUD_PROJECT') + } + const endpointError = getGeminiVertexEndpointError({ + project: vertexProject, location, model: geminiVertexModel, + }) ?? getGeminiVertexGlobalEndpointError(geminiVertexModel, location) + if (endpointError) throw new Error(endpointError) + + const { createGeminiVertexClient } = await import('./geminiVertexClient.js') + return createGeminiVertexClient({ + project: vertexProject, + location, + model: geminiVertexModel, + getAccessToken: async () => { + const fresh = await resolveVertexCredential() + if (fresh.kind === 'none') { + throw new Error('Gemini Vertex authentication is no longer available; refresh the access token or Google ADC credentials.') + } + return fresh.credential + }, + ...(resolvedFetch ? { fetch: resolvedFetch } : {}), + }) as unknown as Anthropic + } + if ( useXiaomiMimoEnvOnlyProvider || useXaiEnvOnlyProvider || @@ -881,6 +932,9 @@ export async function getAnthropicClient({ return new AnthropicFoundry(foundryArgs) as unknown as Anthropic } if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) { + // Anthropic-on-Vertex (Claude models). The Gemini-on-Vertex case already + // returned above, so reaching this branch means the user really + // selected Anthropic-on-Vertex. // Refresh GCP credentials if gcpAuthRefresh is configured and credentials are expired // This is similar to how we handle AWS credential refresh for Bedrock if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { diff --git a/src/services/api/codexOAuth.test.ts b/src/services/api/codexOAuth.test.ts index 7990d31404..6bb852ae16 100644 --- a/src/services/api/codexOAuth.test.ts +++ b/src/services/api/codexOAuth.test.ts @@ -156,6 +156,8 @@ function createFakeAuthCodeListener(callbackPath: string): FakeAuthCodeListenerI async function acquireCodexOAuthTestIsolation(): Promise { const result = await acquireEnvMutex() expect(result.acquired).toBe(true) + nextFakePort = 41000 + fakeListenerInstance = null activeSnapshot = { fetch: globalThis.fetch, diff --git a/src/services/api/codexShim.interruption.test.ts b/src/services/api/codexShim.interruption.test.ts index cb12e0126a..db8829aa94 100644 --- a/src/services/api/codexShim.interruption.test.ts +++ b/src/services/api/codexShim.interruption.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, jest, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -707,6 +707,10 @@ describe('issue #1830 Codex interruption ownership', () => { const originalTrace = process.env.OPENCLAUDE_INTERRUPT_TRACE process.env.OPENCLAUDE_INTERRUPT_TRACE = '1' __resetInterruptionTraceForTests() + const realSetImmediate = setImmediate + const nextTurn = () => new Promise(resolve => realSetImmediate(resolve)) + let consumption: ReturnType | undefined + jest.useFakeTimers() const timed = makeTimedStream( index => { if (index % 4 === 0) return ': keepalive\n\n' @@ -721,15 +725,35 @@ describe('issue #1830 Codex interruption ownership', () => { 25, ) try { - const outcome = await driveWithGuard(timed.response, { + let settled = false + consumption = driveWithGuard(timed.response, { idleTimeoutMs: 250, hardMaxQueryMs: 1_500, readerIdleTimeoutMs: 500, }) + void consumption.then( + () => { settled = true }, + () => { settled = true }, + ) + // Finish message_start and drain each frame before advancing the clock. + // Real interval callbacks can fall behind the guard deadline under load. + await nextTurn() + for (let elapsed = 25; elapsed < 250; elapsed += 25) { + jest.advanceTimersByTime(25) + await nextTurn() + } + jest.advanceTimersByTime(24) + await nextTurn() + expect(settled).toBe(false) + jest.advanceTimersByTime(1) + await nextTurn() + expect(settled).toBe(true) + const outcome = await consumption expect(outcome.timeout.reason satisfies QueryGuardTimeoutReason).toBe( 'idle', ) + expect(outcome.timeout.elapsedMs).toBe(250) expect(outcome.signalReason).toBe('query-timeout') expect(outcome.result.status).toBe('rejected') expect(outcome.events.map(event => event.type)).toEqual([ @@ -766,6 +790,8 @@ describe('issue #1830 Codex interruption ownership', () => { expect(timed.cancelReasons).toHaveLength(1) } finally { timed.stop() + jest.useRealTimers() + if (consumption) await bounded(consumption).catch(() => {}) await __waitForInterruptionTraceFlushForTests() __resetInterruptionTraceForTests() if (originalTrace === undefined) { diff --git a/src/services/api/codexShim.test.ts b/src/services/api/codexShim.test.ts index e6f100d9a8..983ea7f499 100644 --- a/src/services/api/codexShim.test.ts +++ b/src/services/api/codexShim.test.ts @@ -883,6 +883,74 @@ describe('Codex request translation', () => { expect(tools.map(t => t.name)).toEqual(['ToolSearch', 'Read']) }) + test('sanitizes Gemini Vertex signed tool_use ids for the Codex wire', () => { + // The Gemini Vertex client smuggles thought signatures into tool_use ids + // (`toolu_vertex_x~~sig~~`, up to ~1.8k chars). The Responses API + // rejects ids longer than 64 chars - the signature must never reach the + // Codex wire, and call/output pairing must survive the sanitization. + const signedId = `toolu_vertex_abc12_7~~sig~~${'U'.repeat(1700)}` + const items = convertAnthropicMessagesToResponsesInput([ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: signedId, name: 'Read', input: { file_path: '/tmp/x' } }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: signedId, content: 'done' }, + ], + }, + ]) + + const call = items.find(i => i.type === 'function_call') as + | { id: string; call_id: string } + | undefined + const output = items.find(i => i.type === 'function_call_output') as + | { call_id: string } + | undefined + + expect(call).toBeDefined() + expect(output).toBeDefined() + expect(call!.id.length).toBeLessThanOrEqual(64) + expect(call!.call_id.length).toBeLessThanOrEqual(64) + expect(call!.id).toMatch(/^[A-Za-z0-9_-]+$/) + expect(call!.call_id).toMatch(/^[A-Za-z0-9_-]+$/) + expect(output!.call_id).toBe(call!.call_id) + }) + + test.each([ + [`toolu_${'a'.repeat(90)}1`, `toolu_${'a'.repeat(90)}2`], + ['call.one', 'call.two'], + ])('keeps distinct IDs paired after sanitation: %s / %s', (a, b) => { + const items = convertAnthropicMessagesToResponsesInput([ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: a, name: 'Read', input: {} }, + { type: 'tool_use', id: b, name: 'Grep', input: {} }, + ], + }, + { + role: 'user', + content: [a, b].map(id => ({ type: 'tool_result', tool_use_id: id, content: 'done' })), + }, + ]) + + const calls = items.filter(i => i.type === 'function_call') as Array<{ + call_id: string + }> + expect(calls).toHaveLength(2) + for (const c of calls) { + expect(c.call_id.length).toBeLessThanOrEqual(64) + expect(c.call_id).toMatch(/^[A-Za-z0-9_-]+$/) + } + expect(calls[0]!.call_id).not.toBe(calls[1]!.call_id) + const outputs = items.filter(i => i.type === 'function_call_output') as Array<{ call_id: string }> + expect(outputs.map(output => output.call_id)).toEqual(calls.map(call => call.call_id)) + }) + test('converts completed Codex tool response into Anthropic message', () => { const message = convertCodexResponseToAnthropicMessage( { diff --git a/src/services/api/codexShim.ts b/src/services/api/codexShim.ts index 2b1fdebea7..78f238328c 100644 --- a/src/services/api/codexShim.ts +++ b/src/services/api/codexShim.ts @@ -7,6 +7,10 @@ import type { ResolvedProviderRequest, } from './providerConfig.js' import { sanitizeSchemaForOpenAICompat } from './openaiSchemaSanitizer.js' +import { + MAX_WIRE_TOOL_ID_LENGTH, + sanitizeToolUseIdForWire, +} from '../../utils/toolUseIds.js' import { createThinkTagFilter, stripThinkTags, @@ -108,13 +112,18 @@ function normalizeToolUseId(toolUseId: string | undefined): { id: string callId: string } { - const value = (toolUseId || '').trim() - if (!value) { + const rawValue = (toolUseId || '').trim() + if (!rawValue) { return { id: 'fc_unknown', callId: 'call_unknown', } } + // `fc_` / `call_` prefixes added below must stay within the wire limit too. + const value = sanitizeToolUseIdForWire( + rawValue, + MAX_WIRE_TOOL_ID_LENGTH - 'call_'.length, + ) if (value.startsWith('call_')) { return { id: `fc_${value.slice('call_'.length)}`, diff --git a/src/services/api/emptyUsage.ts b/src/services/api/emptyUsage.ts index ad8c25ffd5..852b32af4c 100644 --- a/src/services/api/emptyUsage.ts +++ b/src/services/api/emptyUsage.ts @@ -10,6 +10,8 @@ export const EMPTY_USAGE: Readonly = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0, + output_tokens_details: { thinking_tokens: 0 }, + fallback_credit: null, server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, service_tier: 'standard', cache_creation: { diff --git a/src/services/api/errors.openaiCompatibility.test.ts b/src/services/api/errors.openaiCompatibility.test.ts index 0c71692cca..71d555bf78 100644 --- a/src/services/api/errors.openaiCompatibility.test.ts +++ b/src/services/api/errors.openaiCompatibility.test.ts @@ -1,7 +1,7 @@ import { APIError } from '@anthropic-ai/sdk' import { expect, test } from 'bun:test' -import { getAssistantMessageFromError } from './errors.js' +import { categorizeRetryableAPIError, getAssistantMessageFromError } from './errors.js' function getFirstText(message: ReturnType): string { const first = message.message.content[0] @@ -11,6 +11,17 @@ function getFirstText(message: ReturnType): return typeof first.text === 'string' ? first.text : '' } +test.each([401, 403])('keeps native authentication retry categories for HTTP %i', status => { + const error = APIError.generate( + status, + undefined, + 'Infrastructure auth unavailable', + new Headers(), + ) + + expect(categorizeRetryableAPIError(error)).toBe('authentication_failed') +}) + test('maps endpoint_not_found category markers to actionable setup guidance', () => { const error = APIError.generate( 404, diff --git a/src/services/api/errors.opencodeGo.test.ts b/src/services/api/errors.opencodeGo.test.ts index c18cf68cd2..945e5e97aa 100644 --- a/src/services/api/errors.opencodeGo.test.ts +++ b/src/services/api/errors.opencodeGo.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { APIError } from '@anthropic-ai/sdk' import { afterEach, beforeEach, expect, test } from 'bun:test' +import { clearOAuthTokenCache } from '../../utils/auth.js' import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js' import { classifyAPIError, @@ -27,6 +28,7 @@ let tempDir: string beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'openclaude-opencode-go-error-test-')) setClaudeConfigHomeDirForTesting(tempDir) + clearOAuthTokenCache() }) afterEach(() => { diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index 0b542e69b3..c18b04ab09 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -1616,7 +1616,10 @@ export function categorizeRetryableAPIError( ) { return 'rate_limit' } - if (error.status === 429) { + if ( + error.status === 429 || + (error.status === 403 && extractOpenAICategoryMarker(error.message ?? '') === 'rate_limited') + ) { return 'rate_limit' } if (error.status === 401 || error.status === 403) { diff --git a/src/services/api/geminiVertexClient.test.ts b/src/services/api/geminiVertexClient.test.ts index 577c22349c..0bb402c7c9 100644 --- a/src/services/api/geminiVertexClient.test.ts +++ b/src/services/api/geminiVertexClient.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from 'bun:test' +import { APIConnectionTimeoutError, APIUserAbortError } from '@anthropic-ai/sdk' +import { expect, jest, test } from 'bun:test' import { createGeminiVertexClient, type GeminiVertexStreamEvent } from './geminiVertexClient.js' function createJsonVertexResponse(text: string) { @@ -12,6 +13,80 @@ function createJsonVertexResponse(text: string) { ) } +test.each([false, true])('Vertex request cancellation reaches fetch and settles (stream=%s)', async stream => { + const started = Promise.withResolvers() + const response = Promise.withResolvers() + const controller = new AbortController() + let fetchSignal: AbortSignal | null | undefined + const client = createGeminiVertexClient({ + project: 'project-test', location: 'global', model: 'gemini-2.5-flash', + getAccessToken: async () => 'token', + fetch: async (_input, init) => { + fetchSignal = init?.signal + fetchSignal?.addEventListener('abort', () => response.reject(fetchSignal?.reason), { once: true }) + started.resolve() + return response.promise + }, + }) + const request = (stream ? client.beta.messages : client.messages).create({ + model: 'gemini-2.5-flash', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }], stream, + }, { signal: controller.signal }) + const outcome = (stream ? request.withResponse() : request).then(() => undefined, error => error) + try { + await started.promise + controller.abort() + response.resolve(createJsonVertexResponse('late')) + expect(await outcome).toBeInstanceOf(APIUserAbortError) + expect(fetchSignal?.aborted).toBe(true) + } finally { + controller.abort() + response.resolve(createJsonVertexResponse('cleanup')) + await outcome + } +}) + +test('Vertex honors an explicit request timeout and releases its timer', async () => { + jest.useFakeTimers() + const started = Promise.withResolvers() + const response = Promise.withResolvers() + const client = createGeminiVertexClient({ + project: 'project-test', location: 'global', model: 'gemini-2.5-flash', + getAccessToken: async () => 'token', + fetch: async (_input, init) => { + init?.signal?.addEventListener('abort', () => response.reject(init.signal?.reason), { once: true }) + started.resolve() + return response.promise + }, + }) + const outcome = client.messages.create({ + model: 'gemini-2.5-flash', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }], + }, { timeout: 5 }).then(() => undefined, error => error) + try { + await started.promise + jest.advanceTimersByTime(5) + response.resolve(createJsonVertexResponse('late')) + expect(await outcome).toBeInstanceOf(APIConnectionTimeoutError) + expect(jest.getTimerCount()).toBe(0) + } finally { + response.resolve(createJsonVertexResponse('cleanup')) + await outcome + jest.useRealTimers() + } +}) + +test('an already-cancelled Vertex request never resolves credentials or sends a request', async () => { + let calls = 0 + const client = createGeminiVertexClient({ + project: 'project-test', location: 'global', model: 'gemini-2.5-flash', + getAccessToken: async () => { calls++; return 'token' }, + fetch: async () => { calls++; return createJsonVertexResponse('unexpected') }, + }) + await expect(client.messages.create({ + model: 'gemini-2.5-flash', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }], + }, { signal: AbortSignal.abort() })).rejects.toBeInstanceOf(APIUserAbortError) + expect(calls).toBe(0) +}) + test('Gemini Vertex client uses root aiplatform host for global location', async () => { let capturedUrl: string | undefined @@ -549,3 +624,64 @@ test('Gemini Vertex client propagates HTTP errors', async () => { messages: [{ role: 'user', content: 'Salut' }], })).rejects.toThrow('Gemini Vertex request failed: 403 permission denied') }) + +test('native wire preserves direct media and FileRead images without mixing function responses', async () => { + const { FileReadTool } = await import('../../tools/FileReadTool/FileReadTool.js') + const image = { type: 'image' as const, source: { + type: 'base64' as const, media_type: 'image/png' as const, data: 'cG5n', + } } + const pdf = { type: 'document' as const, source: { + type: 'base64' as const, media_type: 'application/pdf' as const, data: 'cGRm', + } } + const result = FileReadTool.mapToolResultToToolResultBlockParam({ + type: 'image', file: { base64: image.source.data, type: image.source.media_type, originalSize: 3 }, + }, 'read-image') + let sent: Record | undefined + const client = createGeminiVertexClient({ + project: 'project-test', location: 'global', model: 'gemini-2.5-flash', + getAccessToken: async () => 'token', + fetch: async (_url, init) => { + sent = JSON.parse(String(init?.body)) + return createJsonVertexResponse('seen') + }, + }) + await client.messages.create({ + model: 'gemini-2.5-flash', max_tokens: 8, messages: [ + { role: 'user', content: [image] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'read-image', name: 'Read', input: {} }] }, + { role: 'user', content: [result] }, + { role: 'user', content: [pdf] }, + ], + }) + expect(sent?.contents).toEqual([ + { role: 'user', parts: [{ inlineData: { mimeType: 'image/png', data: 'cG5n' } }] }, + { role: 'model', parts: [{ functionCall: { name: 'Read', args: {} } }] }, + { role: 'user', parts: [{ functionResponse: { name: 'Read', response: { result: '' } } }] }, + { role: 'user', parts: [{ inlineData: { mimeType: 'image/png', data: 'cG5n' } }] }, + { role: 'user', parts: [{ inlineData: { mimeType: 'application/pdf', data: 'cGRm' } }] }, + ]) +}) + +test('native wire rejects unsupported URL media before sending instead of losing it', async () => { + let requests = 0 + const client = createGeminiVertexClient({ + project: 'project-test', location: 'global', model: 'gemini-2.5-flash', + getAccessToken: async () => 'token', + fetch: async () => { requests++; return createJsonVertexResponse('unexpected') }, + }) + await expect(client.messages.create({ + model: 'gemini-2.5-flash', max_tokens: 8, messages: [{ + role: 'user', content: [{ type: 'image', source: { type: 'url', url: 'https://example.invalid/secret' } }], + }], + })).rejects.toThrow(/base64/) + expect(requests).toBe(0) +}) + +test('direct native client rejects a host-changing location before resolving a bearer', () => { + let credentials = 0 + expect(() => createGeminiVertexClient({ + project: 'project-test', location: 'evil.example/x#', model: 'gemini-2.5-flash', + getAccessToken: async () => { credentials++; return 'token' }, + })).toThrow('GEMINI_VERTEX_LOCATION') + expect(credentials).toBe(0) +}) diff --git a/src/services/api/geminiVertexClient.ts b/src/services/api/geminiVertexClient.ts index 63a8396869..a5bc169833 100644 --- a/src/services/api/geminiVertexClient.ts +++ b/src/services/api/geminiVertexClient.ts @@ -1,5 +1,8 @@ -import type { ClientOptions } from '@anthropic-ai/sdk' +import { APIConnectionError, APIConnectionTimeoutError, APIError, APIUserAbortError, type ClientOptions } from '@anthropic-ai/sdk' import type { MessageCreateParamsBase } from '@anthropic-ai/sdk/resources/messages/messages' +import { raceAbort, throwIfAborted } from '../../utils/boundedAsync.js' +import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js' +import { getGeminiVertexEndpointError } from '../../utils/geminiAuth.js' type AccessTokenProvider = () => Promise @@ -23,6 +26,7 @@ type GeminiVertexFunctionResponsePart = { } type GeminiVertexPart = | { text: string } + | { inlineData: { mimeType: string; data: string } } | GeminiVertexFunctionCallPart | GeminiVertexFunctionResponsePart type GeminiVertexContent = { role: 'user' | 'model'; parts: GeminiVertexPart[] } @@ -166,7 +170,7 @@ type GeminiVertexToolUseBlock = { input: Record } type GeminiVertexContentBlock = GeminiVertexTextBlock | GeminiVertexToolUseBlock -type GeminiVertexStopReason = 'end_turn' | 'tool_use' +type GeminiVertexStopReason = 'end_turn' | 'tool_use' | 'max_tokens' type GeminiVertexMessage = { id: string @@ -356,11 +360,34 @@ function safeJsonParse(value: unknown): Record { return {} } -function stringifyToolResultContent(content: unknown): string { +function toGeminiMediaPart(block: unknown): GeminiVertexPart | undefined { + if (!block || typeof block !== 'object') return undefined + const { type, source } = block as { + type?: string + source?: { type?: string; media_type?: string; data?: string } + } + if (type !== 'image' && type !== 'document') return undefined + if ( + source?.type !== 'base64' || !source.data || + !(type === 'image' ? source.media_type?.startsWith('image/') : source.media_type === 'application/pdf') + ) { + // Never return a successful answer to an attachment the model did not see. + // Do not fetch arbitrary URLs or include their potentially secret text. + throw new Error('Gemini Vertex requires base64 images or PDF documents; attach the file as base64 instead of an unsupported media source.') + } + return { inlineData: { mimeType: source.media_type!, data: source.data } } +} + +function stringifyToolResultContent(content: unknown, mediaParts: GeminiVertexPart[]): string { if (typeof content === 'string') return content if (Array.isArray(content)) { return content .map(block => { + const media = toGeminiMediaPart(block) + if (media) { + mediaParts.push(media) + return '' + } if (block && typeof block === 'object' && 'text' in block) { return String((block as { text?: unknown }).text ?? '') } @@ -389,8 +416,8 @@ function stringifyToolResultContent(content: unknown): string { // so the subsequent tool_result can be wired back to the right function) // - user tool_result blocks → user functionResponse parts, looking up the // function name from the most recent tool_use with the same id -// Drops blocks we can't translate (e.g. images) instead of failing the whole -// request — Vertex will respond from whatever it received. +// - direct and nested tool-result images/PDFs → inlineData parts +// Unsupported media sources fail before sending, rather than losing content. function toGeminiContents( messages: MessageCreateParamsBase['messages'], ): GeminiVertexContent[] { @@ -414,6 +441,7 @@ function toGeminiContents( if (message.content) otherParts.push({ text: message.content }) } else { for (const block of message.content) { + const media = toGeminiMediaPart(block) const b = block as { type?: string text?: string @@ -423,7 +451,9 @@ function toGeminiContents( tool_use_id?: string content?: unknown } - if (b.type === 'text' && typeof b.text === 'string' && b.text) { + if (media) { + otherParts.push(media) + } else if (b.type === 'text' && typeof b.text === 'string' && b.text) { otherParts.push({ text: b.text }) } else if (b.type === 'tool_use' && b.name && b.id) { toolUseIdToName.set(b.id, b.name) @@ -443,7 +473,7 @@ function toGeminiContents( responseParts.push({ functionResponse: { name, - response: { result: stringifyToolResultContent(b.content) }, + response: { result: stringifyToolResultContent(b.content, otherParts) }, }, }) } @@ -606,13 +636,24 @@ async function* toAnthropicStream( } export function createGeminiVertexClient(options: GeminiVertexClientOptions) { + const endpointError = getGeminiVertexEndpointError(options) + if (endpointError) throw new Error(endpointError) const fetchImpl = options.fetch ?? fetch - const create = (params: MessageCreateParamsBase & { stream?: boolean }): GeminiVertexPromise => { + const create = ( + params: MessageCreateParamsBase & { stream?: boolean }, + requestOptions: { signal?: AbortSignal | null; timeout?: number } = {}, + ): GeminiVertexPromise => { let capturedResponse: Response | undefined - const promise = (async (): Promise => { + const { signal, cleanup } = createCombinedAbortSignal(requestOptions.signal ?? undefined, { + timeoutMs: requestOptions.timeout, + trace: { subsystem: 'gemini_vertex', controllerRole: 'request' }, + }) + const promise = raceAbort((async (): Promise => { + throwIfAborted(signal) const model = options.model const token = await options.getAccessToken() + throwIfAborted(signal) const host = options.location === 'global' ? 'aiplatform.googleapis.com' : `${options.location}-aiplatform.googleapis.com` @@ -647,6 +688,7 @@ export function createGeminiVertexClient(options: GeminiVertexClientOptions) { const response = await fetchImpl(url, { method: 'POST', + signal, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', @@ -671,15 +713,25 @@ export function createGeminiVertexClient(options: GeminiVertexClientOptions) { : {}), }, }), + }).catch(error => { + // Only transport failures become connection errors. Schema/content + // errors below retain their existing fatal classification. + throw new APIConnectionError({ cause: error instanceof Error ? error : undefined }) }) capturedResponse = response + const body = await response.text().catch(error => { + throw new APIConnectionError({ cause: error instanceof Error ? error : undefined }) + }) if (!response.ok) { - const body = await response.text() - throw new Error(`Gemini Vertex request failed: ${response.status} ${body}`) + throw APIError.generate( + response.status, undefined, + `Gemini Vertex request failed: ${response.status} ${body}`, + response.headers, + ) } - const json = (await response.json()) as GeminiVertexResponse + const json = JSON.parse(body) as GeminiVertexResponse const contentBlocks = extractContentBlocks(json) const text = extractText(json) const candidate = json.candidates?.[0] @@ -770,15 +822,24 @@ export function createGeminiVertexClient(options: GeminiVertexClientOptions) { type: 'message', role: 'assistant', model, - stop_reason: hasToolCall ? 'tool_use' : 'end_turn', + stop_reason: hasToolCall ? 'tool_use' : candidate?.finishReason === 'MAX_TOKENS' ? 'max_tokens' : 'end_turn', stop_sequence: null, usage: { input_tokens: json.usageMetadata?.promptTokenCount ?? 0, - output_tokens: json.usageMetadata?.candidatesTokenCount ?? 0, + output_tokens: (json.usageMetadata?.candidatesTokenCount ?? 0) + + (json.usageMetadata?.thoughtsTokenCount ?? 0), }, content: finalBlocks, } - })() + })(), signal).catch(error => { + if (signal.aborted) { + if (requestOptions.signal?.aborted && signal.reason === requestOptions.signal.reason) { + throw new APIUserAbortError() + } + throw new APIConnectionTimeoutError() + } + throw error + }).finally(cleanup) const typed = promise as GeminiVertexPromise typed.withResponse = async () => { diff --git a/src/services/api/googleAuth.integration.test.ts b/src/services/api/googleAuth.integration.test.ts new file mode 100644 index 0000000000..70edde8275 --- /dev/null +++ b/src/services/api/googleAuth.integration.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { GoogleAuth, UserRefreshClient } from 'google-auth-library' + +import { snapshotEnv } from '../../test/envSnapshot.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { checkGcpCredentialsValid } from '../../utils/auth.js' +import { resolveGeminiCredential } from '../../utils/geminiAuth.js' +import { getAnthropicClient } from './client.js' + +const originalGetClient = GoogleAuth.prototype.getClient +const envKeys = [ + 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', + 'GOOGLE_CLOUD_QUOTA_PROJECT', 'GOOGLE_PROJECT_ID', + 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_SKIP_VERTEX_AUTH', + 'ANTHROPIC_VERTEX_PROJECT_ID', 'ANTHROPIC_VERTEX_BASE_URL', 'CLOUD_ML_REGION', + 'CLAUDE_CODE_USE_GEMINI', 'GEMINI_AUTH_MODE', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', + 'GEMINI_ACCESS_TOKEN', 'GEMINI_BASE_URL', 'GEMINI_MODEL', + 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_MISTRAL', 'ANTHROPIC_CUSTOM_HEADERS', +] as const + +let restoreEnv: () => void +let fixtureDir: string +let originalMacro: PropertyDescriptor | undefined +let authInstances: GoogleAuth[] +let tokenRequests: URLSearchParams[] +let apiHeaders: Headers | undefined +let apiUrl: string | undefined + +const apiFetch = (async (input, init) => { + apiUrl = String(input) + apiHeaders = new Headers(init?.headers) + const vertex = process.env.CLAUDE_CODE_USE_VERTEX === '1' + return new Response(JSON.stringify(vertex ? { + id: 'msg_auth', type: 'message', role: 'assistant', + model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + } : { + id: 'chatcmpl_auth', model: 'gemini-2.5-flash', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { headers: { 'Content-Type': 'application/json' } }) +}) as typeof fetch + +beforeEach(async () => { + await acquireSharedMutationLock('services/api/googleAuth.integration.test.ts') + restoreEnv = snapshotEnv(envKeys, { clear: true }) + originalMacro = Object.getOwnPropertyDescriptor(globalThis, 'MACRO') + ;(globalThis as Record).MACRO = { VERSION: 'test-version' } + fixtureDir = mkdtempSync(join(tmpdir(), 'openclaude-google-auth-')) + const credentialsPath = join(fixtureDir, 'adc.json') + writeFileSync(credentialsPath, JSON.stringify({ + type: 'authorized_user', + client_id: 'fixture-client', + client_secret: 'fixture-secret', + refresh_token: 'fixture-refresh', + quota_project_id: 'fixture-quota', + })) + process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath + process.env.GOOGLE_CLOUD_PROJECT = 'fixture-project' + authInstances = [] + tokenRequests = [] + apiHeaders = undefined + apiUrl = undefined + + // Keep the production constructor, ADC file loading, getClient, and token/header + // methods real. Replace only the HTTP transport of the resulting auth client. + spyOn(GoogleAuth.prototype, 'getClient').mockImplementation(async function (this: GoogleAuth) { + const client = await originalGetClient.call(this) + expect(client).toBeInstanceOf(UserRefreshClient) + authInstances.push(this) + client.transporter.defaults.fetchImplementation = async (url, init) => { + expect(String(url)).toBe('https://oauth2.googleapis.com/token') + expect(init?.method).toBe('POST') + tokenRequests.push(new URLSearchParams(String(init?.body))) + return new Response(JSON.stringify({ + access_token: 'fixture-access-token', token_type: 'Bearer', expires_in: 3600, + }), { headers: { 'Content-Type': 'application/json' } }) + } + return client + }) +}) + +afterEach(() => { + try { + mock.restore() + restoreEnv() + if (originalMacro) { + Object.defineProperty(globalThis, 'MACRO', originalMacro) + } else { + delete (globalThis as Record).MACRO + } + rmSync(fixtureDir, { recursive: true, force: true }) + } finally { + releaseSharedMutationLock() + } +}) + +function expectRealAuthFlow(): void { + expect(authInstances).toHaveLength(1) + expect(tokenRequests).toHaveLength(1) + expect(Object.fromEntries(tokenRequests[0]!)).toEqual({ + client_id: 'fixture-client', + client_secret: 'fixture-secret', + refresh_token: 'fixture-refresh', + grant_type: 'refresh_token', + }) +} + +test('Vertex loads ADC and forwards real Google auth Headers to rawPredict', async () => { + process.env.CLAUDE_CODE_USE_VERTEX = '1' + process.env.CLOUD_ML_REGION = 'us-east5' + process.env.ANTHROPIC_VERTEX_PROJECT_ID = 'fixture-target' + const client = await getAnthropicClient({ + maxRetries: 0, model: 'claude-sonnet-4-6', fetchOverride: apiFetch, + }) + await client.messages.create({ + model: 'claude-sonnet-4-6', max_tokens: 16, + messages: [{ role: 'user', content: 'hello' }], + }) + + expectRealAuthFlow() + expect(apiUrl).toContain('/projects/fixture-target/locations/us-east5/') + expect(apiUrl).toEndWith(':rawPredict') + expect(apiHeaders?.get('authorization')).toBe('Bearer fixture-access-token') + expect(apiHeaders?.get('x-goog-user-project')).toBe('fixture-quota') +}) + +test('Gemini loads ADC and normalizes the real getAccessToken result for requests', async () => { + // The OpenAI shim uses the shared fetch transport rather than fetchOverride. + spyOn(globalThis, 'fetch').mockImplementation(apiFetch) + process.env.CLAUDE_CODE_USE_GEMINI = '1' + process.env.GEMINI_AUTH_MODE = 'adc' + process.env.GEMINI_MODEL = 'gemini-2.5-flash' + process.env.GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai' + const client = await getAnthropicClient({ + maxRetries: 0, model: 'gemini-2.5-flash', fetchOverride: apiFetch, + }) + await client.messages.create({ + model: 'gemini-2.5-flash', max_tokens: 16, + messages: [{ role: 'user', content: 'hello' }], + }) + + expectRealAuthFlow() + expect(apiUrl).toBe('https://generativelanguage.googleapis.com/v1beta/openai/chat/completions') + expect(apiHeaders?.get('authorization')).toBe('Bearer fixture-access-token') + expect(apiHeaders?.get('x-goog-user-project')).toBe('fixture-project') +}) + +test('Vertex credential validation loads ADC and retrieves a real access token', async () => { + await expect(checkGcpCredentialsValid()).resolves.toBe(true) + expectRealAuthFlow() +}) + +test('Vertex validation and Gemini ADC reject malformed credential files', async () => { + writeFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS!, '{invalid json') + process.env.GEMINI_AUTH_MODE = 'adc' + await expect(checkGcpCredentialsValid()).resolves.toBe(false) + await expect(resolveGeminiCredential()).resolves.toEqual({ kind: 'none' }) + expect(tokenRequests).toHaveLength(0) +}) diff --git a/src/services/api/openaiErrorClassification.test.ts b/src/services/api/openaiErrorClassification.test.ts index 8b96c74974..d973a04b01 100644 --- a/src/services/api/openaiErrorClassification.test.ts +++ b/src/services/api/openaiErrorClassification.test.ts @@ -574,3 +574,106 @@ test('does not classify generic billing 400 errors as quota_exhausted', () => { expect(failure.category).toBe('malformed_provider_response') expect(failure.retryable).toBe(false) }) + + +test.each([ + 'Too many requests, try again in 30s', + 'Rate limit exceeded', + 'Rate limit exceeded for this API key', + 'Rate limit exceeded for this OAuth token', + 'Rate limit exceeded for this account', + 'Rate limit exceeded for this API key; streaming is disabled', + 'Rate limit exceeded for this API key, streaming is disabled', + 'Rate limit exceeded for this API key, currently streaming is disabled', + 'Bad gateway: rate limit exceeded for this API key', + 'Too many requests; no tokens remaining in the rate limit bucket', + 'Too many requests; empty token bucket', + 'Too many requests; no tokens found in the rate limit bucket', + 'Rate limit exceeded for this API key: bad gateway', + 'Rate limit exceeded for this API key: upstream response is empty', + 'Rate limit exceeded for this API key, bad gateway', + 'Rate limit exceeded for this API key, empty response received', + 'Too many requests for api_key', + 'Request rate-limited', + 'Requests are being throttled', + '{"error":{"code":"rate_limit_exceeded","message":"Please retry later"}}', +])('classifies explicit throttle-403 as retryable: %s', body => { + expect(classifyOpenAIHttpFailure({ status: 403, body })).toMatchObject({ + category: 'rate_limited', + retryable: true, + }) + expect(classifyOpenAIHttpFailure({ status: 401, body })).toMatchObject({ + category: 'auth_invalid', + retryable: false, + }) +}) + +test.each([ + '', + ' \n\t', + 'Try again later', + 'Server busy; slow down', + 'Capacity exceeded', + 'See the rate limit documentation', + 'Forbidden: too many requests', + 'Unauthorized: rate limit exceeded', + 'Invalid API key; too many requests', + 'Missing API key; too many requests', + 'No API key was provided; too many requests', + 'No valid API key found; too many requests', + 'No API key found; too many requests', + 'No token provided; rate limit exceeded', + 'API key is empty; too many requests', + '{"error":{"code":"bad_api_key","message":"Too many requests"}}', + 'Too many requests; your API key is no longer valid', + 'Too many requests; your API key does not exist', + 'Too many requests; API key, which is no longer valid', + 'API key, now empty; too many requests', + 'Invalid\nAPI key; too many requests', + 'Rate limit exceeded; API key is invalid', + 'Rate limit exceeded; API key is required', + 'Rate limit exceeded; API key is disabled', + 'Rate limit exceeded; API key is not valid', + '{"error":{"code":"api_key_missing","message":"Too many requests"}}', + 'Invalid credentials; too many requests', + 'Account suspended; rate limit exceeded', + 'Account disabled; too many requests', + 'Account disabled, too many requests', + 'API key is disabled, too many requests', + 'API key, supplied here, is invalid; too many requests', + 'API key, which is invalid; too many requests', + 'API key, which you disabled; too many requests', + 'API key, now disabled; too many requests', + 'Account deactivated; rate limit exceeded', + 'Deactivated account; too many requests', + '{"error":{"code":"account_disabled","message":"Too many requests"}}', + 'Key is invalid; too many requests', + 'Your key has been disabled; rate limit exceeded', + 'API key has been permanently disabled; too many requests', + 'Token has been permanently disabled; too many requests', + 'Disabled for this organization: API key; too many requests', + 'Invalid API keys; too many requests', + 'Invalid tokens; too many requests', + '{"error":{"code":"api_key_invalid_format","message":"Too many requests"}}', + 'Insufficient funds; requests throttled', + 'Invalid token; requests throttled', + 'Token is invalid; too many requests', + 'Token\nis invalid; too many requests', + 'Invalid\ntoken; too many requests', + 'Authentication failed; too many requests', + 'Not authenticated; too many requests', + 'Token expired; too many requests', + 'Token revoked; rate limit exceeded', + 'Access denied; too many requests', + 'Permission denied; request throttled', + 'Organization not allowed; rate limit exceeded', + 'Quota exceeded; too many requests', + 'Billing limit reached; too many requests', + 'Credit balance depleted; rate limit exceeded', + '{"error":{"code":"insufficient_quota","message":"Too many requests"}}', + '{"error":{"type":"authentication_error","message":"Rate limit exceeded"}}', +])('keeps ambiguous or denied throttle-403 terminal: %s', body => { + const failure = classifyOpenAIHttpFailure({ status: 403, body }) + expect(failure.retryable).toBe(false) + expect(failure.category).not.toBe('rate_limited') +}) diff --git a/src/services/api/openaiErrorClassification.ts b/src/services/api/openaiErrorClassification.ts index a2cb8a4563..dde680e7ec 100644 --- a/src/services/api/openaiErrorClassification.ts +++ b/src/services/api/openaiErrorClassification.ts @@ -413,6 +413,38 @@ function isQuotaExhaustedMessage(body: string): boolean { ) } +function isExplicitThrottleMessage(body: string): boolean { + // A 403 is normally an authorization failure. Require positive throttling + // evidence, with auth/quota signals taking precedence even in mixed bodies. + // Empty bodies and generic "try again" / "busy" responses are ambiguous. + // A comma may continue the credential's predicate ("key, now disabled" or + // "key, supplied here, is invalid"). Keep that evidence together, without + // joining a separate subject such as "key, streaming is disabled". + const credentialFaultBody = body.replace( + /,\s*(?:[^,.;!?]*,\s*)?(?=(?:(?:which|that)\b[^,.;!?]*|(?:(?:is|are|was|were|has|have|had|been|being|now|still|already|[a-z]+ly)\s+)*)(?:invalid|missing|incorrect|disabled|deactivated|unusable|required|not[ _-](?:valid|found|provided|configured|set))\b)/gi, + continuation => continuation.replace(/,/g, ' '), + ) + if ( + /unauthori[sz]ed|unauthenticated|not[ _-]auth|forbidden|access[ _-]denied|permission|credential|denied|suspended|blocked|banned|insufficient[ _-]funds|revoked|expired|not[ _-]allowed|quota|billing|credit|payment|allotment/i.test(body) || + /auth(?:entication|ori[sz]ation)?[\s\S]{0,20}(?:error|fail|invalid|required|missing)|(?:invalid|failed|missing|required)[\s\S]{0,20}auth/i.test(body) || + // New absence/state forms must describe the credential directly. Do not + // scan across an unrelated subject such as a bad gateway or empty response. + /(?:\b|_)(?:no(?:[\s_-]+valid)?|bad|empty)[\s_-]+(?:accounts?|(?:api[\s_-]*)?keys?|tokens?)(?:\b|_)(?:[\s_-]+(?:(?:is|are|was|were|has|have|had|been|being)[\s_-]+)*(?:provided|configured|set)(?:\b|_)|(?=\s*(?:$|[,.;!?:'"}\]])))/i.test(body) || + // A key/account lookup failure is explicit absence; unqualified tokens + // can instead describe a rate-limit bucket, so require credential validity. + /(?:\b|_)no[\s_-]+(?:(?:valid[\s_-]+)?(?:accounts?|(?:api[\s_-]*)?keys?)|valid[\s_-]+tokens?)[\s_-]+(?:(?:is|are|was|were|has|have|had|been|being)[\s_-]+)*found(?:\b|_)/i.test(body) || + /(?:\b|_)(?:accounts?|(?:api[ _-]?)?keys?|tokens?)(?:,\s*[^,.;!?]*,)?(?:[\s_-]+|,\s*)(?:(?:which|that)[\s_-]+)?(?:(?:is|are|was|were|has|have|had|been|being|now|still|already|[a-z]+ly)[\s_-]+)+empty(?:\b|_)/i.test(body) || + /(?:\b|_)(?:accounts?|(?:api[ _-]?)?keys?|tokens?)(?:,\s*[^,.;!?]*,)?(?:[\s_-]+|,\s*)(?:(?:which|that)[\s_-]+)?(?:(?:is|are|was|were|has|have|had|been|being|now|still|already|[a-z]+ly)[\s_-]+)*(?:no[\s_-]longer[\s_-]valid|does[\s_-]not[\s_-]exist)(?:\b|_)/i.test(body) || + // Rate limits can name valid accounts, keys or OAuth tokens. Match faults + // in either word order within a clause, including line wraps and codes; + // an arbitrary character limit can miss "has been permanently disabled". + /(?:\b|_)(?:invalid|missing|incorrect|disabled|deactivated|unusable)[^,.;!?]*(?:accounts?|(?:api[ _-]?)?keys?|tokens?)(?:\b|_)|(?:\b|_)(?:accounts?|(?:api[ _-]?)?keys?|tokens?)[^,.;!?]*(?:invalid|missing|incorrect|disabled|deactivated|unusable|required|not[ _-](?:valid|found|provided|configured|set))(?:\b|_)/i.test(credentialFaultBody) + ) { + return false + } + return /\brate[ _-]limit(?:ed|ing|[ _-](?:exceeded|reached))\b|\btoo[ _-]many[ _-]requests\b|\bthrottl(?:ed|ing)\b/i.test(body) +} + export function formatOpenAICategoryMarker( category: OpenAICompatibilityFailureCategory, host?: string, @@ -545,6 +577,17 @@ export function classifyOpenAIHttpFailure(options: { } } + if (options.status === 403 && isExplicitThrottleMessage(body)) { + return { + source: 'http', + category: 'rate_limited', + retryable: true, + status: options.status, + message: body, + hint: 'Provider rate-limited the request. Retry after backoff.', + } + } + if (options.status === 401 || options.status === 403) { // OAuth-issued tokens (GitHub Models via /onboard-github, Codex) expire // and surface as 401 with a "token expired" body. The generic API-key diff --git a/src/services/api/openaiShim.ollamaTextToolCalls.test.ts b/src/services/api/openaiShim.ollamaTextToolCalls.test.ts index 53c3f4f42c..4d8faec0be 100644 --- a/src/services/api/openaiShim.ollamaTextToolCalls.test.ts +++ b/src/services/api/openaiShim.ollamaTextToolCalls.test.ts @@ -10,9 +10,12 @@ * 5. P1 context guard — bare JSON in explanatory prose is skipped */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { snapshotEnv } from '../../test/envSnapshot.js' import { createOpenAIShimClient } from './openaiShim.js' import { parseTextToolCalls } from './openaiShim/rawToolCallParsing.js' +const ENV_KEYS = ['OLLAMA_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL'] as const + type FetchType = typeof globalThis.fetch type OpenAIShimClient = { @@ -224,17 +227,17 @@ const ollamaToolChunk = (toolCalls: unknown[], finishReason?: string) => ({ describe('Ollama streaming — think-tag filtering on text-tool fallback (P1)', () => { let originalFetch: FetchType + let restoreEnv: () => void beforeEach(() => { originalFetch = globalThis.fetch + restoreEnv = snapshotEnv(ENV_KEYS) process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' }) afterEach(() => { globalThis.fetch = originalFetch - delete process.env.OLLAMA_BASE_URL - delete process.env.OPENAI_API_KEY - delete process.env.OPENAI_BASE_URL + restoreEnv() }) test(' content is NOT emitted as assistant text when text-tool fallback fires', async () => { @@ -278,17 +281,17 @@ describe('Ollama streaming — think-tag filtering on text-tool fallback (P1)', describe('Ollama streaming — plain text response with no tool calls', () => { let originalFetch: FetchType + let restoreEnv: () => void beforeEach(() => { originalFetch = globalThis.fetch + restoreEnv = snapshotEnv(ENV_KEYS) process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' }) afterEach(() => { globalThis.fetch = originalFetch - delete process.env.OLLAMA_BASE_URL - delete process.env.OPENAI_API_KEY - delete process.env.OPENAI_BASE_URL + restoreEnv() }) test('plain text in two chunks (content then stop) is emitted as text_delta', async () => { @@ -385,17 +388,17 @@ describe('Ollama streaming — plain text response with no tool calls', () => { describe('Ollama streaming — visible text before real structured tool_calls (P2)', () => { let originalFetch: FetchType + let restoreEnv: () => void beforeEach(() => { originalFetch = globalThis.fetch + restoreEnv = snapshotEnv(ENV_KEYS) process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' }) afterEach(() => { globalThis.fetch = originalFetch - delete process.env.OLLAMA_BASE_URL - delete process.env.OPENAI_API_KEY - delete process.env.OPENAI_BASE_URL + restoreEnv() }) test('visible assistant text is preserved when real delta.tool_calls follow it', async () => { @@ -441,17 +444,17 @@ describe('Ollama streaming — visible text before real structured tool_calls (P describe('Ollama streaming — visible prose before text-based tool-call fallback (P2 buffered path)', () => { let originalFetch: FetchType + let restoreEnv: () => void beforeEach(() => { originalFetch = globalThis.fetch + restoreEnv = snapshotEnv(ENV_KEYS) process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' }) afterEach(() => { globalThis.fetch = originalFetch - delete process.env.OLLAMA_BASE_URL - delete process.env.OPENAI_API_KEY - delete process.env.OPENAI_BASE_URL + restoreEnv() }) test('visible prose before text-based JSON tool call is preserved in emitted text_delta', async () => { @@ -513,17 +516,17 @@ describe('parseTextToolCalls — pretty-printed bare JSON detection', () => { describe('Ollama streaming — non-stop terminal finish reasons flush buffer', () => { let originalFetch: FetchType + let restoreEnv: () => void beforeEach(() => { originalFetch = globalThis.fetch + restoreEnv = snapshotEnv(ENV_KEYS) process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' }) afterEach(() => { globalThis.fetch = originalFetch - delete process.env.OLLAMA_BASE_URL - delete process.env.OPENAI_API_KEY - delete process.env.OPENAI_BASE_URL + restoreEnv() }) test('buffered text is flushed when finish_reason is "length"', async () => { diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 49368fdf3c..8571c2f840 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -5042,17 +5042,26 @@ test('disarms the API timeout after headers arrive while the body keeps streamin // Native Ollama endpoint selection remains an adapter/facade integration concern. // Keep this marker stable for independent adjacent test migrations. // openaiShim test extraction seam 129 start: uses native Ollama chat endpoint when local base URL omits /v1 -test('uses native Ollama chat endpoint when local base URL omits /v1', async () => { - process.env.OPENAI_BASE_URL = 'http://localhost:11434' +test.each([ + ['', '', undefined], + ['/v1', '?thinking=enabled', true], + ['', '?thinking=disabled', false], + ['/v1', '?reasoning=high', undefined], + ['', '?thinking=enabled&reasoning=low', true], + ['/v1', '?thinking=disabled&reasoning=high', false], +] as const)('native Ollama preserves defaults and explicit thinking (%s, %s)', async (basePath, modelOptions, think) => { + process.env.OPENAI_BASE_URL = `http://localhost:11434${basePath}` const requestUrls: string[] = [] - globalThis.fetch = (async (input, _init) => { + let requestBody: Record | undefined + globalThis.fetch = (async (input, init) => { + requestBody = JSON.parse(String(init?.body)) const url = typeof input === 'string' ? input : input.url requestUrls.push(url) return new Response( JSON.stringify({ - model: 'qwen2.5-coder:7b', + model: 'qwen3:8b', message: { role: 'assistant', content: 'hello from native Ollama', @@ -5075,7 +5084,7 @@ test('uses native Ollama chat endpoint when local base URL omits /v1', async () await expect( client.beta.messages.create({ - model: 'qwen2.5-coder:7b', + model: `qwen3:8b${modelOptions}`, messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false, @@ -5083,6 +5092,12 @@ test('uses native Ollama chat endpoint when local base URL omits /v1', async () ).resolves.toBeDefined() expect(requestUrls).toEqual(['http://localhost:11434/api/chat']) + expect(requestBody?.model).toBe('qwen3:8b') + if (think === undefined) { + expect(requestBody).not.toHaveProperty('think') + } else { + expect(requestBody).toHaveProperty('think', think) + } }) // openaiShim test extraction seam 132 end @@ -6525,3 +6540,36 @@ test('JSON fallback: recovers Tencent HY3 text tool calls into tool_use blocks', expect(stopEvent?.delta?.stop_reason).toBe('tool_use') }) // openaiShim test extraction seam 204 end + +test.each([ + ['signed Vertex ID', [`toolu_vertex_k9x_3~~sig~~${'S'.repeat(1700)}`]], + ['punctuated IDs', ['call.one', 'call.two']], + ['valid IDs', ['call_one', 'call_two']], +] as const)('sanitizes and pairs distinct tool IDs for the chat/completions wire: %s', async (_label, ids) => { + const { __test } = await import('./openaiShim.ts') + + const messages = __test.convertMessages( + [ + { + role: 'assistant', + content: ids.map(id => ({ type: 'tool_use', id, name: 'Read', input: { file_path: '/tmp/x' } })), + }, + { + role: 'user', + content: ids.map(id => ({ type: 'tool_result', tool_use_id: id, content: 'done' })), + }, + ], + undefined, + ) + + const assistant = messages.find(m => m.role === 'assistant' && m.tool_calls?.length) + const callIds = assistant?.tool_calls?.map(call => call.id) ?? [] + expect(callIds).toHaveLength(ids.length) + expect(new Set(callIds).size).toBe(ids.length) + for (const id of callIds) { + expect(id.length).toBeLessThanOrEqual(40) + expect(id).toMatch(/^[A-Za-z0-9_-]+$/) + } + expect(messages.filter(m => m.role === 'tool').map(m => m.tool_call_id)).toEqual(callIds) + if (ids.every(id => /^[A-Za-z0-9_-]+$/.test(id))) expect(callIds).toEqual([...ids]) +}) diff --git a/src/services/api/openaiShim/messageConversion.ts b/src/services/api/openaiShim/messageConversion.ts index e268fd3806..96c6aa5cae 100644 --- a/src/services/api/openaiShim/messageConversion.ts +++ b/src/services/api/openaiShim/messageConversion.ts @@ -1,3 +1,6 @@ +import { sanitizeToolUseIdForWire } from '../../../utils/toolUseIds.js' + +const CHAT_TOOL_CALL_ID_MAX_LENGTH = 40 import { stripEchoedToolResultsMarker } from './markerEchoGuard.js' export type OpenAIContentPart = @@ -163,7 +166,7 @@ export function convertMessages( const content = (msg.message ?? msg).content if (!Array.isArray(content)) continue for (const block of content) { - if (block?.type === 'tool_result' && block.tool_use_id) toolResultIds.add(block.tool_use_id) + if (block?.type === 'tool_result' && block.tool_use_id) toolResultIds.add(sanitizeToolUseIdForWire(block.tool_use_id, CHAT_TOOL_CALL_ID_MAX_LENGTH)) } } @@ -188,7 +191,7 @@ export function convertMessages( otherContent.push(block) continue } - const id = block.tool_use_id ?? 'unknown' + const id = sanitizeToolUseIdForWire(block.tool_use_id ?? 'unknown', CHAT_TOOL_CALL_ID_MAX_LENGTH) if (knownToolCallIds.has(id)) { result.push({ role: 'tool', @@ -239,7 +242,7 @@ export function convertMessages( } const mappedToolCalls: NonNullable = [] for (const toolUse of toolUses) { - const id = toolUse.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}` + const id = toolUse.id ? sanitizeToolUseIdForWire(toolUse.id, CHAT_TOOL_CALL_ID_MAX_LENGTH) : `call_${crypto.randomUUID().replace(/-/g, '')}` if (!toolResultIds.has(id) && !isLastInHistory) continue knownToolCallIds.add(id) const toolCall: NonNullable[number] = { diff --git a/src/services/api/openaiShim/requestExecutor.integration.test.ts b/src/services/api/openaiShim/requestExecutor.integration.test.ts index 0ea2c25369..9776787b92 100644 --- a/src/services/api/openaiShim/requestExecutor.integration.test.ts +++ b/src/services/api/openaiShim/requestExecutor.integration.test.ts @@ -2048,6 +2048,30 @@ test('xiaomi mimo token plan uses raw api-key and OpenAI-compatible reasoning_ef expect(capturedBody).not.toHaveProperty('stream_options') }) +test.each([ + ['https://opencode.ai/zen/v1', 'minimax-m3.1-flash-free'], + ['https://opencode.ai/zen/v1', 'minimax-m3'], + ['https://openrouter.ai/api/v1', 'minimax/minimax-m3.1-flash:free'], +])('MiniMax gateway %s preserves the explicit model ID %s at HTTP boundary', async (baseURL, model) => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = baseURL + process.env.OPENAI_MODEL = model + process.env.OPENAI_API_KEY = 'gateway-fixture-key' + let capturedURL = '' + let body: Record = {} + globalThis.fetch = (async (input, init) => { + capturedURL = String(input) + body = JSON.parse(String(init?.body)) + return makeChatCompletionResponse(model) + }) as unknown as FetchType + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ model, messages: [{ role: 'user', content: 'hello' }], max_tokens: 32, stream: false }) + expect(capturedURL).toBe(`${baseURL}/chat/completions`) + expect(body.model).toBe(model) + expect(body.thinking).not.toEqual({ type: 'disabled' }) + expect(body.reasoning_effort).not.toBe('none') +}) + test.each([ 'minimax-m3', 'minimax-m2.7', diff --git a/src/services/api/openaiShim/requestExecutor.test.ts b/src/services/api/openaiShim/requestExecutor.test.ts index 52b962d86e..a9cc53d7ff 100644 --- a/src/services/api/openaiShim/requestExecutor.test.ts +++ b/src/services/api/openaiShim/requestExecutor.test.ts @@ -1,4 +1,7 @@ +import type Anthropic from '@anthropic-ai/sdk' import { APIError } from '@anthropic-ai/sdk' +import { snapshotEnv } from '../../../test/envSnapshot.js' +import { CannotRetryError, withRetry } from '../withRetry.js' import { afterEach, beforeEach, expect, jest, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../../test/sharedMutationLock.js' import { asMockFetch } from '../../../test/typedMocks.js' @@ -6,6 +9,7 @@ import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } f import { applyProviderFlag } from '../../../utils/providerFlag.ts' import { applyProviderProfileToProcessEnv } from '../../../utils/providerProfiles.ts' import { + categorizeRetryableAPIError, getAssistantMessageFromError, OPENCODE_GO_FREE_LIMIT_ERROR_MESSAGE, } from '../errors.ts' @@ -876,6 +880,202 @@ test('OPENAI_API_KEYS rotates to the next key on rate-limit failure', async () = expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b']) }) +test.each([ + { body: 'Too many requests, try again in 30s', retries: true }, + { body: 'Rate limit exceeded for this API key', retries: true }, + { body: 'Rate limit exceeded for this API key, streaming is disabled', retries: true }, + { body: 'Rate limit exceeded for this API key, currently streaming is disabled', retries: true }, + { body: 'Rate limit exceeded for this OAuth token', retries: true }, + { body: 'Too many requests; no tokens remaining in the rate limit bucket', retries: true }, + { body: 'Too many requests; empty token bucket', retries: true }, + { body: 'Too many requests; no tokens found in the rate limit bucket', retries: true }, + { body: 'Rate limit exceeded for this API key: bad gateway', retries: true }, + { body: 'Rate limit exceeded for this API key: upstream response is empty', retries: true }, + { body: 'Rate limit exceeded for this API key, bad gateway', retries: true }, + { body: 'Rate limit exceeded for this API key, empty response received', retries: true }, + { body: 'Token is invalid; too many requests', retries: false }, + { body: 'No API key was provided; too many requests', retries: false }, + { body: 'No valid API key found; too many requests', retries: false }, + { body: 'No API key found; too many requests', retries: false }, + { body: 'No token provided; rate limit exceeded', retries: false }, + { body: 'API key is empty; too many requests', retries: false }, + { body: '{"error":{"code":"bad_api_key","message":"Too many requests"}}', retries: false }, + { body: 'Too many requests; your API key is no longer valid', retries: false }, + { body: 'Too many requests; your API key does not exist', retries: false }, + { body: 'Too many requests; API key, which is no longer valid', retries: false }, + { body: 'API key, now empty; too many requests', retries: false }, + { body: 'Account disabled; too many requests', retries: false }, + { body: '{"error":{"code":"account_disabled","message":"Too many requests"}}', retries: false }, + { body: 'Key is invalid; too many requests', retries: false }, + { body: 'API key has been permanently disabled; too many requests', retries: false }, + { body: 'Token has been permanently disabled; too many requests', retries: false }, + { body: 'Rate limit exceeded; API key is invalid', retries: false }, + { body: 'Rate limit exceeded; API key\nis invalid', retries: false }, + { body: '{"error":{"code":"rate_limit_exceeded"}}', retries: true }, + { body: '', retries: false }, + { body: 'Try again later', retries: false }, + { body: 'Forbidden: too many requests', retries: false }, + { body: 'Quota exceeded; too many requests', retries: false }, + { body: 'Too many requests', retries: false, noRetry: true }, + { body: '{"message":"Too many requests","type":"overloaded_error"}', retries: true }, + { body: '{"message":"Too many requests","type":"overloaded_error"}', retries: false, noRetry: true }, + { body: 'Too many requests: {"type":"overloaded_error"}', retries: false, noRetry: true }, + { body: 'Too many requests: {"type":"overloaded_error"}', retries: false, noRetry: true, remote: true }, + { body: 'Too many requests', retries: false, noRetry: true, remote: true }, + { body: '{"error":{"code":"rate_limit_exceeded"}}', retries: false, noRetry: true, remote: true }, + { body: 'Too many requests', retries: true, remote: true }, + { body: 'Too many requests', retries: true, baseUrl: 'https://billing.example.com/v1' }, + { body: '{"error":{"code":"rate_limit_exceeded"}}', retries: true, baseUrl: 'https://billing.example.com/v1' }, + { body: 'Too many requests', retries: false, noRetry: true, baseUrl: 'https://billing.example.com/v1' }, + { body: 'Quota exceeded; too many requests', retries: false, baseUrl: 'https://billing.example.com/v1' }, + { body: 'Too many requests', retries: true, status: 429, baseUrl: 'https://billing.example.com/v1' }, + { body: 'Billing required; too many requests', retries: false, status: 429, baseUrl: 'https://billing.example.com/v1' }, +])('throttle responses through the shim and retry loop: %j', async ({ body, retries, noRetry, remote, baseUrl, status = 403 }) => { + const restoreRetryEnv = snapshotEnv(['OPENCLAUDE_RETRY_DELAY_MS', 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_REMOTE', 'OPENAI_BASE_URL']) + if (baseUrl) process.env.OPENAI_BASE_URL = baseUrl + if (remote) process.env.CLAUDE_CODE_REMOTE = '1' + else delete process.env.CLAUDE_CODE_REMOTE + process.env.OPENCLAUDE_RETRY_DELAY_MS = '1' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + let attempts = 0 + globalThis.fetch = asMockFetch(mock(async () => { + attempts++ + return attempts === 1 + ? new Response(body, { + status, + headers: noRetry ? { 'x-should-retry': 'false' } : {}, + }) + : makeChatCompletionResponse('gpt-5.5') + })) + const client = createOpenAIShimClient({}) as Anthropic + const countdowns: unknown[] = [] + const retryCategories: string[] = [] + let caught: unknown + let result: unknown + try { + const generator = withRetry( + async () => client, + api => api.beta.messages.create({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + { maxRetries: 2, model: 'gpt-5.5', thinkingConfig: { type: 'disabled' } }, + ) + while (true) { + const next = await generator.next() + if (next.done) { + result = next.value + break + } + countdowns.push(next.value) + // QueryEngine uses this same projection for public SDK api_retry events. + retryCategories.push(categorizeRetryableAPIError(next.value.error)) + } + } catch (error) { + caught = error + } finally { + restoreRetryEnv() + } + expect(attempts).toBe(retries ? 2 : 1) + expect(retryCategories).toEqual(retries ? ['rate_limit'] : []) + if (retries) { + expect(caught).toBeUndefined() + expect(result).toMatchObject({ content: [{ type: 'text', text: 'ok' }] }) + expect(countdowns).toEqual([expect.objectContaining({ + type: 'system', subtype: 'api_error', retryAttempt: 1, maxRetries: 2, + })]) + } else { + expect(caught).toBeInstanceOf(CannotRetryError) + expect(countdowns).toEqual([]) + if (/account|key|token/i.test(body)) { + const message = getAssistantMessageFromError((caught as CannotRetryError).originalError, 'gpt-5.5') + expect(JSON.stringify(message)).toContain('Authentication failed') + expect(JSON.stringify(message)).not.toContain('Provider rate limit reached') + } + } +}) + +test.each([ + 'Too many requests', + '{"error":{"code":"rate_limit_exceeded","message":"Too many requests"}}', +])('throttle-403 exhausts the bounded retry budget with rate-limit guidance: %s', async body => { + const restoreRetryEnv = snapshotEnv(['OPENCLAUDE_RETRY_DELAY_MS', 'CLAUDE_CODE_USE_OPENAI']) + process.env.OPENCLAUDE_RETRY_DELAY_MS = '1' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + let attempts = 0 + globalThis.fetch = asMockFetch(mock(async () => { + attempts++ + return new Response(body, { status: 403, headers: { 'request-id': 'throttle-request' } }) + })) + const client = createOpenAIShimClient({}) as Anthropic + let caught: unknown + try { + for await (const _ of withRetry( + async () => client, + api => api.beta.messages.create({ + model: 'gpt-5.5', messages: [{ role: 'user', content: 'hello' }], max_tokens: 32, + }), + { maxRetries: 2, model: 'gpt-5.5', thinkingConfig: { type: 'disabled' } }, + )) { /* drain countdowns */ } + } catch (error) { + caught = error + } finally { + restoreRetryEnv() + } + expect(attempts).toBe(3) + expect(caught).toBeInstanceOf(CannotRetryError) + const originalError = (caught as CannotRetryError).originalError as APIError + expect(originalError.status).toBe(403) + expect(originalError.requestID).toBe('throttle-request') + if (body.startsWith('{')) expect(originalError.error).toEqual(JSON.parse(body)) + const message = getAssistantMessageFromError(originalError, 'gpt-5.5') + expect(JSON.stringify(message)).toContain('Provider rate limit reached') + expect(JSON.stringify(message)).not.toContain('Authentication failed') +}) + +test.each([ + { body: 'Rate limit exceeded for this OAuth token', recoverable: true }, + { body: 'Rate limit exceeded for this API key, bad gateway', recoverable: true }, + { body: 'Account disabled; too many requests', recoverable: false }, + { body: 'No API key was provided; too many requests', recoverable: false }, + { body: 'No valid API key found; too many requests', recoverable: false }, + { body: 'Too many requests; API key, which is no longer valid', recoverable: false }, + { body: '{"error":{"code":"bad_api_key","message":"Too many requests"}}', recoverable: false }, + { body: '{"error":{"code":"account_deactivated","message":"Too many requests"}}', recoverable: false }, +])('throttle-403 credential recovery follows fault precedence: %j', async ({ body, recoverable }) => { + const restorePoolEnv = snapshotEnv(['CLAUDE_CODE_USE_OPENAI', 'OPENAI_API_KEYS', 'OPENAI_API_KEY']) + try { + jest.useFakeTimers() + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_KEYS = 'key-a,key-b' + delete process.env.OPENAI_API_KEY + const authorizations: Array = [] + globalThis.fetch = asMockFetch(mock(async (_input, init) => { + const headers = new Headers(init?.headers) + authorizations.push(headers.get('authorization')) + return authorizations.length === 1 + ? new Response(body, { status: 403 }) + : makeChatCompletionResponse('gpt-5.5') + })) + const client = createOpenAIShimClient({}) as Anthropic + const request = () => client.beta.messages.create({ + model: 'gpt-5.5', messages: [{ role: 'user', content: 'hello' }], max_tokens: 32, + }) + await request() + await request() + expect(authorizations).toEqual(['Bearer key-a', 'Bearer key-b', 'Bearer key-b']) + jest.advanceTimersByTime(30_001) + await request() + expect(authorizations).toEqual([ + 'Bearer key-a', 'Bearer key-b', 'Bearer key-b', recoverable ? 'Bearer key-a' : 'Bearer key-b', + ]) + } finally { + restorePoolEnv() + } +}) + test('GitHub 429 cools the active pooled credential before retrying', async () => { const authorizations: Array = [] diff --git a/src/services/api/openaiShim/requestExecutor.ts b/src/services/api/openaiShim/requestExecutor.ts index 5a07a28fd1..d2cc77b447 100644 --- a/src/services/api/openaiShim/requestExecutor.ts +++ b/src/services/api/openaiShim/requestExecutor.ts @@ -257,7 +257,8 @@ export async function executeOpenAIRequest( // behavior for unrelated providers or generic custom endpoints. const supportsConfiguredAuthHeaders = (runtimeShimContext.routeId !== 'llmtr' && - runtimeShimContext.routeId !== 'commandcode') || + runtimeShimContext.routeId !== 'commandcode' && + runtimeShimContext.routeId !== 'merge-gateway') || runtimeShimContext.openaiShimConfig.supportsAuthHeaders === true const unsupportedCustomHeaderNames = supportsConfiguredAuthHeaders ? null @@ -774,15 +775,22 @@ export async function executeOpenAIRequest( { level: 'warn' }, ) - throw APIError.generate( + const message = buildOpenAICompatibilityErrorMessage( + `OpenAI API error ${status}: ${safeErrorBody}${rateHint}`, + failureWithUrl, + ) + const apiError = APIError.generate( status, safeParsedBody, - buildOpenAICompatibilityErrorMessage( - `OpenAI API error ${status}: ${safeErrorBody}${rateHint}`, - failureWithUrl, - ), + message, headersWithRequestUrl(responseHeaders, requestUrl), ) + // The SDK prefers a JSON error body over the supplied message. Retain the + // throttle marker needed by withRetry without discarding the error payload. + if (status === 403 && failure.category === 'rate_limited') { + apiError.message = message + } + throw apiError } let response: Response | undefined @@ -1097,7 +1105,9 @@ export async function executeOpenAIRequest( const credentialFailureKind = failure.category === 'auth_invalid' && !failure.retryable ? 'auth' - : response.status === 402 || response.status === 429 + : response.status === 402 || + response.status === 429 || + (response.status === 403 && failure.category === 'rate_limited') ? 'cooldown' : null if (credentialPool && credentialPool.size > 1 && credentialFailureKind) { diff --git a/src/services/api/openaiShim/requestPlanner.ts b/src/services/api/openaiShim/requestPlanner.ts index 98eda33e1c..beec8490cb 100644 --- a/src/services/api/openaiShim/requestPlanner.ts +++ b/src/services/api/openaiShim/requestPlanner.ts @@ -71,6 +71,7 @@ type RequestBodyPlannerContext = { request: { resolvedModel: string reasoning?: { effort?: string } + thinking?: { type: 'enabled' | 'disabled' } } params: ShimRequestParams effectiveTransport: RequestTransport @@ -429,6 +430,10 @@ export function createRequestBodyPlanner(context: RequestBodyPlannerContext) { messages: normalizeOllamaNativeMessages(body.messages), stream: params.stream ?? false, options, + // Only an explicit model option overrides Ollama's model-specific default. + ...(request.thinking + ? { think: request.thinking.type === 'enabled' } + : {}), ...(body.tools ? { tools: body.tools } : {}), } } diff --git a/src/services/api/openaiShim/xmlToolCallParsing.test.ts b/src/services/api/openaiShim/xmlToolCallParsing.test.ts index 52ae3a7a55..15418f0800 100644 --- a/src/services/api/openaiShim/xmlToolCallParsing.test.ts +++ b/src/services/api/openaiShim/xmlToolCallParsing.test.ts @@ -15,6 +15,7 @@ * D. NAMEVALUE… */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { snapshotEnv } from '../../../test/envSnapshot.js' import { createOpenAIShimClient } from '../openaiShim.js' import { parseXmlToolCalls as parseXmlToolCallsModule, @@ -283,16 +284,17 @@ describe('parseXmlToolCalls', () => { describe('GLM streaming — XML tool calls', () => { let originalFetch: FetchType + let restoreEnv: () => void beforeEach(() => { originalFetch = globalThis.fetch + restoreEnv = snapshotEnv(['OPENAI_API_KEY', 'OPENAI_BASE_URL']) process.env.OPENAI_API_KEY = 'test-key' // Deliberately NOT an Ollama endpoint, so isOllama=false. process.env.OPENAI_BASE_URL = 'https://gateway.example.com/v1' }) afterEach(() => { globalThis.fetch = originalFetch - delete process.env.OPENAI_API_KEY - delete process.env.OPENAI_BASE_URL + restoreEnv() }) async function run( diff --git a/src/services/api/promptCacheBreakDetection.test.ts b/src/services/api/promptCacheBreakDetection.test.ts index 5ad9423e3e..4091fa707c 100644 --- a/src/services/api/promptCacheBreakDetection.test.ts +++ b/src/services/api/promptCacheBreakDetection.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' import type { QuerySource } from '../../constants/querySource.js' @@ -8,6 +8,7 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' +import { PROVIDER_SELECTION_FLAGS } from '../../utils/providerSelectionFlags.js' type PromptCacheBreakModule = typeof import('./promptCacheBreakDetection.js') type EventCall = { @@ -30,9 +31,23 @@ const logForDebuggingMock = mock( debugCalls.push({ message, options }) }, ) -const actualDebugModule = await import('../../utils/debug.js') +const nonce = `${Date.now()}-${Math.random()}` +const actualDebugModule = { + ...await import(`../../utils/debug.js?real=${nonce}`), +} +const actualAnalyticsModule = { + ...await import(`../analytics/index.js?real=${nonce}`), +} + +afterAll(() => { + mock.restore() + mock.module('../analytics/index.js', () => actualAnalyticsModule) + mock.module('src/utils/debug.js', () => actualDebugModule) + mock.module('../../utils/debug.js', () => actualDebugModule) +}) mock.module('../analytics/index.js', () => ({ + ...actualAnalyticsModule, logEvent: logEventMock, })) @@ -47,13 +62,7 @@ mock.module('../../utils/debug.js', () => ({ })) const PROVIDER_ENV_KEYS = [ - 'CLAUDE_CODE_USE_OPENAI', - 'CLAUDE_CODE_USE_GITHUB', - 'CLAUDE_CODE_USE_GEMINI', - 'CLAUDE_CODE_USE_MISTRAL', - 'CLAUDE_CODE_USE_BEDROCK', - 'CLAUDE_CODE_USE_VERTEX', - 'CLAUDE_CODE_USE_FOUNDRY', + ...PROVIDER_SELECTION_FLAGS, 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_MODEL', diff --git a/src/services/api/providerConfig.envDiagnostics.test.ts b/src/services/api/providerConfig.envDiagnostics.test.ts index cdfd424cce..1c6c4494c5 100644 --- a/src/services/api/providerConfig.envDiagnostics.test.ts +++ b/src/services/api/providerConfig.envDiagnostics.test.ts @@ -10,6 +10,7 @@ type DebugSpy = ReturnType< const originalEnv = { CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, + CLAUDE_CODE_USE_GEMINI_VERTEX: process.env.CLAUDE_CODE_USE_GEMINI_VERTEX, CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_MODEL: process.env.OPENAI_MODEL, @@ -18,6 +19,12 @@ const originalEnv = { MISTRAL_MODEL: process.env.MISTRAL_MODEL, GEMINI_BASE_URL: process.env.GEMINI_BASE_URL, GEMINI_MODEL: process.env.GEMINI_MODEL, + GEMINI_VERTEX_PROJECT: process.env.GEMINI_VERTEX_PROJECT, + GEMINI_VERTEX_LOCATION: process.env.GEMINI_VERTEX_LOCATION, + GEMINI_VERTEX_MODEL: process.env.GEMINI_VERTEX_MODEL, + GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT, + GCLOUD_PROJECT: process.env.GCLOUD_PROJECT, + GOOGLE_PROJECT_ID: process.env.GOOGLE_PROJECT_ID, } let actualDebugModule: DebugModule | undefined @@ -29,6 +36,19 @@ function restoreEnv(key: string, value: string | undefined): void { } } +type DebugCall = [string, { level?: string }?] + +function debugCalls(debugSpy: ReturnType): DebugCall[] { + return debugSpy.mock.calls as unknown as DebugCall[] +} + +function findDebugCall( + debugSpy: ReturnType, + predicate: (message: string) => boolean, +): DebugCall | undefined { + return debugCalls(debugSpy).find(([message]) => predicate(message)) +} + beforeEach(async () => { await acquireSharedMutationLock('providerConfig.envDiagnostics.test.ts') }) @@ -37,6 +57,7 @@ afterEach(() => { try { restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI) + restoreEnv('CLAUDE_CODE_USE_GEMINI_VERTEX', originalEnv.CLAUDE_CODE_USE_GEMINI_VERTEX) restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL) restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) @@ -45,6 +66,12 @@ afterEach(() => { restoreEnv('MISTRAL_MODEL', originalEnv.MISTRAL_MODEL) restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL) restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL) + restoreEnv('GEMINI_VERTEX_PROJECT', originalEnv.GEMINI_VERTEX_PROJECT) + restoreEnv('GEMINI_VERTEX_LOCATION', originalEnv.GEMINI_VERTEX_LOCATION) + restoreEnv('GEMINI_VERTEX_MODEL', originalEnv.GEMINI_VERTEX_MODEL) + restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT) + restoreEnv('GCLOUD_PROJECT', originalEnv.GCLOUD_PROJECT) + restoreEnv('GOOGLE_PROJECT_ID', originalEnv.GOOGLE_PROJECT_ID) mock.restore() restoreDebugModule() } finally { @@ -67,10 +94,9 @@ test('logs a warning when OPENAI_BASE_URL is literal undefined', async () => { expect(resolved.baseUrl).toBe('https://api.openai.com/v1') - const warningCall = debugSpy.mock.calls.find(call => - typeof call?.[0] === 'string' && - call[0].includes('OPENAI_BASE_URL') && - call[0].includes('"undefined"'), + const warningCall = findDebugCall( + debugSpy, + message => message.includes('OPENAI_BASE_URL') && message.includes('"undefined"'), ) expect(warningCall).toBeDefined() @@ -93,10 +119,9 @@ test('does not warn for OPENAI_API_BASE when OPENAI_BASE_URL is active', async ( expect(resolved.baseUrl).toBe('http://127.0.0.1:11434/v1') - const aliasWarning = debugSpy.mock.calls.find(call => - typeof call?.[0] === 'string' && - call[0].includes('OPENAI_API_BASE') && - call[0].includes('"undefined"'), + const aliasWarning = findDebugCall( + debugSpy, + message => message.includes('OPENAI_API_BASE') && message.includes('"undefined"'), ) expect(aliasWarning).toBeUndefined() @@ -140,6 +165,108 @@ test('uses descriptor-backed Gemini default model when GEMINI_MODEL is unset', a expect(resolved.baseUrl).toBe('https://generativelanguage.googleapis.com/v1beta/openai') }) +test('uses GEMINI_VERTEX_MODEL instead of OPENAI_MODEL in Gemini Vertex mode', async () => { + await mockDebugLogging() + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_MISTRAL + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.GEMINI_VERTEX_MODEL = 'gemini-vertex-custom' + delete process.env.OPENAI_API_BASE + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.requestedModel).toBe('gemini-vertex-custom') + expect(resolved.resolvedModel).toBe('gemini-vertex-custom') +}) + +test('uses current Gemini Vertex fallback model instead of OPENAI_MODEL when vertex model is unset', async () => { + await mockDebugLogging() + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_MISTRAL + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.OPENAI_MODEL = 'gpt-5.5' + delete process.env.GEMINI_VERTEX_MODEL + delete process.env.OPENAI_API_BASE + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.requestedModel).toBe('gemini-2.5-flash') + expect(resolved.resolvedModel).toBe('gemini-2.5-flash') +}) + +test('exposes Gemini Vertex project and location from dedicated env vars', async () => { + await mockDebugLogging() + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_MISTRAL + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GEMINI_VERTEX_PROJECT = 'vertex-project' + process.env.GEMINI_VERTEX_LOCATION = 'europe-west4' + delete process.env.OPENAI_API_BASE + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.vertexProject).toBe('vertex-project') + expect(resolved.vertexLocation).toBe('europe-west4') +}) + +test('falls back to Google project env vars for Gemini Vertex project', async () => { + await mockDebugLogging() + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_MISTRAL + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GOOGLE_CLOUD_PROJECT = 'project-test-1234' + process.env.GEMINI_VERTEX_LOCATION = 'us-central1' + delete process.env.GEMINI_VERTEX_PROJECT + delete process.env.OPENAI_API_BASE + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.vertexProject).toBe('project-test-1234') + expect(resolved.vertexLocation).toBe('us-central1') +}) + +test('defaults Gemini Vertex location to global when unset', async () => { + await mockDebugLogging() + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_MISTRAL + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + process.env.GOOGLE_CLOUD_PROJECT = 'project-test-1234' + delete process.env.GEMINI_VERTEX_PROJECT + delete process.env.GEMINI_VERTEX_LOCATION + delete process.env.OPENAI_API_BASE + + const nonce = `${Date.now()}-${Math.random()}` + const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`) + + const resolved = resolveProviderRequest() + + expect(resolved.vertexProject).toBe('project-test-1234') + expect(resolved.vertexLocation).toBe('global') +}) + async function mockDebugLogging(): Promise { actualDebugModule ??= await import( `../../utils/debug.ts?providerConfigEnvDiagnosticsActual=${Date.now()}-${Math.random()}` diff --git a/src/services/api/providerConfig.local.test.ts b/src/services/api/providerConfig.local.test.ts index bcbfd8e17e..a745fd2e82 100644 --- a/src/services/api/providerConfig.local.test.ts +++ b/src/services/api/providerConfig.local.test.ts @@ -333,6 +333,18 @@ test('skips local model cache scope for remote openai-compatible providers', () expect(getAdditionalModelOptionsCacheScope()).toBeNull() }) +test('does not classify Gemini Vertex sessions as first-party bootstrap traffic', () => { + // Maintainer P2: with only the Gemini Vertex flag set, the cache scope + // must be null (third-party), not 'firstParty' — otherwise bootstrap + // options are fetched through the Anthropic first-party path. + process.env.CLAUDE_CODE_USE_GEMINI_VERTEX = '1' + try { + expect(getAdditionalModelOptionsCacheScope()).toBeNull() + } finally { + delete process.env.CLAUDE_CODE_USE_GEMINI_VERTEX + } +}) + test('derives local retry base URLs with /v1 and loopback fallback candidates', () => { expect(getLocalProviderRetryBaseUrls('http://localhost:11434')).toEqual([ 'http://localhost:11434/v1', diff --git a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts index 14b62b2966..5eeaeeccb4 100644 --- a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts +++ b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts @@ -9,6 +9,11 @@ import { releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' +const nonce = `${Date.now()}-${Math.random()}` +const realCodexCredentials = { + ...await import(`../../utils/codexCredentials.js?real=${nonce}`), +} as typeof import('../../utils/codexCredentials.js') + type ProviderConfigModule = typeof import('./providerConfig.js') function importFreshProviderConfig( @@ -26,6 +31,7 @@ beforeEach(async () => { afterEach(() => { try { mock.restore() + mock.module('../../utils/codexCredentials.js', () => realCodexCredentials) } finally { releaseSharedMutationLock() } diff --git a/src/services/api/providerConfig.test.ts b/src/services/api/providerConfig.test.ts index ade2445311..76a3b5867b 100644 --- a/src/services/api/providerConfig.test.ts +++ b/src/services/api/providerConfig.test.ts @@ -461,6 +461,68 @@ test('resolveProviderRequest resolves the GPT-5.6 family Codex aliases', () => { expect(bare.reasoning).toEqual({ effort: 'high' }) }) +test('resolveProviderRequest pairs bare Codex-alias launches with the Codex backend endpoint', () => { + // --provider openai --model with no base URL anywhere resolves + // through OPENAI_MODEL; the codex_responses transport must not end up + // paired with the public OpenAI fallback URL (#2066). + for (const model of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.5']) { + const request = resolveProviderRequest({ + processEnv: { CLAUDE_CODE_USE_OPENAI: '1', OPENAI_MODEL: model }, + }) + expect(request.requestedModel).toBe(model) + expect(request.transport).toBe('codex_responses') + expect(request.baseUrl).toBe('https://chatgpt.com/backend-api/codex') + } +}) + +test('resolveProviderRequest keeps an explicit default base URL on the direct route for Codex aliases', () => { + const request = resolveProviderRequest({ + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-5.6-terra', + }, + }) + + expect(request.transport).toBe('responses') + expect(request.baseUrl).toBe('https://api.openai.com/v1') +}) + +test('resolveProviderRequest promotes [1m]-tagged Codex aliases identically to untagged ones', () => { + for (const baseUrl of [undefined, 'https://api.openai.com/v1', 'https://gateway.example/v1']) { + for (const model of ['gpt-5.6-sol', 'gpt-5.5', 'codexplan', 'codexspark']) { + const processEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + OPENAI_MODEL: `${model}?reasoning=medium`, + } + const untagged = resolveProviderRequest({ processEnv }) + for (const taggedModel of [`${model}[1m]?reasoning=medium`, `${model}?reasoning=medium[1m]`]) { + const request = resolveProviderRequest({ + processEnv: { ...processEnv, OPENAI_MODEL: taggedModel }, + }) + expect(request.requestedModel).toBe(taggedModel) + expect(request.baseUrl).toBe(untagged.baseUrl) + expect(request.transport).toBe(untagged.transport) + expect(request.resolvedModel).toBe(untagged.resolvedModel) + expect(request.reasoning).toEqual({ effort: 'medium' }) + } + const tagged = resolveProviderRequest({ + processEnv: { ...processEnv, OPENAI_MODEL: `${model}[1m]` }, + }) + const plain = resolveProviderRequest({ + processEnv: { ...processEnv, OPENAI_MODEL: model }, + }) + expect(tagged).toMatchObject({ + baseUrl: plain.baseUrl, + transport: plain.transport, + resolvedModel: plain.resolvedModel, + reasoning: plain.reasoning, + }) + } + } +}) + test('resolveProviderRequest keeps the implicit Codex fallback on Sol with high reasoning', () => { expect(resolveProviderRequest({ processEnv: {} })).toMatchObject({ requestedModel: 'codexplan', diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index c783c790f4..d0b4b2ec54 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -11,6 +11,7 @@ import { } from '../../utils/codexCredentials.js' import { logForDebugging } from '../../utils/debug.js' import { isEnvTruthy } from '../../utils/envUtils.js' +import { PROVIDER_SELECTION_FLAGS } from '../../utils/providerSelectionFlags.js' import { asTrimmedString, parseChatgptAccountId, @@ -30,6 +31,12 @@ import { isClinePassBaseUrl, } from '../../integrations/routeMetadata.js' import { hasUsableOpenAICredential } from './credentialPool.js' +import { + getGeminiVertexLocation, + getGeminiVertexModel, + getGeminiVertexProjectId, + DEFAULT_GEMINI_VERTEX_MODEL, +} from '../../utils/geminiAuth.js' import { openAIShimSupportsApiFormatForModel, resolveOpenAIShimRuntimeContext, @@ -167,6 +174,8 @@ export type ResolvedProviderRequest = { requestedModel: string resolvedModel: string baseUrl: string + vertexProject?: string + vertexLocation?: string reasoning?: { effort: ReasoningEffort } @@ -382,7 +391,11 @@ export function parseOpenAICompatibleApiFormat( return undefined } -function parseModelDescriptor(model: string): ModelDescriptor { +function parseModelIdentity(model: string): { + raw: string + baseModel: string + query: string | undefined +} { // A trailing [1m] suffix is the client-side 1M-context opt-in (see // has1mContext) — never part of the wire model id or the ?query syntax. // Strip it before parsing so tagged aliases keep their mapping and effort @@ -391,14 +404,25 @@ function parseModelDescriptor(model: string): ModelDescriptor { // on the base id (`gpt-5.5[1m]`); both forms are handled. const trimmed = model.trim().replace(/\[1m]$/i, '').trim() const queryIndex = trimmed.indexOf('?') - if (queryIndex === -1) { - const alias = trimmed.toLowerCase() as CodexAlias + return { + raw: trimmed, + baseModel: (queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex)) + .trim() + .replace(/\[1m]$/i, ''), + query: queryIndex === -1 ? undefined : trimmed.slice(queryIndex + 1), + } +} + +function parseModelDescriptor(model: string): ModelDescriptor { + const { raw, baseModel, query } = parseModelIdentity(model) + if (query === undefined) { + const alias = baseModel.toLowerCase() as CodexAlias const aliasConfig = Object.hasOwn(CODEX_ALIAS_MODELS, alias) ? CODEX_ALIAS_MODELS[alias] : undefined if (aliasConfig) { return { - raw: trimmed, + raw, baseModel: aliasConfig.model, reasoning: aliasConfig.reasoningEffort ? { effort: aliasConfig.reasoningEffort } @@ -407,16 +431,12 @@ function parseModelDescriptor(model: string): ModelDescriptor { } } return { - raw: trimmed, - baseModel: trimmed, + raw, + baseModel, } } - const baseModel = trimmed - .slice(0, queryIndex) - .trim() - .replace(/\[1m]$/i, '') - const params = new URLSearchParams(trimmed.slice(queryIndex + 1)) + const params = new URLSearchParams(query) const alias = baseModel.toLowerCase() as CodexAlias const aliasConfig = Object.hasOwn(CODEX_ALIAS_MODELS, alias) ? CODEX_ALIAS_MODELS[alias] @@ -431,7 +451,7 @@ function parseModelDescriptor(model: string): ModelDescriptor { const thinking = parseThinkingType(params.get('thinking') ?? undefined) return { - raw: trimmed, + raw, baseModel: resolvedBaseModel, reasoning: typeof reasoning === 'string' ? { effort: reasoning } : reasoning, reasoningFromAlias: @@ -441,15 +461,13 @@ function parseModelDescriptor(model: string): ModelDescriptor { } export function isCodexAlias(model: string): boolean { - const normalized = model.trim().toLowerCase() - const base = normalized.split('?', 1)[0] ?? normalized - return Object.hasOwn(CODEX_ALIAS_MODELS, base) + const { baseModel } = parseModelIdentity(model) + return Object.hasOwn(CODEX_ALIAS_MODELS, baseModel.toLowerCase()) } function isOpenAICodexShortcutAlias(model: string): boolean { - const normalized = model.trim().toLowerCase() - const base = normalized.split('?', 1)[0] ?? normalized - return OPENAI_CODEX_SHORTCUT_ALIASES.has(base) + const { baseModel } = parseModelIdentity(model) + return OPENAI_CODEX_SHORTCUT_ALIASES.has(baseModel.toLowerCase()) } export function shouldUseCodexTransport( @@ -944,7 +962,9 @@ export function resolveProviderRequest(options?: { const processEnv = options?.processEnv ?? process.env const isGithubMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB) const isMistralMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL) - const isGeminiMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI) + const isGeminiVertexMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI_VERTEX) + const isGeminiMode = isGeminiVertexMode || isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI) + const geminiVertexModel = getGeminiVertexModel(processEnv) const isClinePassMode = Boolean(processEnv.CLINE_API_KEY?.trim()) const isApismartMode = hasUsableOpenAICredential(processEnv.APISMART_API_KEY) const explicitBaseUrl = asEnvUrl(options?.baseUrl) @@ -1020,7 +1040,7 @@ export function resolveProviderRequest(options?: { (isMistralMode ? processEnv.MISTRAL_MODEL?.trim() : isGeminiMode - ? processEnv.GEMINI_MODEL?.trim() + ? (isGeminiVertexMode ? geminiVertexModel : processEnv.GEMINI_MODEL?.trim()) : effectiveClinePassMode ? processEnv.CLINE_API_MODEL?.trim() || processEnv.OPENAI_MODEL?.trim() @@ -1029,6 +1049,7 @@ export function resolveProviderRequest(options?: { asUsableModelEnvValue(processEnv.OPENAI_MODEL) : processEnv.OPENAI_MODEL?.trim()) || options?.fallbackModel?.trim() || + (isGeminiVertexMode ? DEFAULT_GEMINI_VERTEX_MODEL : undefined) || (isGeminiMode ? DEFAULT_GEMINI_MODEL : undefined) || clinePassDefaultModel || apismartDefaultModel || @@ -1070,9 +1091,19 @@ export function resolveProviderRequest(options?: { descriptor.baseModel === envResolvedCodexModel const isCodexAliasModel = isOpenAICodexShortcutAlias(requestedModel) || requestedMatchesEnvCodexShortcut + // A non-shortcut Codex alias launched with no endpoint identity anywhere + // still selects the codex_responses transport (shouldUseCodexTransport + // treats every alias as Codex once the base URL is absent), leaving it + // paired with the public OpenAI fallback URL (#2066). Promote bare-alias + // launches to the Codex backend so transport and baseUrl describe one + // route. An explicit base URL — even set to the literal default value — + // still pins the direct OpenAI route. + const isBareCodexAliasLaunch = + !isGithubMode && !isCodexAliasModel && !rawBaseUrl && isCodexAlias(requestedModel) const hasUserSetBaseUrl = rawBaseUrl && rawBaseUrl !== DEFAULT_OPENAI_BASE_URL const finalBaseUrlRaw = - !isGithubMode && isCodexAliasModel && !hasUserSetBaseUrl + !isGithubMode && + ((isCodexAliasModel && !hasUserSetBaseUrl) || isBareCodexAliasLaunch) ? DEFAULT_CODEX_BASE_URL : rawBaseUrl const finalBaseUrl = normalizeGitlawbOpengatewayBaseUrl(finalBaseUrlRaw) @@ -1227,6 +1258,8 @@ export function resolveProviderRequest(options?: { ? GITHUB_COPILOT_BASE_URL : DEFAULT_OPENAI_BASE_URL)))) ).replace(/\/+$/, ''), + vertexProject: isGeminiVertexMode ? getGeminiVertexProjectId(processEnv) : undefined, + vertexLocation: isGeminiVertexMode ? getGeminiVertexLocation(processEnv) : undefined, reasoning, thinking: descriptor.thinking, } @@ -1234,12 +1267,11 @@ export function resolveProviderRequest(options?: { export function getAdditionalModelOptionsCacheScope(): string | null { if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { - if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) && - !isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL) && - !isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) && - !isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && - !isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && - !isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) { + const anotherProviderSelected = PROVIDER_SELECTION_FLAGS.some( + flag => + flag !== 'CLAUDE_CODE_USE_OPENAI' && isEnvTruthy(process.env[flag]), + ) + if (!anotherProviderSelected) { return 'firstParty' } return null diff --git a/src/services/api/withRetry.test.ts b/src/services/api/withRetry.test.ts index 78cc503249..13552f2a11 100644 --- a/src/services/api/withRetry.test.ts +++ b/src/services/api/withRetry.test.ts @@ -3,8 +3,11 @@ import type Anthropic from '@anthropic-ai/sdk' import { APIError, APIUserAbortError } from '@anthropic-ai/sdk' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import * as debugNs from '../../utils/debug.js' +import * as sleepNs from '../../utils/sleep.js' import { markOpenAIRequestNonReplayable } from './openaiErrorClassification.js' +import { PROVIDER_SELECTION_FLAGS } from '../../utils/providerSelectionFlags.js' type ProvidersModule = typeof import('../../utils/model/providers.js') +type FastModeModule = typeof import('../../utils/fastMode.js') // Helper to build a mock APIError with specific headers function makeError(headers: Record): APIError { @@ -20,16 +23,14 @@ function makeError(headers: Record): APIError { // Save/restore env vars between tests const originalEnv = { ...process.env } const originalDebugModule = { ...debugNs } +const originalSleepModule = { ...sleepNs } let originalProvidersModule: ProvidersModule | undefined +let originalFastModeModule: FastModeModule | undefined const envKeys = [ - 'CLAUDE_CODE_USE_OPENAI', - 'CLAUDE_CODE_USE_GEMINI', - 'CLAUDE_CODE_USE_GITHUB', - 'CLAUDE_CODE_USE_BEDROCK', - 'CLAUDE_CODE_USE_VERTEX', - 'CLAUDE_CODE_USE_FOUNDRY', + ...PROVIDER_SELECTION_FLAGS, 'CLAUDE_CODE_UNATTENDED_RETRY', + 'CLAUDE_CODE_REMOTE', 'CLAUDE_CODE_MAX_RETRIES', 'OPENCLAUDE_MAX_RETRIES', 'OPENCLAUDE_RETRY_DELAY_MS', @@ -56,15 +57,22 @@ afterEach(() => { mock.module('src/utils/model/providers.js', () => originalProvidersModule!) } mock.module('src/utils/debug.js', () => originalDebugModule) + mock.module('src/utils/sleep.js', () => originalSleepModule) + if (originalFastModeModule) { + mock.module('src/utils/fastMode.js', () => originalFastModeModule!) + } } finally { releaseSharedMutationLock() } }) async function importActualProviders(): Promise { - return import( - `../../utils/model/providers.ts?withRetryActual=${Date.now()}-${Math.random()}` - ) + // Bun repoints module namespaces when mocked; keep the original exports. + return { + ...await import( + `../../utils/model/providers.ts?withRetryActual=${Date.now()}-${Math.random()}` + ), + } } async function importFreshWithRetryModule( @@ -102,9 +110,11 @@ async function importFreshWithRetryModule( usesAnthropicAccountFlow: () => false, })) if (options.forceFastMode) { - const realFastMode = await import('../../utils/fastMode.js') + originalFastModeModule ??= { + ...(await import('../../utils/fastMode.js')), + } mock.module('src/utils/fastMode.js', () => ({ - ...realFastMode, + ...originalFastModeModule!, isFastModeEnabled: () => true, })) } @@ -344,6 +354,25 @@ describe('abort retry classification', () => { }) describe('OpenAI-compatible retry classification', () => { + test.each([401, 403])('remote native auth %i still retries despite a no-retry header', async status => { + process.env.CLAUDE_CODE_REMOTE = '1' + process.env.OPENCLAUDE_RETRY_DELAY_MS = '1' + const { withRetry } = await importFreshWithRetryModule('firstParty') + let attempts = 0 + const result = await drainAsyncGenerator(withRetry( + async () => ({} as Anthropic), + async () => { + if (++attempts === 1) { + throw APIError.generate(status, undefined, 'Infrastructure auth unavailable', new Headers({ 'x-should-retry': 'false' })) + } + return 'ok' + }, + { maxRetries: 1, model: 'test-model', thinkingConfig: { type: 'disabled' } }, + )) + expect(result).toBe('ok') + expect(attempts).toBe(2) + }) + test('does not retry request timeouts marked as non-replayable', async () => { process.env.OPENCLAUDE_RETRY_DELAY_MS = '1' const { CannotRetryError, withRetry } = diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index 757460c1c7..187db1536e 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -124,7 +124,11 @@ function isPersistentRetryEnabled(): boolean { function isQuotaExhausted(error: unknown): boolean { const errObj = error as { message?: unknown; status?: unknown } | null | undefined - const msg = String(errObj?.message ?? '').toLowerCase() + // Routing metadata is not evidence of provider quota exhaustion. Keep the + // category and provider text, without changing the original error message. + const msg = String(errObj?.message ?? '') + .replace(/(\[openai_category=[a-z_]+),host=[A-Za-z0-9.\-:]+]/g, '$1]') + .toLowerCase() const status = errObj?.status // OpenRouter (and other gateways) return 402 with instructions to adjust max_tokens. @@ -895,6 +899,12 @@ function shouldRetry(error: APIError, persistentRetryEnabled: boolean): boolean return false } + // Decide classified provider throttles before native auth/overload + // shortcuts, which can intentionally bypass the normal header gate. + if (error.status === 403 && openAICategory === 'rate_limited') { + return error.headers?.get('x-should-retry') !== 'false' && !isQuotaExhausted(error) + } + // CCR mode: auth is via infrastructure-provided JWTs, so a 401/403 is a // transient blip (auth service flap, network hiccup) rather than bad // credentials. Bypass x-should-retry:false — the server assumes we'd retry diff --git a/src/services/compact/autoCompact.test.ts b/src/services/compact/autoCompact.test.ts index bbb09ca0c1..65d8e316b6 100644 --- a/src/services/compact/autoCompact.test.ts +++ b/src/services/compact/autoCompact.test.ts @@ -12,7 +12,11 @@ import { releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' import type { Message } from '../../types/message.js' -import * as realConfig from '../../utils/config.js' + +const nonce = `${Date.now()}-${Math.random()}` +const realConfig = { + ...await import(`../../utils/config.js?real=${nonce}`), +} as typeof import('../../utils/config.js') const realContext = await import( `../../utils/context.js?real=${Date.now()}-${Math.random()}` diff --git a/src/services/compact/compact.test.ts b/src/services/compact/compact.test.ts index 5b391c8210..668be01257 100644 --- a/src/services/compact/compact.test.ts +++ b/src/services/compact/compact.test.ts @@ -6,6 +6,7 @@ import { describe, expect, mock, + spyOn, test, vi, } from 'bun:test' @@ -363,9 +364,23 @@ export type CompactMockOptions = { * mocks that the provider gate test specifically depends on; all others are * DEFENSIVE (prevent transitive import / side-effect / I/O failures). */ -function registerCommonCompactStubs(options: CompactMockOptions = {}) { +async function registerCommonCompactStubs(options: CompactMockOptions = {}) { mock.restore() + const [config, path, settings, model] = await Promise.all([ + import('../../utils/config.js'), + import('../../utils/path.js'), + import('../../utils/settings/settings.js'), + import('../../utils/model/model.js'), + ]) + spyOn(config, 'getMemoryPath').mockReturnValue('/tmp/memory') + if (options.getGlobalConfig) { + spyOn(config, 'getGlobalConfig').mockImplementation(options.getGlobalConfig) + } + spyOn(path, 'expandPath').mockImplementation((p: string) => p) + spyOn(settings, 'getInitialSettings').mockReturnValue({}) + spyOn(model, 'getCanonicalName').mockImplementation((m: string) => m) + // --- Provider gate control --- // The isAnthropicProvider() gate is exercised via environment variables // (e.g. CLAUDE_CODE_USE_OPENAI=1) instead of mock.module() on betas.ts. @@ -510,15 +525,6 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { groupMessagesByApiRound: mock((msgs: Message[]) => [msgs]), })) - // --- Config (DEFENSIVE) --- - mock.module('../../utils/config.js', () => ({ - ..._realConfigModule, - getMemoryPath: mock(() => '/tmp/memory'), - ...(options.getGlobalConfig - ? { getGlobalConfig: options.getGlobalConfig } - : {}), - })) - // --- File state cache (DEFENSIVE) --- mock.module('../../utils/fileStateCache.js', () => ({ cacheToObject: mock(() => ({})), @@ -555,12 +561,6 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { getPlanFilePath: mock(() => '/tmp/plan'), })) - // --- Path (DEFENSIVE) --- - mock.module('../../utils/path.js', () => ({ - ..._realPathModule, - expandPath: mock((p: string) => p), - })) - // --- Sleep (DEFENSIVE) --- mock.module('../../utils/sleep.js', () => ({ sleep: mock(async () => {}), @@ -646,18 +646,6 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { get3PModelCapabilityOverride: mock(() => undefined), })) - // --- Settings (DEFENSIVE) --- - mock.module('../../utils/settings/settings.js', () => ({ - ..._realSettingsModule, - getInitialSettings: mock(() => ({})), - })) - - // --- Model (DEFENSIVE) --- - mock.module('../../utils/model/model.js', () => ({ - ..._realModelModule, - getCanonicalName: mock((m: string) => m), - })) - return { runForkedAgent } } @@ -682,7 +670,7 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { * trigger hundreds of failed transitive resolution steps. */ async function importCompact(options: CompactMockOptions = {}) { - const { runForkedAgent } = registerCommonCompactStubs(options) + const { runForkedAgent } = await registerCommonCompactStubs(options) // Dynamic import with cache-busting so each test gets fresh module state const nonce = `${Date.now()}-${Math.random()}` @@ -820,6 +808,143 @@ afterAll(async () => { } }) +describe('compact media stripping', () => { + test('strips top-level images and documents without mutating user messages', async () => { + const { stripImagesFromMessages } = await import( + `./compact.ts?test=${Date.now()}-${Math.random()}` + ) + const message = { + type: 'user', + uuid: randomUUID(), + timestamp: '2026-10-21T00:00:00.000Z', + origin: { kind: 'human' }, + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'text', text: 'Summarize these attachments.' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'aW1hZ2U=' }, + }, + { + type: 'document', + source: { type: 'text', media_type: 'text/plain', data: 'document' }, + }, + ], + }, + } satisfies Message + const original = structuredClone(message) + + expect(stripImagesFromMessages([message])).toEqual([ + { + ...original, + message: { + ...original.message, + content: [ + { type: 'text', text: 'Summarize these attachments.' }, + { type: 'text', text: '[image]' }, + { type: 'text', text: '[document]' }, + ], + }, + }, + ]) + expect(message).toEqual(original) + expect(stripImagesFromMessages([])).toEqual([]) + + const stringMessage = userMessage('plain text') + const textOnlyMessage = { + ...message, + message: { role: 'user', content: [{ type: 'text', text: 'only text' }] }, + } satisfies Message + const assistant = assistantMessage('assistant text') + const unaffected = [stringMessage, textOnlyMessage, assistant] + const stripped = stripImagesFromMessages(unaffected) + expect(stripped).toEqual(unaffected) + for (const [index, unchanged] of unaffected.entries()) { + expect(stripped[index]).toBe(unchanged) + } + }) + + test('strips media inside tool results while preserving tool metadata and text', async () => { + const { stripImagesFromMessages } = await import( + `./compact.ts?test=${Date.now()}-${Math.random()}` + ) + const message = { + type: 'user', + uuid: randomUUID(), + timestamp: '2026-10-21T00:00:00.000Z', + sourceToolUseID: 'toolu_media', + toolUseResult: { status: 'failed' }, + message: { + role: 'user', + content: [ + { type: 'text', text: 'before the tool result' }, + { + type: 'tool_result', + tool_use_id: 'toolu_media', + is_error: true, + content: [ + { type: 'text', text: 'tool text' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'aW1hZ2U=' }, + }, + { + type: 'document', + source: { type: 'text', media_type: 'text/plain', data: 'document' }, + }, + { type: 'text', text: 'trailing tool text' }, + ], + }, + { type: 'tool_result', tool_use_id: 'toolu_string', content: 'string result' }, + { type: 'tool_result', tool_use_id: 'toolu_empty', content: [] }, + { + type: 'tool_result', + tool_use_id: 'toolu_text', + content: [{ type: 'text', text: 'text-only result' }], + }, + { type: 'text', text: 'after the tool result' }, + ], + }, + } satisfies Message + const original = structuredClone(message) + const stripped = stripImagesFromMessages([message])[0] as typeof message + + expect(stripped).toEqual({ + ...original, + message: { + ...original.message, + content: [ + original.message.content[0], + { + type: 'tool_result', + tool_use_id: 'toolu_media', + is_error: true, + content: [ + { type: 'text', text: 'tool text' }, + { type: 'text', text: '[image]' }, + { type: 'text', text: '[document]' }, + { type: 'text', text: 'trailing tool text' }, + ], + }, + ...original.message.content.slice(2), + ], + }, + }) + expect(message).toEqual(original) + for (const index of [0, 2, 3, 4, 5]) { + expect(stripped.message.content[index]).toBe(message.message.content[index]) + } + + const unchanged = { + ...message, + message: { ...message.message, content: message.message.content.slice(2) }, + } + expect(stripImagesFromMessages([unchanged])[0]).toBe(unchanged) + }) +}) + describe('compactConversation provider gate', () => { test('attributes cache-sharing timeout aborts to the compact fork', async () => { const originalTrace = process.env.OPENCLAUDE_INTERRUPT_TRACE diff --git a/src/services/compact/resumeCompactPrompt.test.ts b/src/services/compact/resumeCompactPrompt.test.ts index eb32bec466..14a2de8e99 100644 --- a/src/services/compact/resumeCompactPrompt.test.ts +++ b/src/services/compact/resumeCompactPrompt.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +const realTokens = { + ...await import(`../../utils/tokens.js?real=${Date.now()}-${Math.random()}`), +} +const realAutoCompact = { + ...await import(`./autoCompact.js?real=${Date.now()}-${Math.random()}`), +} + async function importResumeCompactPrompt() { mock.restore() const nonce = `${Date.now()}-${Math.random()}` @@ -15,6 +22,8 @@ describe('shouldPromptCompactOnResume', () => { afterEach(() => { mock.restore() + mock.module('../../utils/tokens.js', () => realTokens) + mock.module('./autoCompact.js', () => realAutoCompact) }) test('returns false for empty messages', async () => { diff --git a/src/services/contextCollapse/spawnCtxAgent.test.ts b/src/services/contextCollapse/spawnCtxAgent.test.ts index a7af0e852f..792a12df4d 100644 --- a/src/services/contextCollapse/spawnCtxAgent.test.ts +++ b/src/services/contextCollapse/spawnCtxAgent.test.ts @@ -14,21 +14,29 @@ import type { Message } from '../../types/message.js' // which imports that function and uses it to size truncation. Restoring by // specifier is safe: autoCompact.test.ts re-imports autoCompact fresh via a // cache-busting nonce (a different specifier), so it is unaffected. -import * as spanSelectionNs from './spanSelection.js' -import * as forkedAgentNs from '../../utils/forkedAgent.js' -import * as tokensNs from '../../utils/tokens.js' -import * as autoCompactNs from '../compact/autoCompact.js' -import * as analyticsNs from '../../services/analytics/index.js' -import * as logNs from '../../utils/log.js' -import * as messagesNs from '../../utils/messages.js' -const realSpanSelection = { ...spanSelectionNs } -const realForkedAgent = { ...forkedAgentNs } -const realTokens = { ...tokensNs } -const realAutoCompact = { ...autoCompactNs } -const realAnalytics = { ...analyticsNs } -const realLog = { ...logNs } -const realMessages = { ...messagesNs } +const nonce = `${Date.now()}-${Math.random()}` +const realSpanSelection = { + ...await import(`./spanSelection.js?real=${nonce}`), +} as typeof import('./spanSelection.js') +const realForkedAgent = { + ...await import(`../../utils/forkedAgent.js?real=${nonce}`), +} as typeof import('../../utils/forkedAgent.js') +const realTokens = { + ...await import(`../../utils/tokens.js?real=${nonce}`), +} as typeof import('../../utils/tokens.js') +const realAutoCompact = { + ...await import(`../compact/autoCompact.js?real=${nonce}`), +} as typeof import('../compact/autoCompact.js') +const realAnalytics = { + ...await import(`../../services/analytics/index.js?real=${nonce}`), +} as typeof import('../../services/analytics/index.js') +const realLog = { + ...await import(`../../utils/log.js?real=${nonce}`), +} as typeof import('../../utils/log.js') +const realMessages = { + ...await import(`../../utils/messages.js?real=${nonce}`), +} as typeof import('../../utils/messages.js') // Build a transcript big enough to yield a collapsible span. function bigTranscript(): Message[] { diff --git a/src/services/extractMemories/extractMemories.abort.test.ts b/src/services/extractMemories/extractMemories.abort.test.ts index 86b38c435b..2b8c003432 100644 --- a/src/services/extractMemories/extractMemories.abort.test.ts +++ b/src/services/extractMemories/extractMemories.abort.test.ts @@ -42,6 +42,64 @@ function createContext(messageUuids: string | string[]): any { } } +function registerExtractMemoriesStubs({ + runForkedAgent, + debugLog, + events, +}: { + runForkedAgent: ReturnType + debugLog: ReturnType void>> + events: string[] +}) { + mock.module('../../bootstrap/state.js', () => ({ + ...realBootstrapState, + getIsRemoteMode: () => false, + })) + mock.module('../../memdir/memoryScan.js', () => ({ + ...realMemoryScan, + formatMemoryManifest: () => 'No memories yet.', + scanMemoryFiles: async () => [], + })) + mock.module('../../memdir/paths.js', () => ({ + ...realPaths, + getAutoMemPath: () => '/tmp/memory-extraction-test', + isAutoMemoryEnabled: () => true, + isAutoMemPath: () => false, + })) + mock.module('../../utils/governancePolicy.js', () => ({ + ...realGovernance, + isMemoryWriteApprovalRequired: () => false, + })) + mock.module('../../utils/debug.js', () => ({ + ...realDebug, + logForDebugging: debugLog, + })) + mock.module('../../utils/forkedAgent.js', () => ({ + ...realForkedAgent, + createCacheSafeParams: (context: any) => ({ + systemPrompt: context.systemPrompt, + userContext: context.userContext, + systemContext: context.systemContext, + toolUseContext: context.toolUseContext, + forkContextMessages: context.messages, + }), + runForkedAgent, + })) + mock.module('../analytics/growthbook.js', () => ({ + ...realGrowthbook, + getFeatureValue_CACHED_MAY_BE_STALE: ( + key: string, + defaultValue: unknown, + ) => (key === 'tengu_passport_quail' ? true : defaultValue), + })) + mock.module('../analytics/index.js', () => ({ + ...realAnalytics, + logEvent: (name: string) => { + events.push(name) + }, + })) +} + describe('extractMemories cancellation', () => { beforeEach(async () => { await acquireSharedMutationLock('extractMemories.abort.test.ts') @@ -94,53 +152,7 @@ describe('extractMemories cancellation', () => { }) }) - mock.module('../../bootstrap/state.js', () => ({ - ...realBootstrapState, - getIsRemoteMode: () => false, - })) - mock.module('../../memdir/memoryScan.js', () => ({ - ...realMemoryScan, - formatMemoryManifest: () => 'No memories yet.', - scanMemoryFiles: async () => [], - })) - mock.module('../../memdir/paths.js', () => ({ - ...realPaths, - getAutoMemPath: () => '/tmp/memory-extraction-test', - isAutoMemoryEnabled: () => true, - isAutoMemPath: () => false, - })) - mock.module('../../utils/governancePolicy.js', () => ({ - ...realGovernance, - isMemoryWriteApprovalRequired: () => false, - })) - mock.module('../../utils/debug.js', () => ({ - ...realDebug, - logForDebugging: debugLog, - })) - mock.module('../../utils/forkedAgent.js', () => ({ - ...realForkedAgent, - createCacheSafeParams: (context: any) => ({ - systemPrompt: context.systemPrompt, - userContext: context.userContext, - systemContext: context.systemContext, - toolUseContext: context.toolUseContext, - forkContextMessages: context.messages, - }), - runForkedAgent, - })) - mock.module('../analytics/growthbook.js', () => ({ - ...realGrowthbook, - getFeatureValue_CACHED_MAY_BE_STALE: ( - key: string, - defaultValue: unknown, - ) => (key === 'tengu_passport_quail' ? true : defaultValue), - })) - mock.module('../analytics/index.js', () => ({ - ...realAnalytics, - logEvent: (name: string) => { - events.push(name) - }, - })) + registerExtractMemoriesStubs({ runForkedAgent, debugLog, events }) const { executeExtractMemories, initExtractMemories } = await import( `./extractMemories.js?ts=${Date.now()}-${Math.random()}` @@ -202,53 +214,7 @@ describe('extractMemories cancellation', () => { }) }) - mock.module('../../bootstrap/state.js', () => ({ - ...realBootstrapState, - getIsRemoteMode: () => false, - })) - mock.module('../../memdir/memoryScan.js', () => ({ - ...realMemoryScan, - formatMemoryManifest: () => 'No memories yet.', - scanMemoryFiles: async () => [], - })) - mock.module('../../memdir/paths.js', () => ({ - ...realPaths, - getAutoMemPath: () => '/tmp/memory-extraction-test', - isAutoMemoryEnabled: () => true, - isAutoMemPath: () => false, - })) - mock.module('../../utils/governancePolicy.js', () => ({ - ...realGovernance, - isMemoryWriteApprovalRequired: () => false, - })) - mock.module('../../utils/debug.js', () => ({ - ...realDebug, - logForDebugging: debugLog, - })) - mock.module('../../utils/forkedAgent.js', () => ({ - ...realForkedAgent, - createCacheSafeParams: (context: any) => ({ - systemPrompt: context.systemPrompt, - userContext: context.userContext, - systemContext: context.systemContext, - toolUseContext: context.toolUseContext, - forkContextMessages: context.messages, - }), - runForkedAgent, - })) - mock.module('../analytics/growthbook.js', () => ({ - ...realGrowthbook, - getFeatureValue_CACHED_MAY_BE_STALE: ( - key: string, - defaultValue: unknown, - ) => (key === 'tengu_passport_quail' ? true : defaultValue), - })) - mock.module('../analytics/index.js', () => ({ - ...realAnalytics, - logEvent: (name: string) => { - events.push(name) - }, - })) + registerExtractMemoriesStubs({ runForkedAgent, debugLog, events }) const { executeExtractMemories, initExtractMemories } = await import( `./extractMemories.js?ts=${Date.now()}-${Math.random()}` @@ -307,53 +273,7 @@ describe('extractMemories cancellation', () => { }) }) - mock.module('../../bootstrap/state.js', () => ({ - ...realBootstrapState, - getIsRemoteMode: () => false, - })) - mock.module('../../memdir/memoryScan.js', () => ({ - ...realMemoryScan, - formatMemoryManifest: () => 'No memories yet.', - scanMemoryFiles: async () => [], - })) - mock.module('../../memdir/paths.js', () => ({ - ...realPaths, - getAutoMemPath: () => '/tmp/memory-extraction-test', - isAutoMemoryEnabled: () => true, - isAutoMemPath: () => false, - })) - mock.module('../../utils/governancePolicy.js', () => ({ - ...realGovernance, - isMemoryWriteApprovalRequired: () => false, - })) - mock.module('../../utils/debug.js', () => ({ - ...realDebug, - logForDebugging: debugLog, - })) - mock.module('../../utils/forkedAgent.js', () => ({ - ...realForkedAgent, - createCacheSafeParams: (context: any) => ({ - systemPrompt: context.systemPrompt, - userContext: context.userContext, - systemContext: context.systemContext, - toolUseContext: context.toolUseContext, - forkContextMessages: context.messages, - }), - runForkedAgent, - })) - mock.module('../analytics/growthbook.js', () => ({ - ...realGrowthbook, - getFeatureValue_CACHED_MAY_BE_STALE: ( - key: string, - defaultValue: unknown, - ) => (key === 'tengu_passport_quail' ? true : defaultValue), - })) - mock.module('../analytics/index.js', () => ({ - ...realAnalytics, - logEvent: (name: string) => { - events.push(name) - }, - })) + registerExtractMemoriesStubs({ runForkedAgent, debugLog, events }) const { executeExtractMemories, initExtractMemories } = await import( `./extractMemories.js?ts=${Date.now()}-${Math.random()}` diff --git a/src/services/lsp/LSPClient.test.ts b/src/services/lsp/LSPClient.test.ts new file mode 100644 index 0000000000..ae9cb5d717 --- /dev/null +++ b/src/services/lsp/LSPClient.test.ts @@ -0,0 +1,494 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, jest, mock, test } from 'bun:test' +import type { ChildProcess } from 'child_process' +import type { MessageConnection } from 'vscode-jsonrpc/node' +import { + createLSPClient, + type LSPClientDependencies, +} from './LSPClient.js' + +type FakeConnection = { + connection: MessageConnection + notificationMethods: string[] + requestMethods: string[] + sentRequestMethods: string[] + emitNotification(method: string, params: unknown): void + emitRequest(method: string, params: unknown): Promise + emitError(error: Error): void + emitClose(): void +} + +type FakeConnectionOptions = { + failTransportDuringRequest?: { method: string; error: Error } + pendingRequest?: string + rejectNotification?: string + rejectRequest?: string + retainHandlersOnDispose?: boolean +} + +function createFakeProcess(autoSpawn = true): ChildProcess { + const child = new EventEmitter() as ChildProcess + Object.assign(child, { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: mock(() => true), + }) + if (autoSpawn) queueMicrotask(() => child.emit('spawn')) + return child +} + +const spawnFakeProcess = (() => createFakeProcess()) as + LSPClientDependencies['spawnProcess'] + +function createFakeConnection( + options: FakeConnectionOptions = {}, +): FakeConnection { + const notificationMethods: string[] = [] + const requestMethods: string[] = [] + const sentRequestMethods: string[] = [] + let errorHandler: ((error: [Error, unknown, number]) => void) | undefined + let closeHandler: (() => void) | undefined + let pendingRequestReject: ((error: Error) => void) | undefined + const notificationHandlers = new Map< + string, + (params: unknown) => void + >() + const requestHandlers = new Map< + string, + (params: unknown) => unknown | Promise + >() + + const connection = { + listen: mock(() => {}), + trace: mock(async () => {}), + onError: mock((handler: (error: [Error, unknown, number]) => void) => { + errorHandler = handler + }), + onClose: mock((handler: () => void) => { + closeHandler = handler + }), + onNotification: mock((method: string, handler: (params: unknown) => void) => { + notificationMethods.push(method) + notificationHandlers.set(method, handler) + }), + onRequest: mock( + ( + method: string, + handler: (params: unknown) => unknown | Promise, + ) => { + requestMethods.push(method) + requestHandlers.set(method, handler) + }, + ), + sendRequest: mock(async (method: string) => { + sentRequestMethods.push(method) + if (method === options.pendingRequest) { + return await new Promise((_resolve, reject) => { + pendingRequestReject = reject + }) + } + if (method === options.rejectRequest) { + throw new Error('request rejected') + } + if (method === options.failTransportDuringRequest?.method) { + queueMicrotask(() => { + errorHandler?.([ + options.failTransportDuringRequest!.error, + undefined, + 0, + ]) + }) + } + return method === 'initialize' ? { capabilities: {} } : null + }), + sendNotification: mock(async (method: string) => { + if (method === options.rejectNotification) { + throw new Error('writer rejected') + } + }), + dispose: mock(() => { + pendingRequestReject?.(new Error('connection disposed')) + pendingRequestReject = undefined + if (!options.retainHandlersOnDispose) { + errorHandler = undefined + closeHandler = undefined + } + }), + } as unknown as MessageConnection + + return { + connection, + notificationMethods, + requestMethods, + sentRequestMethods, + emitNotification(method, params) { + notificationHandlers.get(method)?.(params) + }, + async emitRequest(method, params) { + const handler = requestHandlers.get(method) + if (!handler) throw new Error(`No request handler registered for ${method}`) + return await handler(params) + }, + emitError(error) { + errorHandler?.([error, undefined, 0]) + }, + emitClose() { + closeHandler?.() + }, + } +} + +function initializeParams() { + return { + processId: process.pid, + rootUri: null, + capabilities: {}, + } +} + +describe('LSP client transport lifecycle', () => { + test('best-effort notifications preserve lifecycle preflight errors', async () => { + const fakeConnection = createFakeConnection() + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: () => fakeConnection.connection, + }) + + await expect( + client.sendNotification('textDocument/didSave', {}), + ).rejects.toThrow('LSP client not started') + + await client.start('unused', []) + await client.initialize(initializeParams()) + fakeConnection.emitError(new Error('connection failed')) + + await expect( + client.sendNotification('textDocument/didSave', {}), + ).rejects.toThrow('connection failed') + await client.stop() + }) + + test('strict notifications reject while best-effort notifications keep resolving', async () => { + const fakeConnection = createFakeConnection({ + rejectNotification: 'textDocument/didOpen', + }) + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: () => fakeConnection.connection, + }) + + await client.start('unused', []) + await client.initialize(initializeParams()) + + await expect( + client.sendNotificationStrict('textDocument/didOpen', {}), + ).rejects.toThrow('writer rejected') + await expect( + client.sendNotification('textDocument/didOpen', {}), + ).resolves.toBeUndefined() + + await client.stop() + }) + + test('every unexpected transport termination enters crash recovery once', async () => { + const createStartedClient = async () => { + const child = createFakeProcess() + const fakeConnection = createFakeConnection() + const onCrash = mock((_error: Error) => {}) + const client = createLSPClient('typescript', onCrash, { + spawnProcess: (() => child) as LSPClientDependencies['spawnProcess'], + createConnection: () => fakeConnection.connection, + }) + await client.start('unused', []) + await client.initialize(initializeParams()) + return { child, client, fakeConnection, onCrash } + } + + type StartedClient = Awaited> + const scenarios: Array<{ + name: string + terminate: (started: StartedClient) => void + }> = [ + { + name: 'JSON-RPC error', + terminate: ({ fakeConnection }) => + fakeConnection.emitError(new Error('connection failed')), + }, + { + name: 'JSON-RPC close', + terminate: ({ fakeConnection }) => fakeConnection.emitClose(), + }, + { + name: 'child-process error', + terminate: ({ child }) => + child.emit('error', new Error('process transport error')), + }, + { + name: 'clean child exit', + terminate: ({ child }) => child.emit('exit', 0, null), + }, + { + name: 'signal child exit', + terminate: ({ child }) => child.emit('exit', null, 'SIGTERM'), + }, + { + name: 'nonzero child exit', + terminate: ({ child }) => child.emit('exit', 1, null), + }, + ] + + for (const scenario of scenarios) { + const started = await createStartedClient() + scenario.terminate(started) + + expect(started.client.isInitialized, scenario.name).toBe(false) + expect(started.onCrash, scenario.name).toHaveBeenCalledTimes(1) + expect(started.child.kill, scenario.name).toHaveBeenCalledTimes(1) + + started.fakeConnection.emitClose() + started.child.emit('exit', 1, null) + expect(started.onCrash, scenario.name).toHaveBeenCalledTimes(1) + await started.client.stop({ force: true }) + } + }) + + test('ignores a stale close callback after connection replacement', async () => { + const connections: FakeConnection[] = [] + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: () => { + const fake = createFakeConnection({ retainHandlersOnDispose: true }) + connections.push(fake) + return fake.connection + }, + }) + + await client.start('unused', []) + await client.initialize(initializeParams()) + await client.start('unused', []) + await client.initialize(initializeParams()) + + connections[0]?.emitClose() + expect(client.isInitialized).toBe(true) + + await client.stop() + }) + + test('starts after an overlapping shutdown request fails', async () => { + const shutdownErrorConnection = createFakeConnection({ + rejectRequest: 'shutdown', + }) + const replacementConnection = createFakeConnection() + const connections = [shutdownErrorConnection, replacementConnection] + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: () => connections.shift()!.connection, + }) + + await client.start('unused', []) + await client.initialize(initializeParams()) + + const stopOutcome = client.stop().then( + () => undefined, + error => error, + ) + const restart = client.start('unused', []) + + expect(await stopOutcome).toEqual(new Error('request rejected')) + await expect(restart).resolves.toBeUndefined() + await client.initialize(initializeParams()) + expect(client.isInitialized).toBe(true) + + await client.stop() + }) + + test('force stop skips the graceful shutdown request', async () => { + const fakeConnection = createFakeConnection() + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: () => fakeConnection.connection, + }) + + await client.start('unused', []) + await client.initialize(initializeParams()) + await client.stop({ force: true }) + + expect(fakeConnection.sentRequestMethods).toEqual(['initialize']) + }) + + test('force stop supersedes an in-flight graceful shutdown', async () => { + const child = createFakeProcess() + const fakeConnection = createFakeConnection({ pendingRequest: 'shutdown' }) + const client = createLSPClient('typescript', undefined, { + spawnProcess: (() => child) as LSPClientDependencies['spawnProcess'], + createConnection: () => fakeConnection.connection, + }) + + await client.start('unused', []) + await client.initialize(initializeParams()) + const gracefulStop = client.stop() + await Promise.resolve() + + const forcedStop = client.stop({ force: true }) + const outcome = await Promise.race([ + forcedStop.then(() => 'stopped' as const), + new Promise<'timed-out'>(resolve => + setTimeout(() => resolve('timed-out'), 50), + ), + ]) + + try { + expect(outcome).toBe('stopped') + expect(child.kill).toHaveBeenCalledTimes(1) + } finally { + fakeConnection.connection.dispose() + await Promise.allSettled([gracefulStop, forcedStop]) + } + }) + + test('bounds an unanswered graceful shutdown exchange', async () => { + jest.useFakeTimers() + const child = createFakeProcess() + const fakeConnection = createFakeConnection({ pendingRequest: 'shutdown' }) + const client = createLSPClient('typescript', undefined, { + spawnProcess: (() => child) as LSPClientDependencies['spawnProcess'], + createConnection: () => fakeConnection.connection, + gracefulShutdownTimeoutMs: 5, + }) + + await client.start('unused', []) + await client.initialize(initializeParams()) + const gracefulStop = client.stop() + + try { + jest.advanceTimersByTime(5) + await expect(gracefulStop).resolves.toBeUndefined() + expect(child.kill).toHaveBeenCalledTimes(1) + } finally { + fakeConnection.connection.dispose() + jest.runAllTimers() + jest.useRealTimers() + await gracefulStop.catch(() => {}) + } + }) + + test('an immediate stop settles an in-flight spawn wait', async () => { + const child = createFakeProcess(false) + const client = createLSPClient('typescript', undefined, { + spawnProcess: (() => child) as LSPClientDependencies['spawnProcess'], + }) + + const start = client.start('unused', []) + await client.stop({ force: true }) + + await expect(start).rejects.toThrow('cancelled during spawn') + expect(() => + child.emit('error', new Error('late missing-command ENOENT')), + ).not.toThrow() + }) + + test('transport failure rejects a pending initialization', async () => { + const fakeConnection = createFakeConnection({ pendingRequest: 'initialize' }) + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: () => fakeConnection.connection, + }) + + await client.start('unused', []) + const initialize = client.initialize(initializeParams()) + fakeConnection.emitError(new Error('connection lost')) + + try { + await expect(initialize).rejects.toThrow('connection disposed') + expect(client.isInitialized).toBe(false) + } finally { + await client.stop({ force: true }).catch(() => {}) + await initialize.catch(() => {}) + } + }) + + test('transport failure cannot be overwritten by initialization success', async () => { + const transportError = new Error('late transport failure') + const fakeConnection = createFakeConnection({ + failTransportDuringRequest: { + method: 'initialize', + error: transportError, + }, + }) + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: () => fakeConnection.connection, + }) + + await client.start('unused', []) + try { + await expect(client.initialize(initializeParams())).rejects.toBe( + transportError, + ) + expect(client.isInitialized).toBe(false) + } finally { + await client.stop().catch(() => {}) + } + }) + + test('reinstalls registered handlers on a replacement connection', async () => { + const connections: FakeConnection[] = [] + const nextConnection = () => { + const fake = createFakeConnection() + connections.push(fake) + return fake.connection + } + const client = createLSPClient('typescript', undefined, { + spawnProcess: spawnFakeProcess, + createConnection: nextConnection, + }) + + const notificationHandler = mock((_params: unknown) => {}) + const requestHandler = mock((params: unknown) => ({ configured: params })) + client.onNotification( + 'textDocument/publishDiagnostics', + notificationHandler, + ) + client.onRequest('workspace/configuration', requestHandler) + + await client.start('unused', []) + await client.initialize(initializeParams()) + connections[0]?.emitNotification('textDocument/publishDiagnostics', { + generation: 1, + }) + expect( + await connections[0]?.emitRequest('workspace/configuration', { + generation: 1, + }), + ).toEqual({ configured: { generation: 1 } }) + await client.stop() + await client.start('unused', []) + await client.initialize(initializeParams()) + connections[1]?.emitNotification('textDocument/publishDiagnostics', { + generation: 2, + }) + expect( + await connections[1]?.emitRequest('workspace/configuration', { + generation: 2, + }), + ).toEqual({ configured: { generation: 2 } }) + + expect(connections).toHaveLength(2) + expect(connections[0]?.notificationMethods).toEqual([ + 'textDocument/publishDiagnostics', + ]) + expect(connections[1]?.notificationMethods).toEqual([ + 'textDocument/publishDiagnostics', + ]) + expect(connections[0]?.requestMethods).toEqual(['workspace/configuration']) + expect(connections[1]?.requestMethods).toEqual(['workspace/configuration']) + expect(notificationHandler).toHaveBeenCalledTimes(2) + expect(notificationHandler).toHaveBeenNthCalledWith(1, { generation: 1 }) + expect(notificationHandler).toHaveBeenNthCalledWith(2, { generation: 2 }) + expect(requestHandler).toHaveBeenCalledTimes(2) + + await client.stop() + }) +}) diff --git a/src/services/lsp/LSPClient.ts b/src/services/lsp/LSPClient.ts index 5ac8509cb9..10a90d9fce 100644 --- a/src/services/lsp/LSPClient.ts +++ b/src/services/lsp/LSPClient.ts @@ -5,7 +5,7 @@ import { StreamMessageReader, StreamMessageWriter, Trace, -} from 'vscode-jsonrpc/node.js' +} from 'vscode-jsonrpc/node' import type { InitializeParams, InitializeResult, @@ -32,26 +32,41 @@ export type LSPClient = { initialize: (params: InitializeParams) => Promise sendRequest: (method: string, params: unknown) => Promise sendNotification: (method: string, params: unknown) => Promise + sendNotificationStrict: (method: string, params: unknown) => Promise onNotification: (method: string, handler: (params: unknown) => void) => void onRequest: ( method: string, handler: (params: TParams) => TResult | Promise, ) => void - stop: () => Promise + stop: (options?: { force?: boolean }) => Promise +} + +export type LSPClientDependencies = { + spawnProcess: typeof spawn + createConnection: typeof createMessageConnection + gracefulShutdownTimeoutMs: number +} + +const DEFAULT_DEPENDENCIES: LSPClientDependencies = { + spawnProcess: spawn, + createConnection: createMessageConnection, + gracefulShutdownTimeoutMs: 2_000, } /** * Create an LSP client wrapper using vscode-jsonrpc. * Manages communication with an LSP server process via stdio. * - * @param onCrash - Called when the server process exits unexpectedly (non-zero - * exit code during operation, not during intentional stop). Allows the owner - * to propagate crash state so the server can be restarted on next use. + * @param onCrash - Called when the active process or JSON-RPC connection becomes + * unavailable outside intentional shutdown. Allows the owner to propagate + * crash state so the server can be restarted on next use. */ export function createLSPClient( serverName: string, onCrash?: (error: Error) => void, + dependencyOverrides: Partial = {}, ): LSPClient { + const dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencyOverrides } // State variables in closure let process: ChildProcess | undefined let connection: MessageConnection | undefined @@ -60,12 +75,19 @@ export function createLSPClient( let startFailed = false let startError: Error | undefined let isStopping = false // Track intentional shutdown to avoid spurious error logging - // Queue handlers registered before connection ready (lazy initialization support) - const pendingHandlers: Array<{ + let unavailableReported = false + let stopPromise: Promise | undefined + let stopMode: 'graceful' | 'force' | undefined + let forceCurrentStop: (() => void) | undefined + let spawnWait: + | { process: ChildProcess; reject: (error: Error) => void } + | undefined + // Retain handlers for the client lifetime so replacement connections receive them. + const notificationHandlers: Array<{ method: string handler: (params: unknown) => void }> = [] - const pendingRequestHandlers: Array<{ + const requestHandlers: Array<{ method: string handler: (params: unknown) => unknown | Promise }> = [] @@ -76,6 +98,82 @@ export function createLSPClient( } } + function reportUnavailable(error: Error): void { + if (isStopping || unavailableReported) return + unavailableReported = true + isInitialized = false + capabilities = undefined + startFailed = true + startError = error + const unavailableConnection = connection + const unavailableProcess = process + connection = undefined + process = undefined + disposeResources(unavailableConnection, unavailableProcess) + onCrash?.(error) + } + + function disposeResources( + targetConnection: MessageConnection | undefined, + targetProcess: ChildProcess | undefined, + ): void { + if (targetConnection) { + try { + targetConnection.dispose() + } catch (error) { + logForDebugging( + `Connection disposal failed for ${serverName}: ${errorMessage(error)}`, + ) + } + } + + if (!targetProcess) return + const activeSpawnWait = spawnWait + const cancelledDuringSpawn = activeSpawnWait?.process === targetProcess + if (cancelledDuringSpawn) { + activeSpawnWait.reject( + new Error(`LSP server ${serverName} start was cancelled during spawn`), + ) + } + targetProcess.removeAllListeners('error') + targetProcess.removeAllListeners('exit') + if (cancelledDuringSpawn) { + // spawn() may report a real launch error after an immediate force-stop. + // Keep a one-shot sink after detaching the cancelled start listener so + // that late error cannot become an uncaught EventEmitter error. + targetProcess.once('error', () => {}) + } + targetProcess.stdin?.removeAllListeners('error') + targetProcess.stderr?.removeAllListeners('data') + try { + targetProcess.kill() + } catch (error) { + logForDebugging( + `Process kill failed for ${serverName} (may already be dead): ${errorMessage(error)}`, + ) + } + } + + async function sendNotificationStrict( + method: string, + params: unknown, + ): Promise { + checkStartFailed() + if (!connection) { + throw new Error('LSP client not started') + } + + try { + await connection.sendNotification(method, params) + } catch (error) { + const notificationError = new Error( + `LSP server ${serverName} notification ${method} failed: ${errorMessage(error)}`, + ) + logError(notificationError) + throw notificationError + } + } + return { get capabilities(): ServerCapabilities | undefined { return capabilities @@ -93,17 +191,37 @@ export function createLSPClient( cwd?: string }, ): Promise { + if (stopPromise) await stopPromise.catch(() => {}) + + // A crashed transport may leave handles behind even though its owner has + // already marked the server unavailable. Detach those handles before a + // replacement is installed so they cannot leak across generations. + if (connection || process) { + const staleConnection = connection + const staleProcess = process + connection = undefined + process = undefined + disposeResources(staleConnection, staleProcess) + } + try { + unavailableReported = false + startFailed = false + startError = undefined + isInitialized = false + capabilities = undefined + // 1. Spawn LSP server process - process = spawn(command, args, { + const spawnedProcess = dependencies.spawnProcess(command, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...subprocessEnv(), ...options?.env }, cwd: options?.cwd, // Prevent visible console window on Windows (no-op on other platforms) windowsHide: true, }) + process = spawnedProcess - if (!process.stdout || !process.stdin) { + if (!spawnedProcess.stdout || !spawnedProcess.stdin) { throw new Error('LSP server process stdio not available') } @@ -112,27 +230,28 @@ export function createLSPClient( // (e.g., ENOENT for command not found) fires asynchronously. // If we use the streams before confirming spawn succeeded, we get // unhandled promise rejections when writes fail on invalid streams. - const spawnedProcess = process // Capture for closure await new Promise((resolve, reject) => { - const onSpawn = (): void => { + const resolveWait = (): void => { cleanup() resolve() } - const onError = (error: Error): void => { + const rejectWait = (error: Error): void => { cleanup() reject(error) } const cleanup = (): void => { - spawnedProcess.removeListener('spawn', onSpawn) - spawnedProcess.removeListener('error', onError) + spawnedProcess.removeListener('spawn', resolveWait) + spawnedProcess.removeListener('error', rejectWait) + if (spawnWait?.process === spawnedProcess) spawnWait = undefined } - spawnedProcess.once('spawn', onSpawn) - spawnedProcess.once('error', onError) + spawnWait = { process: spawnedProcess, reject: rejectWait } + spawnedProcess.once('spawn', resolveWait) + spawnedProcess.once('error', rejectWait) }) // Capture stderr for server diagnostics and errors - if (process.stderr) { - process.stderr.on('data', (data: Buffer) => { + if (spawnedProcess.stderr) { + spawnedProcess.stderr.on('data', (data: Buffer) => { const output = data.toString().trim() if (output) { logForDebugging(`[LSP SERVER ${serverName}] ${output}`) @@ -141,35 +260,31 @@ export function createLSPClient( } // Handle process errors (after successful spawn, e.g., crash during operation) - process.on('error', error => { - if (!isStopping) { - startFailed = true - startError = error - logError( - new Error( - `LSP server ${serverName} failed to start: ${error.message}`, - ), - ) - } + spawnedProcess.on('error', error => { + if (process !== spawnedProcess || isStopping) return + reportUnavailable(error) + logError( + new Error( + `LSP server ${serverName} failed to start: ${error.message}`, + ), + ) }) - process.on('exit', (code, _signal) => { - if (code !== 0 && code !== null && !isStopping) { - isInitialized = false - startFailed = false - startError = undefined - const crashError = new Error( - `LSP server ${serverName} crashed with exit code ${code}`, - ) - logError(crashError) - onCrash?.(crashError) - } + spawnedProcess.on('exit', (code, signal) => { + if (process !== spawnedProcess || isStopping) return + const exitDetail = + code !== null ? `exit code ${code}` : `signal ${signal ?? 'unknown'}` + const crashError = new Error( + `LSP server ${serverName} exited unexpectedly with ${exitDetail}`, + ) + logError(crashError) + reportUnavailable(crashError) }) // Handle stdin stream errors to prevent unhandled promise rejections // when the LSP server process exits before we finish writing - process.stdin.on('error', (error: Error) => { - if (!isStopping) { + spawnedProcess.stdin.on('error', (error: Error) => { + if (process === spawnedProcess && !isStopping) { logForDebugging( `LSP server ${serverName} stdin error: ${error.message}`, ) @@ -178,42 +293,41 @@ export function createLSPClient( }) // 2. Create JSON-RPC connection - const reader = new StreamMessageReader(process.stdout) - const writer = new StreamMessageWriter(process.stdin) - connection = createMessageConnection(reader, writer) + const reader = new StreamMessageReader(spawnedProcess.stdout) + const writer = new StreamMessageWriter(spawnedProcess.stdin) + const startedConnection = dependencies.createConnection(reader, writer) + connection = startedConnection // 2.5. Register error/close handlers BEFORE listen() to catch all errors // This prevents unhandled promise rejections when the server crashes or closes unexpectedly - connection.onError(([error, _message, _code]) => { - // Only log if not intentionally stopping (avoid spurious errors during shutdown) - if (!isStopping) { - startFailed = true - startError = error - logError( - new Error( - `LSP server ${serverName} connection error: ${error.message}`, - ), - ) - } + startedConnection.onError(([error, _message, _code]) => { + if (connection !== startedConnection || isStopping) return + reportUnavailable(error) + logError( + new Error( + `LSP server ${serverName} connection error: ${error.message}`, + ), + ) }) - connection.onClose(() => { + startedConnection.onClose(() => { + if (connection !== startedConnection || isStopping) return // Only treat as error if not intentionally stopping - if (!isStopping) { - isInitialized = false - // Don't set startFailed here - the connection may close after graceful shutdown - logForDebugging(`LSP server ${serverName} connection closed`) - } + const closeError = new Error( + `LSP server ${serverName} connection closed unexpectedly`, + ) + logForDebugging(closeError.message) + reportUnavailable(closeError) }) // 3. Start listening for messages - connection.listen() + startedConnection.listen() // 3.5. Enable protocol tracing for debugging // Note: trace() sends a $/setTrace notification which can fail if the server // process has already exited. We catch and log the error rather than letting // it become an unhandled promise rejection. - connection + startedConnection .trace(Trace.Verbose, { log: (message: string) => { logForDebugging(`[LSP PROTOCOL ${serverName}] ${message}`) @@ -225,23 +339,21 @@ export function createLSPClient( ) }) - // 4. Apply any queued notification handlers - for (const { method, handler } of pendingHandlers) { - connection.onNotification(method, handler) + // 4. Apply all retained notification handlers to this connection + for (const { method, handler } of notificationHandlers) { + startedConnection.onNotification(method, handler) logForDebugging( - `Applied queued notification handler for ${serverName}.${method}`, + `Applied notification handler for ${serverName}.${method}`, ) } - pendingHandlers.length = 0 // Clear the queue - // 5. Apply any queued request handlers - for (const { method, handler } of pendingRequestHandlers) { - connection.onRequest(method, handler) + // 5. Apply all retained request handlers to this connection + for (const { method, handler } of requestHandlers) { + startedConnection.onRequest(method, handler) logForDebugging( - `Applied queued request handler for ${serverName}.${method}`, + `Applied request handler for ${serverName}.${method}`, ) } - pendingRequestHandlers.length = 0 // Clear the queue logForDebugging(`LSP client started for ${serverName}`) } catch (error) { @@ -254,23 +366,35 @@ export function createLSPClient( }, async initialize(params: InitializeParams): Promise { + checkStartFailed() if (!connection) { throw new Error('LSP client not started') } - - checkStartFailed() + const initializingConnection = connection try { - const result: InitializeResult = await connection.sendRequest( + const result: InitializeResult = await initializingConnection.sendRequest( 'initialize', params, ) - - capabilities = result.capabilities + checkStartFailed() + if (connection !== initializingConnection) { + throw new Error( + `LSP server ${serverName} connection changed during initialization`, + ) + } // Send initialized notification - await connection.sendNotification('initialized', {}) + await initializingConnection.sendNotification('initialized', {}) + checkStartFailed() + if (connection !== initializingConnection) { + throw new Error( + `LSP server ${serverName} connection changed during initialization`, + ) + } + + capabilities = result.capabilities isInitialized = true logForDebugging(`LSP server ${serverName} initialized`) @@ -290,12 +414,11 @@ export function createLSPClient( method: string, params: unknown, ): Promise { + checkStartFailed() if (!connection) { throw new Error('LSP client not started') } - checkStartFailed() - if (!isInitialized) { throw new Error('LSP server not initialized') } @@ -314,12 +437,11 @@ export function createLSPClient( }, async sendNotification(method: string, params: unknown): Promise { + checkStartFailed() if (!connection) { throw new Error('LSP client not started') } - checkStartFailed() - try { await connection.sendNotification(method, params) } catch (error) { @@ -334,10 +456,11 @@ export function createLSPClient( } }, + sendNotificationStrict, + onNotification(method: string, handler: (params: unknown) => void): void { + notificationHandlers.push({ method, handler }) if (!connection) { - // Queue handler for application when connection is ready (lazy initialization) - pendingHandlers.push({ method, handler }) logForDebugging( `Queued notification handler for ${serverName}.${method} (connection not ready)`, ) @@ -353,12 +476,11 @@ export function createLSPClient( method: string, handler: (params: TParams) => TResult | Promise, ): void { + requestHandlers.push({ + method, + handler: handler as (params: unknown) => unknown | Promise, + }) if (!connection) { - // Queue handler for application when connection is ready (lazy initialization) - pendingRequestHandlers.push({ - method, - handler: handler as (params: unknown) => unknown | Promise, - }) logForDebugging( `Queued request handler for ${serverName}.${method} (connection not ready)`, ) @@ -367,81 +489,102 @@ export function createLSPClient( checkStartFailed() - connection.onRequest(method, handler) + connection.onRequest(method, handler) }, - async stop(): Promise { - let shutdownError: Error | undefined - - // Mark as stopping to prevent error handlers from logging spurious errors - isStopping = true - - try { - if (connection) { - // Try to send shutdown request and exit notification - await connection.sendRequest('shutdown', {}) - await connection.sendNotification('exit', {}) - } - } catch (error) { - const err = error as Error - logError( - new Error(`LSP server ${serverName} stop failed: ${err.message}`), - ) - shutdownError = err - // Continue to cleanup despite shutdown failure - } finally { - // Always cleanup resources, even if shutdown/exit failed - if (connection) { - try { - connection.dispose() - } catch (error) { - // Log but don't throw - disposal errors are less critical - logForDebugging( - `Connection disposal failed for ${serverName}: ${errorMessage(error)}`, - ) - } - connection = undefined + stop(options?: { force?: boolean }): Promise { + const force = options?.force === true + if (stopPromise) { + if (force && stopMode === 'graceful') { + stopMode = 'force' + forceCurrentStop?.() } + return stopPromise + } - if (process) { - // Remove event listeners to prevent memory leaks - process.removeAllListeners('error') - process.removeAllListeners('exit') - if (process.stdin) { - process.stdin.removeAllListeners('error') + const stoppingConnection = connection + const stoppingProcess = process + let requestForce!: () => void + const forceRequested = new Promise(resolve => { + requestForce = resolve + }) + stopMode = force ? 'force' : 'graceful' + forceCurrentStop = requestForce + const pending = (async () => { + let shutdownError: Error | undefined + + // Mark as stopping to prevent error handlers from logging spurious errors + isStopping = true + + try { + if (stoppingConnection && !force) { + let timeout: ReturnType | undefined + const gracefulExchange = (async () => { + await stoppingConnection.sendRequest('shutdown', {}) + await stoppingConnection.sendNotification('exit', {}) + return 'completed' as const + })() + const timedOut = new Promise<'timed-out'>(resolve => { + timeout = setTimeout( + () => resolve('timed-out'), + dependencies.gracefulShutdownTimeoutMs, + ) + }) + const forced = forceRequested.then(() => 'forced' as const) + + let outcome: 'completed' | 'timed-out' | 'forced' + try { + outcome = await Promise.race([ + gracefulExchange, + timedOut, + forced, + ]) + } finally { + if (timeout) clearTimeout(timeout) + } + if (outcome === 'timed-out') { + logForDebugging( + `LSP server ${serverName} graceful shutdown timed out after ${dependencies.gracefulShutdownTimeoutMs}ms`, + ) + } } - if (process.stderr) { - process.stderr.removeAllListeners('data') + } catch (error) { + const err = error as Error + logError( + new Error(`LSP server ${serverName} stop failed: ${err.message}`), + ) + shutdownError = err + // Continue to cleanup despite shutdown failure + } finally { + // Always cleanup the resources captured for this stop attempt. + if (connection === stoppingConnection) connection = undefined + if (process === stoppingProcess) process = undefined + disposeResources(stoppingConnection, stoppingProcess) + + if (!connection) { + isInitialized = false + capabilities = undefined } - - try { - process.kill() - } catch (error) { - // Process might already be dead, which is fine - logForDebugging( - `Process kill failed for ${serverName} (may already be dead): ${errorMessage(error)}`, - ) + isStopping = false // Reset for potential restart + // Don't reset startFailed - preserve error state for diagnostics + if (shutdownError) { + startFailed = true + startError = shutdownError } - process = undefined - } - isInitialized = false - capabilities = undefined - isStopping = false // Reset for potential restart - // Don't reset startFailed - preserve error state for diagnostics - // startFailed and startError remain as-is - if (shutdownError) { - startFailed = true - startError = shutdownError + logForDebugging(`LSP client stopped for ${serverName}`) } - logForDebugging(`LSP client stopped for ${serverName}`) - } + // Re-throw shutdown error after cleanup is complete + if (shutdownError) throw shutdownError + })() - // Re-throw shutdown error after cleanup is complete - if (shutdownError) { - throw shutdownError - } + stopPromise = pending.finally(() => { + stopPromise = undefined + stopMode = undefined + forceCurrentStop = undefined + }) + return stopPromise }, } } diff --git a/src/services/lsp/LSPDiagnosticRegistry.test.ts b/src/services/lsp/LSPDiagnosticRegistry.test.ts index 97fe882ad0..4bcd05803f 100644 --- a/src/services/lsp/LSPDiagnosticRegistry.test.ts +++ b/src/services/lsp/LSPDiagnosticRegistry.test.ts @@ -1,11 +1,19 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' import type { Diagnostic, DiagnosticFile } from '../diagnosticTracking.js' const debugMessages: string[] = [] -const realDebugModule = await import( - `../../utils/debug.js?real=${Date.now()}-${Math.random()}`, -) +const realDebugModule = { + ...await import(`../../utils/debug.js?real=${Date.now()}-${Math.random()}`), +} +const realSlowOperations = { + ...await import(`../../utils/slowOperations.js?real=${Date.now()}-${Math.random()}`), +} + +afterAll(() => { + mock.module('../../utils/debug.js', () => realDebugModule) + mock.module('../../utils/slowOperations.js', () => realSlowOperations) +}) mock.module('../../utils/debug.js', () => ({ ...realDebugModule, @@ -16,6 +24,7 @@ mock.module('../../utils/debug.js', () => ({ // Other tests mock slowOperations process-wide; restore the real serializer so // diagnostic keys keep message/range/code entropy under full-suite ordering. mock.module('../../utils/slowOperations.js', () => ({ + ...realSlowOperations, jsonStringify: JSON.stringify, })) diff --git a/src/services/lsp/LSPServerInstance.test.ts b/src/services/lsp/LSPServerInstance.test.ts new file mode 100644 index 0000000000..cb48eb5eda --- /dev/null +++ b/src/services/lsp/LSPServerInstance.test.ts @@ -0,0 +1,677 @@ +import { describe, expect, jest, mock, test } from 'bun:test' +import type { InitializeParams, InitializeResult } from 'vscode-languageserver-protocol' +import type { LSPClient } from './LSPClient.js' +import { createLSPServerInstance } from './LSPServerInstance.js' +import type { ScopedLspServerConfig } from './types.js' + +const CONFIG: ScopedLspServerConfig = { + command: 'unused-test-lsp', + extensionToLanguage: { '.ts': 'typescript' }, + scope: 'project', + source: 'test', +} + +type FakeClientController = { + createClient: ( + serverName: string, + onCrash?: (error: Error) => void, + ) => LSPClient + crash(error?: Error): void + failNextInitialize(error: Error): void + failNextRequest(error: Error): { called: Promise } + failNextStrictNotification(error: Error): void + failNextStop(error: Error): void + finishNextInitializeUnhealthy(): void + holdNextRequest(result: TResult): { + started: Promise + release(): void + } + holdNextStart(): { started: Promise; release(): void } + holdNextInitialize(): { started: Promise; release(): void } + holdNextStop(options?: { blockForce?: boolean }): { + started: Promise + release(): void + } + startCalls: ReturnType + initializeCalls: ReturnType + stopCalls: ReturnType + sendRequestCalls: ReturnType +} + +function createFakeClientController(): FakeClientController { + let initialized = false + let onCrash: ((error: Error) => void) | undefined + let initializeError: Error | undefined + let initializeUnhealthy = false + let strictNotificationError: Error | undefined + let stopError: Error | undefined + let requestFailure: + | { error: Error; calledResolve: () => void } + | undefined + let startGate: + | { + startedResolve: () => void + wait: Promise + release: () => void + } + | undefined + let initializeGate: + | { + startedResolve: () => void + wait: Promise + release: () => void + } + | undefined + let stopGate: + | { + startedResolve: () => void + wait: Promise + release: () => void + blockForce: boolean + } + | undefined + let requestGate: + | { + result: unknown + startedResolve: () => void + wait: Promise + release: () => void + } + | undefined + + const startCalls = mock(async () => { + startGate?.startedResolve() + if (startGate) { + await startGate.wait + startGate = undefined + } + }) + const initializeCalls = mock(async (_params: InitializeParams) => { + initializeGate?.startedResolve() + if (initializeGate) { + await initializeGate.wait + initializeGate = undefined + } + if (initializeError) { + const error = initializeError + initializeError = undefined + throw error + } + initialized = !initializeUnhealthy + initializeUnhealthy = false + return { capabilities: {} } satisfies InitializeResult + }) + + const stopCalls = mock(async (options?: { force?: boolean }) => { + const shouldBlock = + stopGate && (!options?.force || stopGate.blockForce) + if (shouldBlock) stopGate?.startedResolve() + if (stopGate && shouldBlock) { + await stopGate.wait + stopGate = undefined + } + initialized = false + if (stopError) { + const error = stopError + stopError = undefined + throw error + } + }) + const sendRequestCalls = mock(async () => { + if (requestGate) { + const gate = requestGate + gate.startedResolve() + await gate.wait + requestGate = undefined + return gate.result as TResult + } + if (requestFailure) { + const failure = requestFailure + requestFailure = undefined + failure.calledResolve() + throw failure.error + } + return undefined as TResult + }) + const sendNotificationStrictCalls = mock(async () => { + if (strictNotificationError) { + const error = strictNotificationError + strictNotificationError = undefined + throw error + } + }) + + const client: LSPClient = { + get capabilities() { + return {} + }, + get isInitialized() { + return initialized + }, + start: startCalls, + initialize: initializeCalls, + sendRequest: sendRequestCalls as LSPClient['sendRequest'], + sendNotification: async () => {}, + sendNotificationStrict: sendNotificationStrictCalls, + onNotification: () => {}, + onRequest: () => {}, + stop: stopCalls, + } + + return { + createClient: (_serverName, crashHandler) => { + onCrash = crashHandler + return client + }, + crash(error = new Error('test crash')) { + initialized = false + onCrash?.(error) + }, + failNextInitialize(error) { + initializeError = error + }, + failNextRequest(error) { + let calledResolve!: () => void + const called = new Promise(resolve => { + calledResolve = resolve + }) + requestFailure = { error, calledResolve } + return { called } + }, + failNextStrictNotification(error) { + strictNotificationError = error + }, + failNextStop(error) { + stopError = error + }, + finishNextInitializeUnhealthy() { + initializeUnhealthy = true + }, + holdNextRequest(result) { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + requestGate = { result, startedResolve, wait, release } + return { started, release } + }, + holdNextStart() { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + startGate = { startedResolve, wait, release } + return { started, release } + }, + holdNextInitialize() { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + initializeGate = { startedResolve, wait, release } + return { started, release } + }, + holdNextStop(options = {}) { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + stopGate = { + startedResolve, + wait, + release, + blockForce: options.blockForce ?? true, + } + return { started, release } + }, + startCalls, + initializeCalls, + stopCalls, + sendRequestCalls, + } +} + +describe('LSP server generations', () => { + test('rejects strict notifications while the server is unhealthy', async () => { + const fake = createFakeClientController() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + + await expect( + instance.sendNotificationStrict('textDocument/didOpen', {}), + ).rejects.toThrow( + "Cannot send notification to LSP server 'typescript': server is stopped", + ) + }) + + test('wraps strict notification client failures with instance context', async () => { + const fake = createFakeClientController() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + await instance.start() + fake.failNextStrictNotification(new Error('writer rejected')) + + await expect( + instance.sendNotificationStrict('textDocument/didOpen', {}), + ).rejects.toThrow( + "LSP notification 'textDocument/didOpen' failed for server 'typescript': writer rejected", + ) + }) + + test('advance only after successful initialization and change after restart', async () => { + const fake = createFakeClientController() + const unavailableGenerations: number[] = [] + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + onUnavailable: generation => unavailableGenerations.push(generation), + }) + + expect(instance.generation).toBe(0) + + await instance.start() + expect(instance.generation).toBe(1) + await instance.start() + expect(instance.generation).toBe(1) + expect(fake.initializeCalls).toHaveBeenCalledTimes(1) + + await instance.stop() + expect(instance.generation).toBe(1) + expect(unavailableGenerations).toEqual([1]) + + await instance.start() + expect(instance.generation).toBe(2) + + fake.crash() + expect(instance.state).toBe('error') + expect(instance.generation).toBe(2) + expect(unavailableGenerations).toEqual([1, 2]) + + await instance.start() + expect(instance.generation).toBe(3) + + await instance.stop() + fake.failNextInitialize(new Error('initialize rejected')) + await expect(instance.start()).rejects.toThrow('initialize rejected') + expect(instance.generation).toBe(3) + }) + + test('concurrent callers await one in-flight initialization', async () => { + const fake = createFakeClientController() + const gate = fake.holdNextInitialize() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + + const firstStart = instance.start() + await gate.started + const secondStart = instance.start() + let secondSettled = false + void secondStart.finally(() => { + secondSettled = true + }) + + expect(fake.startCalls).toHaveBeenCalledTimes(1) + expect(fake.initializeCalls).toHaveBeenCalledTimes(1) + expect(instance.state).toBe('starting') + await Promise.resolve() + await Promise.resolve() + expect(secondSettled).toBe(false) + + gate.release() + await Promise.all([firstStart, secondStart]) + + expect(instance.state).toBe('running') + expect(instance.generation).toBe(1) + expect(fake.startCalls).toHaveBeenCalledTimes(1) + expect(fake.initializeCalls).toHaveBeenCalledTimes(1) + }) + + test('concurrent callers share one rejection and a later start retries', async () => { + const fake = createFakeClientController() + const gate = fake.holdNextInitialize() + const initializeError = new Error('initialize rejected') + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + + fake.failNextInitialize(initializeError) + const firstStart = instance.start() + await gate.started + const secondStart = instance.start() + + gate.release() + const [firstResult, secondResult] = await Promise.allSettled([ + firstStart, + secondStart, + ]) + + expect(firstResult.status).toBe('rejected') + expect(secondResult.status).toBe('rejected') + if (firstResult.status === 'rejected') { + expect(firstResult.reason).toBe(initializeError) + } + if (secondResult.status === 'rejected') { + expect(secondResult.reason).toBe(initializeError) + } + expect(fake.initializeCalls).toHaveBeenCalledTimes(1) + expect(instance.generation).toBe(0) + + await instance.start() + expect(fake.initializeCalls).toHaveBeenCalledTimes(2) + expect(instance.generation).toBe(1) + }) + + test('waits for failed-start cleanup before allowing a retry', async () => { + const fake = createFakeClientController() + const initializeError = new Error('initialize rejected') + const stopGate = fake.holdNextStop() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + fake.failNextInitialize(initializeError) + + let firstSettled = false + const firstOutcome = instance.start().then( + () => undefined, + error => error, + ) + void firstOutcome.then(() => { + firstSettled = true + }) + + await stopGate.started + await Promise.resolve() + const secondOutcome = instance.start().then( + () => undefined, + error => error, + ) + + try { + expect(firstSettled).toBe(false) + expect(fake.startCalls).toHaveBeenCalledTimes(1) + } finally { + stopGate.release() + } + + expect(await firstOutcome).toBe(initializeError) + expect(await secondOutcome).toBe(initializeError) + + await instance.start() + expect(fake.startCalls).toHaveBeenCalledTimes(2) + expect(instance.generation).toBe(1) + }) + + test('starts after an overlapping stop fails', async () => { + const fake = createFakeClientController() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + await instance.start() + const stopError = new Error('shutdown rejected') + fake.failNextStop(stopError) + + const stopOutcome = instance.stop().then( + () => undefined, + error => error, + ) + const restart = instance.start() + + expect(await stopOutcome).toBe(stopError) + await expect(restart).resolves.toBeUndefined() + expect(instance.state).toBe('running') + expect(instance.generation).toBe(2) + }) + + test('a stop during initialization wins over the cancelled start', async () => { + const fake = createFakeClientController() + const initializeGate = fake.holdNextInitialize() + const stopGate = fake.holdNextStop() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + + const start = instance.start() + await initializeGate.started + const stop = instance.stop() + await stopGate.started + + initializeGate.release() + await Promise.resolve() + stopGate.release() + + await expect(start).rejects.toThrow('start was cancelled') + await expect(stop).resolves.toBeUndefined() + expect(instance.state).toBe('stopped') + expect(instance.generation).toBe(0) + }) + + test('startup timeout uses immediate cleanup instead of graceful shutdown', async () => { + const fake = createFakeClientController() + const initializeGate = fake.holdNextInitialize() + const stopGate = fake.holdNextStop({ blockForce: false }) + const instance = createLSPServerInstance( + 'typescript', + { ...CONFIG, startupTimeout: 5 }, + { createClient: fake.createClient }, + ) + + const start = instance.start() + await initializeGate.started + + try { + await expect(start).rejects.toThrow('timed out') + expect(fake.stopCalls).toHaveBeenCalledWith({ force: true }) + } finally { + initializeGate.release() + stopGate.release() + await start.catch(() => {}) + } + }) + + test('startup timeout also bounds the process spawn wait', async () => { + jest.useFakeTimers() + const fake = createFakeClientController() + const startGate = fake.holdNextStart() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + defaultStartupTimeoutMs: 5, + }) + + const start = instance.start() + await startGate.started + + try { + jest.advanceTimersByTime(4) + expect(instance.state).toBe('starting') + jest.advanceTimersByTime(1) + await expect(start).rejects.toThrow( + 'timed out after 5ms during process startup', + ) + expect(fake.initializeCalls).not.toHaveBeenCalled() + expect(fake.stopCalls).toHaveBeenCalledWith({ force: true }) + } finally { + startGate.release() + jest.runAllTimers() + jest.useRealTimers() + await start.catch(() => {}) + } + }) + + test('applies a bounded default when startupTimeout is omitted', async () => { + jest.useFakeTimers() + const fake = createFakeClientController() + const initializeGate = fake.holdNextInitialize() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + defaultStartupTimeoutMs: 5, + }) + + const start = instance.start() + await initializeGate.started + + try { + jest.advanceTimersByTime(4) + expect(instance.state).toBe('starting') + jest.advanceTimersByTime(1) + await expect(start).rejects.toThrow('timed out after 5ms') + expect(fake.stopCalls).toHaveBeenCalledWith({ force: true }) + } finally { + initializeGate.release() + jest.runAllTimers() + jest.useRealTimers() + await start.catch(() => {}) + } + }) + + test('does not publish a generation when initialization reports unhealthy', async () => { + const fake = createFakeClientController() + fake.finishNextInitializeUnhealthy() + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + + await expect(instance.start()).rejects.toThrow('did not finish initialization') + expect(instance.state).toBe('error') + expect(instance.generation).toBe(0) + }) + + test('caps repeated crash recovery across successful replacement generations', async () => { + const fake = createFakeClientController() + const instance = createLSPServerInstance( + 'typescript', + { ...CONFIG, maxRestarts: 2 }, + { createClient: fake.createClient }, + ) + + await instance.start() + fake.crash() + await instance.start() + fake.crash() + await instance.start() + fake.crash() + + await expect(instance.start()).rejects.toThrow( + 'exceeded max automatic recovery attempts (2)', + ) + expect(fake.startCalls).toHaveBeenCalledTimes(3) + expect(instance.generation).toBe(3) + }) + + test('explicit restart resets an exhausted automatic recovery budget', async () => { + const fake = createFakeClientController() + const instance = createLSPServerInstance( + 'typescript', + { ...CONFIG, maxRestarts: 2 }, + { createClient: fake.createClient }, + ) + + await instance.start() + fake.crash() + await instance.start() + fake.crash() + await instance.start() + fake.crash() + + expect(instance.isCrashRecoveryExhausted).toBe(true) + + await instance.restart() + expect(instance.isCrashRecoveryExhausted).toBe(false) + + fake.crash() + await expect(instance.start()).resolves.toBeUndefined() + expect(instance.generation).toBe(5) + }) + + test('ordinary stop does not reset or bypass an exhausted automatic recovery budget', async () => { + const fake = createFakeClientController() + const instance = createLSPServerInstance( + 'typescript', + { ...CONFIG, maxRestarts: 0 }, + { createClient: fake.createClient }, + ) + + await instance.start() + fake.crash() + + await instance.stop() + expect(instance.state).toBe('stopped') + await expect(instance.start()).rejects.toThrow( + 'exceeded max automatic recovery attempts (0)', + ) + expect(instance.isCrashRecoveryExhausted).toBe(true) + expect(fake.startCalls).toHaveBeenCalledTimes(1) + }) + + test('notifies one unavailable transition when a crashed generation is later stopped', async () => { + const fake = createFakeClientController() + const unavailableGenerations: number[] = [] + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + onUnavailable: generation => unavailableGenerations.push(generation), + }) + + await instance.start() + fake.crash() + expect(unavailableGenerations).toEqual([1]) + + await instance.stop() + expect(unavailableGenerations).toEqual([1]) + }) + + test('rejects a successful response from a replaced generation', async () => { + const fake = createFakeClientController() + const requestGate = fake.holdNextRequest('stale response') + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + await instance.start() + + const request = instance.sendRequest('textDocument/hover', {}) + await requestGate.started + fake.crash() + await instance.start() + requestGate.release() + + await expect(request).rejects.toThrow('changed generation') + expect(instance.generation).toBe(2) + }) + + test('does not retry ContentModified on a replacement generation', async () => { + const fake = createFakeClientController() + const contentModified = Object.assign(new Error('content modified'), { + code: -32801, + }) + const failure = fake.failNextRequest(contentModified) + const instance = createLSPServerInstance('typescript', CONFIG, { + createClient: fake.createClient, + }) + await instance.start() + + const request = instance.sendRequest('textDocument/hover', {}) + await failure.called + fake.crash() + await instance.start() + + await expect(request).rejects.toThrow('changed generation') + expect(fake.sendRequestCalls).toHaveBeenCalledTimes(1) + expect(instance.generation).toBe(2) + }) +}) diff --git a/src/services/lsp/LSPServerInstance.ts b/src/services/lsp/LSPServerInstance.ts index b5994a5f64..1c0731ae9e 100644 --- a/src/services/lsp/LSPServerInstance.ts +++ b/src/services/lsp/LSPServerInstance.ts @@ -26,6 +26,8 @@ const MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3 * Actual delays: 500ms, 1000ms, 2000ms */ const RETRY_BASE_DELAY_MS = 500 + +const DEFAULT_LSP_STARTUP_TIMEOUT_MS = 30_000 /** * LSP server instance interface returned by createLSPServerInstance. * Manages the lifecycle of a single LSP server with state tracking and health monitoring. @@ -43,6 +45,10 @@ export type LSPServerInstance = { readonly lastError: Error | undefined /** Number of times restart() has been called */ readonly restartCount: number + /** Successful initialization generation for the current process lifecycle */ + readonly generation: number + /** Whether automatic recovery has exhausted the configured failure budget */ + readonly isCrashRecoveryExhausted: boolean /** Start the server and initialize it */ start(): Promise /** Stop the server gracefully */ @@ -55,6 +61,8 @@ export type LSPServerInstance = { sendRequest(method: string, params: unknown): Promise /** Send an LSP notification to the server (fire-and-forget) */ sendNotification(method: string, params: unknown): Promise + /** Send an LSP notification and surface transport/write failure */ + sendNotificationStrict(method: string, params: unknown): Promise /** Register a handler for LSP notifications */ onNotification(method: string, handler: (params: unknown) => void): void /** Register a handler for LSP requests from the server */ @@ -64,6 +72,12 @@ export type LSPServerInstance = { ): void } +export type LSPServerInstanceOptions = { + createClient?: typeof createLSPClientType + onUnavailable?: (generation: number) => void + defaultStartupTimeoutMs?: number +} + /** * Creates and manages a single LSP server instance. * @@ -90,6 +104,7 @@ export type LSPServerInstance = { export function createLSPServerInstance( name: string, config: ScopedLspServerConfig, + options: LSPServerInstanceOptions = {}, ): LSPServerInstance { // Validate that unimplemented fields are not set if (config.restartOnCrash !== undefined) { @@ -106,43 +121,78 @@ export function createLSPServerInstance( // Private state encapsulated via closures. Lazy-require LSPClient so // vscode-jsonrpc (~129KB) only loads when an LSP server is actually // instantiated, not when the static import chain reaches this module. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { createLSPClient } = require('./LSPClient.js') as { - createLSPClient: typeof createLSPClientType + let createLSPClient = options.createClient + if (!createLSPClient) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const clientModule = require('./LSPClient.js') as { + createLSPClient: typeof createLSPClientType + } + createLSPClient = clientModule.createLSPClient } let state: LspServerState = 'stopped' + const maxRestarts = config.maxRestarts ?? 3 let startTime: Date | undefined let lastError: Error | undefined let restartCount = 0 - let crashRecoveryCount = 0 + let automaticRecoveryFailureCount = 0 + let generation = 0 + let startEpoch = 0 + let startPromise: Promise | undefined + let stopPromise: Promise | undefined + let lastUnavailableGeneration: number | undefined + const notifyUnavailable = (unavailableGeneration: number): void => { + if (lastUnavailableGeneration === unavailableGeneration) return + lastUnavailableGeneration = unavailableGeneration + options.onUnavailable?.(unavailableGeneration) + } + + // Automatic recovery is bounded across both startup failures and unexpected + // transport loss. Successful lazy replacement does not reset this budget; + // only an explicit restart does. Intentional stop/cancellation is excluded. + const recordAutomaticRecoveryFailure = (error: unknown): void => { + state = 'error' + lastError = + error instanceof Error ? error : new Error(errorMessage(error)) + automaticRecoveryFailureCount++ + notifyUnavailable(generation) + } + // Propagate crash state so ensureServerStarted can restart on next use. // Without this, state stays 'running' after crash and the server is never // restarted (zombie state). const client = createLSPClient(name, error => { - state = 'error' - lastError = error - crashRecoveryCount++ + startEpoch++ + recordAutomaticRecoveryFailure(error) }) /** * Starts the LSP server and initializes it with workspace information. * - * If the server is already running or starting, this method returns immediately. + * If the server is already running it returns immediately. Concurrent callers + * while starting share and await the same initialization promise. * On failure, sets state to 'error', logs for monitoring, and throws. * * @throws {Error} If server fails to start or initialize */ - async function start(): Promise { - if (state === 'running' || state === 'starting') { - return - } + function start(): Promise { + if (stopPromise) return stopPromise.catch(() => {}).then(start) + if (state === 'running') return Promise.resolve() + if (startPromise) return startPromise + + const epoch = ++startEpoch + const pending = startInternal(epoch) + startPromise = pending.finally(() => { + startPromise = undefined + }) + return startPromise + } - // Cap crash-recovery attempts so a persistently crashing server doesn't - // spawn unbounded child processes on every incoming request. - const maxRestarts = config.maxRestarts ?? 3 - if (state === 'error' && crashRecoveryCount > maxRestarts) { + async function startInternal(epoch: number): Promise { + // Cap automatic recovery so a persistently crashing or unstartable server + // doesn't spawn unbounded child processes on every incoming request. + if (automaticRecoveryFailureCount > maxRestarts) { const error = new Error( - `LSP server '${name}' exceeded max crash recovery attempts (${maxRestarts})`, + `LSP server '${name}' exceeded max automatic recovery attempts (${maxRestarts})`, ) lastError = error logError(error) @@ -154,11 +204,23 @@ export function createLSPServerInstance( state = 'starting' logForDebugging(`Starting LSP server instance: ${name}`) + const startupTimeoutMs = + config.startupTimeout ?? + options.defaultStartupTimeoutMs ?? + DEFAULT_LSP_STARTUP_TIMEOUT_MS + // Start the client - await client.start(config.command, config.args || [], { - env: config.env, - cwd: config.workspaceFolder, - }) + await withTimeout( + client.start(config.command, config.args || [], { + env: config.env, + cwd: config.workspaceFolder, + }), + startupTimeoutMs, + `LSP server '${name}' timed out after ${startupTimeoutMs}ms during process startup`, + ) + if (epoch !== startEpoch) { + throw new Error(`LSP server '${name}' start was cancelled`) + } // Initialize with workspace info const workspaceFolder = config.workspaceFolder || getCwd() @@ -237,28 +299,33 @@ export function createLSPServerInstance( } initPromise = client.initialize(initParams) - if (config.startupTimeout !== undefined) { - await withTimeout( - initPromise, - config.startupTimeout, - `LSP server '${name}' timed out after ${config.startupTimeout}ms during initialization`, + await withTimeout( + initPromise, + startupTimeoutMs, + `LSP server '${name}' timed out after ${startupTimeoutMs}ms during initialization`, + ) + if (epoch !== startEpoch) { + throw new Error(`LSP server '${name}' start was cancelled`) + } + if (state !== 'starting' || !client.isInitialized) { + throw new Error( + `LSP server '${name}' did not finish initialization in a healthy state`, ) - } else { - await initPromise } + generation++ state = 'running' startTime = new Date() - crashRecoveryCount = 0 logForDebugging(`LSP server instance started: ${name}`) } catch (error) { // Clean up the spawned child process on timeout/error - client.stop().catch(() => {}) + await client.stop({ force: true }).catch(() => {}) // Prevent unhandled rejection from abandoned initialize promise initPromise?.catch(() => {}) - state = 'error' - lastError = error as Error - logError(error) + if (epoch === startEpoch) { + recordAutomaticRecoveryFailure(error) + logError(error) + } throw error } } @@ -271,22 +338,43 @@ export function createLSPServerInstance( * * @throws {Error} If server fails to stop */ - async function stop(): Promise { + function stop(): Promise { if (state === 'stopped' || state === 'stopping') { - return + return stopPromise ?? Promise.resolve() } + if (stopPromise) return stopPromise + + const pending = stopInternal() + stopPromise = pending.finally(() => { + stopPromise = undefined + }) + return stopPromise + } + async function stopInternal(): Promise { + const stoppedGeneration = generation + const force = state === 'starting' + startEpoch++ + let stopError: unknown + state = 'stopping' try { - state = 'stopping' - await client.stop() - state = 'stopped' - logForDebugging(`LSP server instance stopped: ${name}`) + await client.stop({ force }) } catch (error) { + stopError = error + } + + await startPromise?.catch(() => {}) + notifyUnavailable(stoppedGeneration) + + if (stopError) { state = 'error' - lastError = error as Error - logError(error) - throw error + lastError = stopError as Error + logError(stopError) + throw stopError } + + state = 'stopped' + logForDebugging(`LSP server instance stopped: ${name}`) } /** @@ -310,7 +398,6 @@ export function createLSPServerInstance( restartCount++ - const maxRestarts = config.maxRestarts ?? 3 if (restartCount > maxRestarts) { const error = new Error( `Max restart attempts (${maxRestarts}) exceeded for server '${name}'`, @@ -319,6 +406,10 @@ export function createLSPServerInstance( throw error } + // A manual restart is the explicit operator action that resets the + // automatic-recovery budget. Ordinary stop/start cleanup must not. + automaticRecoveryFailureCount = 0 + try { await start() } catch (error) { @@ -362,6 +453,7 @@ export function createLSPServerInstance( throw error } + const requestGeneration = generation let lastAttemptError: Error | undefined for ( @@ -369,8 +461,19 @@ export function createLSPServerInstance( attempt <= MAX_RETRIES_FOR_TRANSIENT_ERRORS; attempt++ ) { + if (generation !== requestGeneration || !isHealthy()) { + throw new Error( + `LSP request '${method}' aborted because server '${name}' changed generation or became unavailable`, + ) + } try { - return await client.sendRequest(method, params) + const result = await client.sendRequest(method, params) + if (generation !== requestGeneration || !isHealthy()) { + throw new Error( + `LSP request '${method}' aborted because server '${name}' changed generation or became unavailable`, + ) + } + return result } catch (error) { lastAttemptError = error as Error @@ -436,6 +539,33 @@ export function createLSPServerInstance( } } + /** + * Send a notification while preserving transport/write failure for callers + * whose local state depends on acceptance by the JSON-RPC writer. + */ + async function sendNotificationStrict( + method: string, + params: unknown, + ): Promise { + if (!isHealthy()) { + const error = new Error( + `Cannot send notification to LSP server '${name}': server is ${state}`, + ) + logError(error) + throw error + } + + try { + await client.sendNotificationStrict(method, params) + } catch (error) { + const notificationError = new Error( + `LSP notification '${method}' failed for server '${name}': ${errorMessage(error)}`, + ) + logError(notificationError) + throw notificationError + } + } + /** * Registers a handler for LSP notifications from the server. * @@ -481,12 +611,19 @@ export function createLSPServerInstance( get restartCount() { return restartCount }, + get generation() { + return generation + }, + get isCrashRecoveryExhausted() { + return automaticRecoveryFailureCount > maxRestarts + }, start, stop, restart, isHealthy, sendRequest, sendNotification, + sendNotificationStrict, onNotification, onRequest, } diff --git a/src/services/lsp/LSPServerManager.protocol.test.ts b/src/services/lsp/LSPServerManager.protocol.test.ts new file mode 100644 index 0000000000..ac63fd25db --- /dev/null +++ b/src/services/lsp/LSPServerManager.protocol.test.ts @@ -0,0 +1,207 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { createLSPServerManager } from './LSPServerManager.js' +import type { LSPServerInstance } from './LSPServerInstance.js' +import type { LspServerState, ScopedLspServerConfig } from './types.js' + +type ProtocolEvent = { + generation: number + kind: 'notification' | 'request' + method: string + params: unknown +} + +type FakeServer = { + server: LSPServerInstance + events: ProtocolEvent[] + crash(): void +} + +const CONFIG = { + command: 'unused-test-lsp', + extensionToLanguage: { '.ts': 'typescript' }, + scope: 'project', + source: 'test', +} satisfies ScopedLspServerConfig + +let currentServer: FakeServer | undefined +const managers: Array<{ shutdown(): Promise }> = [] +const activityPaths: string[] = [] + +function createFakeServer( + onUnavailable?: (generation: number) => void, +): FakeServer { + let state: LspServerState = 'stopped' + let generation = 0 + const events: ProtocolEvent[] = [] + + const server: LSPServerInstance = { + name: 'typescript', + config: CONFIG, + get state() { + return state + }, + get generation() { + return generation + }, + get startTime() { + return undefined + }, + get lastError() { + return undefined + }, + get restartCount() { + return 0 + }, + get isCrashRecoveryExhausted() { + return false + }, + async start() { + if (state === 'running') return + generation++ + state = 'running' + }, + async stop() { + const stoppedGeneration = generation + state = 'stopped' + onUnavailable?.(stoppedGeneration) + }, + async restart() { + await server.stop() + await server.start() + }, + isHealthy() { + return state === 'running' + }, + async sendRequest(method: string, params: unknown) { + events.push({ generation, kind: 'request', method, params }) + return null as TResult + }, + async sendNotification(method: string, params: unknown) { + events.push({ generation, kind: 'notification', method, params }) + }, + async sendNotificationStrict(method: string, params: unknown) { + events.push({ generation, kind: 'notification', method, params }) + }, + onNotification() {}, + onRequest() {}, + } + + return { + server, + events, + crash() { + const crashedGeneration = generation + state = 'error' + onUnavailable?.(crashedGeneration) + }, + } +} + +afterEach(async () => { + await Promise.allSettled(managers.splice(0).map(manager => manager.shutdown())) + currentServer = undefined +}) + +beforeEach(() => { + activityPaths.length = 0 +}) + +async function createManager() { + const manager = createLSPServerManager({ + loadServerConfigs: async () => ({ + servers: { typescript: CONFIG }, + }), + createServerInstance: ( + _name: string, + _config: unknown, + options: { onUnavailable?: (generation: number) => void }, + ) => { + currentServer = createFakeServer(options.onUnavailable) + return currentServer.server + }, + readDocument: filePath => readFile(filePath, 'utf-8'), + recordFileActivity: filePath => { + activityPaths.push(filePath) + }, + }) + managers.push(manager) + await manager.initialize() + return { manager, server: currentServer! } +} + +test('delivers monotonically increasing versions for one document', async () => { + const { manager, server } = await createManager() + const file = String.raw`C:\repo\src\version.ts` + const uri = 'file:///c:/repo/src/version.ts' + + await manager.openFile(file, 'one') + await manager.changeFile(file, 'two') + await manager.changeFile(file, 'three') + + expect( + server.events + .filter(event => event.kind === 'notification') + .map(event => ({ + method: event.method, + version: (event.params as { textDocument: { version: number } }) + .textDocument.version, + uri: (event.params as { textDocument: { uri: string } }).textDocument + .uri, + })), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1, uri }, + { method: 'textDocument/didChange', version: 2, uri }, + { method: 'textDocument/didChange', version: 3, uri }, + ]) +}) + +test('resends current contents after restart before making a request', async () => { + const directory = mkdtempSync(join(tmpdir(), 'openclaude-lsp-protocol-')) + const file = join(directory, 'restart.ts') + try { + writeFileSync(file, 'current contents') + const { manager, server } = await createManager() + + await manager.openFile(file, 'before restart') + server.crash() + await manager.sendRequest(file, 'textDocument/hover', {}) + + expect(server.events.slice(-2)).toMatchObject([ + { + generation: 2, + kind: 'notification', + method: 'textDocument/didOpen', + params: { + textDocument: { text: 'current contents', version: 1 }, + }, + }, + { + generation: 2, + kind: 'request', + method: 'textDocument/hover', + }, + ]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('records Windows activity with the opened document casing', async () => { + const { manager } = await createManager() + const firstSpelling = String.raw`C:\Repo\Source File.ts` + const secondSpelling = 'c:/repo/source file.ts' + const openedUri = 'file:///c:/Repo/Source%20File.ts' + + await manager.openFile(firstSpelling, 'one') + await manager.changeFile(secondSpelling, 'two') + + expect(activityPaths).toEqual([ + fileURLToPath(openedUri), + fileURLToPath(openedUri), + ]) +}) diff --git a/src/services/lsp/LSPServerManager.test.ts b/src/services/lsp/LSPServerManager.test.ts new file mode 100644 index 0000000000..c58539ac78 --- /dev/null +++ b/src/services/lsp/LSPServerManager.test.ts @@ -0,0 +1,1390 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, jest, mock, test } from 'bun:test' +import type { ChildProcess } from 'child_process' +import type { MessageConnection } from 'vscode-jsonrpc/node' +import { + getLspDocumentIdentity, + LspDocumentTooLargeError, + MAX_LSP_FILE_SIZE_BYTES, + readLspDocumentContents, +} from './documentIdentity.js' +import { + createLSPClient, + type LSPClientDependencies, +} from './LSPClient.js' +import { + createLSPServerManager, + type LSPServerManager, +} from './LSPServerManager.js' +import { + createLSPServerInstance, + type LSPServerInstance, + type LSPServerInstanceOptions, +} from './LSPServerInstance.js' +import type { LspServerState, ScopedLspServerConfig } from './types.js' + +const TYPESCRIPT_CONFIG: ScopedLspServerConfig = { + command: 'unused-typescript-lsp', + extensionToLanguage: { + '.ts': 'typescript', + '.tsx': 'typescriptreact', + }, + scope: 'project', + source: 'test', +} + +const PYTHON_CONFIG: ScopedLspServerConfig = { + command: 'unused-python-lsp', + extensionToLanguage: { '.py': 'python' }, + scope: 'project', + source: 'test', +} + +const managers: LSPServerManager[] = [] + +afterEach(async () => { + await Promise.allSettled(managers.splice(0).map(manager => manager.shutdown())) +}) + +type ServerEvent = + | { + kind: 'notification' + generation: number + method: string + params: unknown + strict: boolean + } + | { + kind: 'request' + generation: number + method: string + params: unknown + } + +type FakeServerControl = { + server: LSPServerInstance + events: ServerEvent[] + startCalls: ReturnType + stopCalls: ReturnType + blockNextNotification(method: string): { + started: Promise + release(): void + } + blockNextRequest(method: string, error?: Error): { + started: Promise + release(): void + } + holdNextRequest(method: string, result: TResult): { + started: Promise + release(): void + } + failNextNotification(method: string, error?: Error): void + holdNextStart(): { started: Promise; release(): void } + waitForStartCalls(count: number): Promise + simulateCrash(notifyManager?: boolean): void + replaceGeneration(): void + setCrashRecoveryExhausted(exhausted: boolean): void +} + +function createFakeServer( + name: string, + config: ScopedLspServerConfig, + options: LSPServerInstanceOptions = {}, +): FakeServerControl { + let state: LspServerState = 'stopped' + let generation = 0 + let startEpoch = 0 + let startPromise: Promise | undefined + let nextStartGate: + | { startedResolve: () => void; wait: Promise; release: () => void } + | undefined + let notificationGate: + | { + method: string + startedResolve: () => void + wait: Promise + release: () => void + } + | undefined + let notificationFailure: { method: string; error: Error } | undefined + let requestGate: + | { + method: string + result?: unknown + error?: Error + startedResolve: () => void + wait: Promise + release: () => void + } + | undefined + let startInvocationCount = 0 + let crashRecoveryExhausted = false + const startWaiters: Array<{ count: number; resolve: () => void }> = [] + const events: ServerEvent[] = [] + + const startCalls = mock(async () => { + startInvocationCount++ + for (const waiter of startWaiters) { + if (startInvocationCount >= waiter.count) waiter.resolve() + } + if (state === 'running') return + if (startPromise) return startPromise + state = 'starting' + const epoch = startEpoch + const pending = (async () => { + nextStartGate?.startedResolve() + if (nextStartGate) { + await nextStartGate.wait + nextStartGate = undefined + } + if (epoch !== startEpoch) return + generation++ + state = 'running' + })() + startPromise = pending.finally(() => { + startPromise = undefined + }) + return startPromise + }) + + const stopCalls = mock(async () => { + const stoppedGeneration = generation + startEpoch++ + state = 'stopped' + nextStartGate?.release() + nextStartGate = undefined + options.onUnavailable?.(stoppedGeneration) + }) + + const server: LSPServerInstance = { + name, + config, + get state() { + return state + }, + get generation() { + return generation + }, + get startTime() { + return undefined + }, + get lastError() { + return undefined + }, + get restartCount() { + return 0 + }, + get isCrashRecoveryExhausted() { + return crashRecoveryExhausted + }, + start: startCalls, + stop: stopCalls, + async restart() { + await server.stop() + await server.start() + }, + isHealthy() { + return state === 'running' + }, + async sendRequest(method: string, params: unknown) { + events.push({ kind: 'request', generation, method, params }) + if (requestGate?.method === method) { + const gate = requestGate + gate.startedResolve() + await gate.wait + requestGate = undefined + if (gate.error) throw gate.error + return gate.result as TResult + } + return null as TResult + }, + async sendNotification(method: string, params: unknown) { + events.push({ + kind: 'notification', + generation, + method, + params, + strict: false, + }) + }, + async sendNotificationStrict(method: string, params: unknown) { + events.push({ + kind: 'notification', + generation, + method, + params, + strict: true, + }) + if (notificationGate?.method === method) { + notificationGate.startedResolve() + await notificationGate.wait + notificationGate = undefined + } + if (notificationFailure?.method === method) { + const error = notificationFailure.error + notificationFailure = undefined + throw error + } + }, + onNotification() {}, + onRequest() {}, + } + + return { + server, + events, + startCalls, + stopCalls, + blockNextNotification(method) { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + notificationGate = { method, startedResolve, wait, release } + return { started, release } + }, + blockNextRequest(method, error = new Error('request interrupted')) { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + requestGate = { method, error, startedResolve, wait, release } + return { started, release } + }, + holdNextRequest(method, result) { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + requestGate = { method, result, startedResolve, wait, release } + return { started, release } + }, + failNextNotification(method, error = new Error('transport rejected')) { + notificationFailure = { method, error } + }, + holdNextStart() { + let startedResolve!: () => void + let release!: () => void + const started = new Promise(resolve => { + startedResolve = resolve + }) + const wait = new Promise(resolve => { + release = resolve + }) + nextStartGate = { startedResolve, wait, release } + return { started, release } + }, + waitForStartCalls(count) { + if (startInvocationCount >= count) return Promise.resolve() + return new Promise(resolve => { + startWaiters.push({ count, resolve }) + }) + }, + simulateCrash(notifyManager = true) { + const crashedGeneration = generation + state = 'error' + if (notifyManager) options.onUnavailable?.(crashedGeneration) + }, + replaceGeneration() { + generation++ + state = 'running' + }, + setCrashRecoveryExhausted(exhausted) { + crashRecoveryExhausted = exhausted + }, + } +} + +async function createManager(options?: { + configs?: Record + readDocument?: (filePath: string) => Promise + recordFileActivity?: (filePath: string) => void + lifecycleNotificationTimeoutMs?: number +}): Promise<{ + manager: LSPServerManager + controls: Map +}> { + const configs = options?.configs ?? { typescript: TYPESCRIPT_CONFIG } + const controls = new Map() + const manager = createLSPServerManager({ + loadServerConfigs: async () => ({ servers: configs }), + createServerInstance: (name, config, instanceOptions) => { + const control = createFakeServer(name, config, instanceOptions) + controls.set(name, control) + return control.server + }, + readDocument: options?.readDocument ?? (async () => 'current contents'), + recordFileActivity: options?.recordFileActivity ?? (() => {}), + lifecycleNotificationTimeoutMs: + options?.lifecycleNotificationTimeoutMs, + }) + await manager.initialize() + managers.push(manager) + return { manager, controls } +} + +type IntegratedConnectionEvent = { + kind: 'notification' | 'request' + method: string + params: unknown +} + +type IntegratedTransport = { + child: ChildProcess + connection: MessageConnection + events: IntegratedConnectionEvent[] + emitConnectionError(error: Error): void + emitConnectionClose(): void +} + +function createIntegratedTransport(): IntegratedTransport { + const child = new EventEmitter() as ChildProcess + Object.assign(child, { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: mock(() => true), + }) + queueMicrotask(() => child.emit('spawn')) + + const events: IntegratedConnectionEvent[] = [] + let errorHandler: ((error: [Error, unknown, number]) => void) | undefined + let closeHandler: (() => void) | undefined + const connection = { + listen: mock(() => {}), + trace: mock(async () => {}), + onError: mock((handler: (error: [Error, unknown, number]) => void) => { + errorHandler = handler + }), + onClose: mock((handler: () => void) => { + closeHandler = handler + }), + onNotification: mock(() => {}), + onRequest: mock(() => {}), + sendRequest: mock(async (method: string, params: unknown) => { + events.push({ kind: 'request', method, params }) + return method === 'initialize' ? { capabilities: {} } : null + }), + sendNotification: mock(async (method: string, params: unknown) => { + events.push({ kind: 'notification', method, params }) + }), + dispose: mock(() => {}), + } as unknown as MessageConnection + + return { + child, + connection, + events, + emitConnectionError(error) { + errorHandler?.([error, undefined, 0]) + }, + emitConnectionClose() { + closeHandler?.() + }, + } +} + +function documentVersions( + events: ServerEvent[], + uri: string, +): Array<{ method: string; version: number }> { + return events.flatMap(event => { + if (event.kind !== 'notification') return [] + const params = event.params as { + textDocument?: { uri?: string; version?: number } + } + if (params.textDocument?.uri !== uri) return [] + const version = params.textDocument.version + return typeof version === 'number' + ? [{ method: event.method, version }] + : [] + }) +} + +describe('LSP document versions', () => { + test('increments changes monotonically and independently per document', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const first = '/repo/src/first.ts' + const second = '/repo/src/second.tsx' + + await manager.openFile(first, 'one') + await manager.changeFile(first, 'two') + await manager.changeFile(first, 'three') + await manager.openFile(second, 'alpha') + await manager.changeFile(second, 'beta') + + expect( + documentVersions(server.events, getLspDocumentIdentity(first).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + { method: 'textDocument/didChange', version: 3 }, + ]) + expect( + documentVersions(server.events, getLspDocumentIdentity(second).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + ]) + }) + + test('close and reopen starts a new lifecycle at version one', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/reopen.ts' + + await manager.openFile(file, 'one') + await manager.changeFile(file, 'two') + await manager.closeFile(file) + await manager.openFile(file, 'three') + + expect( + server.events + .filter(event => event.kind === 'notification') + .map(event => ({ + method: event.method, + version: (event.params as { textDocument?: { version?: number } }) + .textDocument?.version, + })), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + { method: 'textDocument/didClose', version: undefined }, + { method: 'textDocument/didOpen', version: 1 }, + ]) + }) +}) + +describe('LSP lifecycle notification failures', () => { + test('generation replacement during open leaves no committed state', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/open-generation-change.ts' + const gate = server.blockNextNotification('textDocument/didOpen') + + const open = manager.openFile(file, 'one') + await gate.started + server.replaceGeneration() + gate.release() + + await expect(open).rejects.toThrow('generation changed') + expect(manager.isFileOpen(file)).toBe(false) + + await manager.changeFile(file, 'two') + expect(manager.isFileOpen(file)).toBe(true) + expect( + documentVersions(server.events, getLspDocumentIdentity(file).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didOpen', version: 1 }, + ]) + }) + + test('generation replacement during change forces a version-one reopen', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/change-generation-change.ts' + + await manager.openFile(file, 'one') + const gate = server.blockNextNotification('textDocument/didChange') + const change = manager.changeFile(file, 'two') + await gate.started + server.replaceGeneration() + gate.release() + + await expect(change).rejects.toThrow('generation changed') + expect(manager.isFileOpen(file)).toBe(false) + + await manager.changeFile(file, 'three') + expect(manager.isFileOpen(file)).toBe(true) + expect( + documentVersions(server.events, getLspDocumentIdentity(file).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + { method: 'textDocument/didOpen', version: 1 }, + ]) + }) + + test('failed open creates no state and a later open retries', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/open-failure.ts' + server.failNextNotification('textDocument/didOpen') + + await expect(manager.openFile(file, 'one')).rejects.toThrow( + 'Failed to sync file open', + ) + expect(manager.isFileOpen(file)).toBe(false) + + await manager.openFile(file, 'two') + expect(manager.isFileOpen(file)).toBe(true) + expect( + documentVersions(server.events, getLspDocumentIdentity(file).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didOpen', version: 1 }, + ]) + }) + + test('failed change invalidates state and the next request performs a full open', async () => { + const readDocument = mock(async () => 'latest complete contents') + const { manager, controls } = await createManager({ readDocument }) + const server = controls.get('typescript')! + const file = '/repo/src/change-failure.ts' + + await manager.openFile(file, 'one') + server.failNextNotification('textDocument/didChange') + await expect(manager.changeFile(file, 'two')).rejects.toThrow( + 'Failed to sync file change', + ) + expect(server.stopCalls).toHaveBeenCalledTimes(1) + expect(manager.isFileOpen(file)).toBe(false) + await manager.saveFile(file) + expect( + server.events.some( + event => + event.kind === 'notification' && + event.method === 'textDocument/didSave', + ), + ).toBe(false) + + await manager.sendRequest(file, 'textDocument/hover', {}) + + expect(readDocument).toHaveBeenCalledTimes(1) + expect( + documentVersions(server.events, getLspDocumentIdentity(file).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + { method: 'textDocument/didOpen', version: 1 }, + ]) + expect(server.events.at(-1)?.kind).toBe('request') + }) + + test('failed close removes local state and allows a version-one reopen', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/close-failure.ts' + + await manager.openFile(file, 'one') + server.failNextNotification('textDocument/didClose') + await expect(manager.closeFile(file)).rejects.toThrow( + 'Failed to sync file close', + ) + expect(manager.isFileOpen(file)).toBe(false) + + await manager.openFile(file, 'two') + expect( + documentVersions(server.events, getLspDocumentIdentity(file).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didOpen', version: 1 }, + ]) + }) + + test('bounds shutdown while a lifecycle notification is stuck', async () => { + jest.useFakeTimers() + const { manager, controls } = await createManager({ + lifecycleNotificationTimeoutMs: 5, + }) + const server = controls.get('typescript')! + const gate = server.blockNextNotification('textDocument/didOpen') + const open = manager.openFile('/repo/src/stuck-open.ts', 'contents') + await gate.started + const shutdown = manager.shutdown() + + try { + jest.advanceTimersByTime(5) + await expect(shutdown).resolves.toBeUndefined() + await expect(open).rejects.toThrow('timed out after 5ms') + } finally { + gate.release() + jest.runAllTimers() + jest.useRealTimers() + await Promise.allSettled([open, shutdown]) + } + }) + + test('a stale lifecycle timeout does not stop the replacement generation', async () => { + jest.useFakeTimers() + const { manager, controls } = await createManager({ + lifecycleNotificationTimeoutMs: 5, + }) + const server = controls.get('typescript')! + const gate = server.blockNextNotification('textDocument/didOpen') + const open = manager.openFile('/repo/src/stale-open.ts', 'contents') + await gate.started + server.replaceGeneration() + + try { + jest.advanceTimersByTime(5) + await expect(open).rejects.toThrow('timed out after 5ms') + expect(server.server.state).toBe('running') + expect(server.server.generation).toBe(2) + expect(server.stopCalls).not.toHaveBeenCalled() + } finally { + gate.release() + jest.runAllTimers() + jest.useRealTimers() + await open.catch(() => {}) + } + }) +}) + +describe('LSP generation resynchronization', () => { + test('recognizes request-context changes without throwing on non-object errors', async () => { + const { + isLspDocumentRevisionChanged, + isLspServerGenerationChanged, + LSP_DOCUMENT_REVISION_CHANGED, + LSP_SERVER_GENERATION_CHANGED, + } = await import('./LSPServerManager.js') + + for (const value of [null, undefined, 'context changed']) { + expect(isLspServerGenerationChanged(value)).toBe(false) + expect(isLspDocumentRevisionChanged(value)).toBe(false) + } + expect( + isLspServerGenerationChanged({ code: LSP_SERVER_GENERATION_CHANGED }), + ).toBe(true) + expect( + isLspDocumentRevisionChanged({ code: LSP_DOCUMENT_REVISION_CHANGED }), + ).toBe(true) + }) + + test('generation-bound requests reject instead of replaying opaque params', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/generation-bound.ts' + + await manager.openFile(file, 'one') + const gate = server.blockNextRequest('callHierarchy/incomingCalls') + const request = manager.sendRequestWithGeneration( + file, + 'callHierarchy/incomingCalls', + { item: { data: { generation: 1 } } }, + { expectedGeneration: 1 }, + ) + await gate.started + server.replaceGeneration() + gate.release() + + await expect(request).rejects.toMatchObject({ + code: 'LSP_SERVER_GENERATION_CHANGED', + }) + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(1) + }) + + test('opens current contents on a replacement generation before the request', async () => { + const readDocument = mock(async () => 'contents after crash') + const { manager, controls } = await createManager({ readDocument }) + const server = controls.get('typescript')! + const file = '/repo/src/restart.ts' + + await manager.openFile(file, 'before crash') + server.simulateCrash(false) + expect(manager.isFileOpen(file)).toBe(false) + + await manager.sendRequest(file, 'textDocument/definition', {}) + + expect(server.server.generation).toBe(2) + expect(readDocument).toHaveBeenCalledTimes(1) + expect(server.events.slice(-2).map(event => event.kind)).toEqual([ + 'notification', + 'request', + ]) + const reopen = server.events.at(-2) + expect(reopen).toMatchObject({ + kind: 'notification', + generation: 2, + method: 'textDocument/didOpen', + params: { textDocument: { version: 1, text: 'contents after crash' } }, + }) + }) + + test('generation mismatch is sufficient even when prior cleanup did not run', async () => { + const { manager, controls } = await createManager({ + readDocument: async () => 'replacement contents', + }) + const server = controls.get('typescript')! + const file = '/repo/src/generation.ts' + + await manager.openFile(file, 'old') + server.replaceGeneration() + await manager.sendRequest(file, 'textDocument/hover', {}) + + expect(server.events.slice(-2)).toMatchObject([ + { + kind: 'notification', + generation: 2, + method: 'textDocument/didOpen', + params: { textDocument: { version: 1, text: 'replacement contents' } }, + }, + { kind: 'request', generation: 2, method: 'textDocument/hover' }, + ]) + }) + + test('resynchronizes before retrying a request interrupted by a generation change', async () => { + const { manager, controls } = await createManager({ + readDocument: async () => 'replacement contents', + }) + const server = controls.get('typescript')! + const file = '/repo/src/retry-generation.ts' + const uri = getLspDocumentIdentity(file).fileUri + + await manager.openFile(file, 'old contents') + const gate = server.blockNextRequest( + 'textDocument/hover', + new Error('generation changed'), + ) + const request = manager.sendRequest(file, 'textDocument/hover', { + textDocument: { uri }, + }) + await gate.started + server.replaceGeneration() + gate.release() + + await expect(request).resolves.toBeNull() + expect(server.events.slice(-3)).toMatchObject([ + { kind: 'request', generation: 1, method: 'textDocument/hover' }, + { + kind: 'notification', + generation: 2, + method: 'textDocument/didOpen', + }, + { kind: 'request', generation: 2, method: 'textDocument/hover' }, + ]) + }) + + test('stopping one server clears only that server generation documents', async () => { + const { manager, controls } = await createManager({ + configs: { typescript: TYPESCRIPT_CONFIG, python: PYTHON_CONFIG }, + }) + const tsFile = '/repo/src/file.ts' + const pyFile = '/repo/src/file.py' + + await manager.openFile(tsFile, 'const value = 1') + await manager.openFile(pyFile, 'value = 1') + await controls.get('typescript')!.server.stop() + + expect(manager.isFileOpen(tsFile)).toBe(false) + expect(manager.isFileOpen(pyFile)).toBe(true) + }) +}) + +describe('LSP transport recovery integration', () => { + const scenarios: Array<{ + name: string + terminate(transport: IntegratedTransport): void + }> = [ + { + name: 'JSON-RPC error', + terminate: transport => + transport.emitConnectionError(new Error('connection failed')), + }, + { + name: 'JSON-RPC close', + terminate: transport => transport.emitConnectionClose(), + }, + { + name: 'child-process error', + terminate: transport => + transport.child.emit('error', new Error('process transport error')), + }, + { + name: 'clean child exit', + terminate: transport => transport.child.emit('exit', 0, null), + }, + { + name: 'signal child exit', + terminate: transport => + transport.child.emit('exit', null, 'SIGTERM'), + }, + { + name: 'nonzero child exit', + terminate: transport => transport.child.emit('exit', 1, null), + }, + ] + + for (const scenario of scenarios) { + test(`${scenario.name} recreates the full stack and opens current contents`, async () => { + const transports: IntegratedTransport[] = [] + let currentContent = 'before termination' + const manager = createLSPServerManager({ + loadServerConfigs: async () => ({ + servers: { typescript: TYPESCRIPT_CONFIG }, + }), + createServerInstance: (name, config, instanceOptions) => + createLSPServerInstance(name, config, { + ...instanceOptions, + createClient: (clientName, onCrash) => + createLSPClient(clientName, onCrash, { + spawnProcess: (() => { + const transport = createIntegratedTransport() + transports.push(transport) + return transport.child + }) as LSPClientDependencies['spawnProcess'], + createConnection: () => transports.at(-1)!.connection, + gracefulShutdownTimeoutMs: 5, + }), + }), + readDocument: async () => currentContent, + recordFileActivity: () => {}, + }) + await manager.initialize() + + try { + const file = '/repo/src/integration.ts' + await manager.openFile(file, currentContent) + const server = manager.getServerForFile(file)! + expect(server.generation).toBe(1) + + scenario.terminate(transports[0]!) + expect(server.state).toBe('error') + currentContent = 'after termination' + + await manager.sendRequest(file, 'textDocument/hover', { + textDocument: { uri: 'stale-uri' }, + position: { line: 0, character: 0 }, + }) + + expect(server.generation).toBe(2) + expect(transports).toHaveLength(2) + const replacementEvents = transports[1]!.events + const openIndex = replacementEvents.findIndex( + event => + event.kind === 'notification' && + event.method === 'textDocument/didOpen', + ) + const requestIndex = replacementEvents.findIndex( + event => + event.kind === 'request' && event.method === 'textDocument/hover', + ) + expect(openIndex).toBeGreaterThan(-1) + expect(requestIndex).toBeGreaterThan(openIndex) + expect(replacementEvents[openIndex]!.params).toMatchObject({ + textDocument: { + uri: getLspDocumentIdentity(file).fileUri, + version: 1, + text: 'after termination', + }, + }) + } finally { + await manager.shutdown() + } + }) + } +}) + +describe('LSP request concurrency', () => { + test('two simultaneous first requests send one open before either request', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const gate = server.blockNextNotification('textDocument/didOpen') + const file = '/repo/src/concurrent.ts' + + const first = manager.sendRequest(file, 'textDocument/hover', { request: 1 }) + await gate.started + const second = manager.sendRequest(file, 'textDocument/definition', { + request: 2, + }) + + expect(server.events.filter(event => event.kind === 'request')).toHaveLength(0) + expect( + server.events.filter( + event => + event.kind === 'notification' && + event.method === 'textDocument/didOpen', + ), + ).toHaveLength(1) + + gate.release() + await Promise.all([first, second]) + + expect( + server.events.filter( + event => + event.kind === 'notification' && + event.method === 'textDocument/didOpen', + ), + ).toHaveLength(1) + expect(server.events.filter(event => event.kind === 'request')).toHaveLength(2) + }) + + test('delivers document changes while a stale request is still pending', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/pending-request.ts' + await manager.openFile(file, 'before') + const gate = server.holdNextRequest('textDocument/hover', 'stale response') + const request = manager.sendRequest(file, 'textDocument/hover', {}) + await gate.started + + const changeGate = server.blockNextNotification('textDocument/didChange') + const change = manager.changeFile(file, 'after') + await changeGate.started + + try { + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(1) + changeGate.release() + await change + expect( + documentVersions(server.events, getLspDocumentIdentity(file).fileUri), + ).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + ]) + } finally { + gate.release() + } + + await expect(request).resolves.toBeNull() + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(2) + }) + + test('waits for a queued didChange before publishing a response', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/change-before-response.ts' + await manager.openFile(file, 'before') + const requestGate = server.holdNextRequest( + 'textDocument/hover', + 'stale response', + ) + const request = manager.sendRequest(file, 'textDocument/hover', {}) + await requestGate.started + + const changeGate = server.blockNextNotification('textDocument/didChange') + const change = manager.changeFile(file, 'after') + await changeGate.started + + try { + requestGate.release() + const outcome = await Promise.race([ + request.then(() => 'settled' as const), + new Promise<'pending'>(resolve => + setTimeout(() => resolve('pending'), 20), + ), + ]) + expect(outcome).toBe('pending') + } finally { + changeGate.release() + await Promise.allSettled([change, request]) + } + + await expect(change).resolves.toBeUndefined() + await expect(request).resolves.toBeNull() + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(2) + }) + + test('does not replay a pending request after close and reopen', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/closed-pending-request.ts' + const fileUri = getLspDocumentIdentity(file).fileUri + await manager.openFile(file, 'before close') + const gate = server.holdNextRequest('textDocument/hover', 'stale response') + const request = manager.sendRequest(file, 'textDocument/hover', {}) + await gate.started + + await manager.closeFile(file) + expect(manager.isFileOpen(file)).toBe(false) + await manager.openFile(file, 'after reopen') + expect(manager.isFileOpen(file)).toBe(true) + expect( + server.events.map(event => ({ kind: event.kind, method: event.method })), + ).toEqual([ + { kind: 'notification', method: 'textDocument/didOpen' }, + { kind: 'request', method: 'textDocument/hover' }, + { kind: 'notification', method: 'textDocument/didClose' }, + { kind: 'notification', method: 'textDocument/didOpen' }, + ]) + + gate.release() + await expect(request).rejects.toMatchObject({ + code: 'LSP_DOCUMENT_CLOSED', + }) + expect(manager.isFileOpen(file)).toBe(true) + expect( + documentVersions(server.events, fileUri).filter( + event => event.method === 'textDocument/didOpen', + ), + ).toHaveLength(2) + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(1) + }) + + test('does not retry a closed document after its server generation changes', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/closed-before-replacement.ts' + const fileUri = getLspDocumentIdentity(file).fileUri + await manager.openFile(file, 'before close') + const gate = server.holdNextRequest('textDocument/hover', 'stale response') + const request = manager.sendRequest(file, 'textDocument/hover', {}) + await gate.started + + await manager.closeFile(file) + server.replaceGeneration() + gate.release() + + await expect(request).rejects.toMatchObject({ + code: 'LSP_DOCUMENT_CLOSED', + }) + expect(manager.isFileOpen(file)).toBe(false) + expect(documentVersions(server.events, fileUri)).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + ]) + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(1) + }) + + test('does not recover a request after close is queued behind didChange', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/close-queued-behind-change.ts' + const fileUri = getLspDocumentIdentity(file).fileUri + await manager.openFile(file, 'before') + const requestGate = server.blockNextRequest('textDocument/hover') + const request = manager.sendRequest(file, 'textDocument/hover', {}) + const requestOutcome = request.then( + result => ({ status: 'resolved' as const, result }), + error => ({ status: 'rejected' as const, error }), + ) + await requestGate.started + + const changeGate = server.blockNextNotification('textDocument/didChange') + const change = manager.changeFile(file, 'after') + const changeOutcome = change.then( + () => undefined, + error => error, + ) + await changeGate.started + const close = manager.closeFile(file) + + server.replaceGeneration() + requestGate.release() + changeGate.release() + await expect(changeOutcome).resolves.toBeInstanceOf(Error) + await expect(close).resolves.toBeUndefined() + + expect(await requestOutcome).toMatchObject({ status: 'rejected' }) + expect(manager.isFileOpen(file)).toBe(false) + expect(documentVersions(server.events, fileUri)).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + ]) + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(1) + }) + + test('does not recover after close interrupts the initial didOpen', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/close-during-initial-open.ts' + const gate = server.blockNextNotification('textDocument/didOpen') + const request = manager.sendRequest(file, 'textDocument/hover', {}) + await gate.started + const close = manager.closeFile(file) + server.simulateCrash(false) + gate.release() + + await expect(request).rejects.toMatchObject({ + code: 'LSP_DOCUMENT_CLOSED', + }) + await expect(close).resolves.toBeUndefined() + expect(manager.isFileOpen(file)).toBe(false) + expect( + server.events.filter( + event => + event.kind === 'notification' && + event.method === 'textDocument/didOpen', + ), + ).toHaveLength(1) + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(0) + }) + + test('generation-bound requests also require the prepared document revision', async () => { + const { manager } = await createManager() + const file = '/repo/src/revision-bound.ts' + await manager.openFile(file, 'version one') + + const prepared = await manager.sendRequestWithGeneration( + file, + 'textDocument/prepareCallHierarchy', + {}, + ) + expect(prepared).toBeDefined() + await manager.changeFile(file, 'version two') + + await expect( + manager.sendRequestWithGeneration( + file, + 'callHierarchy/incomingCalls', + { item: { data: 'version one' } }, + { + expectedGeneration: prepared!.serverGeneration, + expectedDocumentRevision: prepared!.documentRevision, + }, + ), + ).rejects.toThrow('changed document revision') + }) + + test('generation-bound requests preserve an explicit close as terminal', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const file = '/repo/src/closed-call-hierarchy.ts' + const fileUri = getLspDocumentIdentity(file).fileUri + await manager.openFile(file, 'version one') + + const prepared = await manager.sendRequestWithGeneration( + file, + 'textDocument/prepareCallHierarchy', + {}, + ) + expect(prepared).toBeDefined() + await manager.closeFile(file) + + await expect( + manager.sendRequestWithGeneration( + file, + 'callHierarchy/incomingCalls', + { item: { data: 'version one' } }, + { + expectedGeneration: prepared!.serverGeneration, + expectedDocumentRevision: prepared!.documentRevision, + expectedDocumentCloseEpoch: prepared!.documentCloseEpoch, + }, + ), + ).rejects.toMatchObject({ code: 'LSP_DOCUMENT_CLOSED' }) + expect( + documentVersions(server.events, fileUri).filter( + event => event.method === 'textDocument/didOpen', + ), + ).toHaveLength(1) + expect( + server.events.filter(event => event.kind === 'request'), + ).toHaveLength(1) + }) + + test('different documents await one shared lazy server initialization', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const gate = server.holdNextStart() + + const first = manager.sendRequest('/repo/src/one.ts', 'textDocument/hover', {}) + await gate.started + const second = manager.sendRequest( + '/repo/src/two.tsx', + 'textDocument/definition', + {}, + ) + await server.waitForStartCalls(2) + + expect(server.startCalls).toHaveBeenCalledTimes(2) + expect(server.events).toHaveLength(0) + + gate.release() + await Promise.all([first, second]) + + expect(server.server.generation).toBe(1) + expect(server.events.filter(event => event.kind === 'request')).toHaveLength(2) + }) + + test('shutdown stops a starting server and rejects its in-flight request', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const gate = server.holdNextStart() + const requestOutcome = manager + .sendRequest('/repo/src/shutdown.ts', 'textDocument/hover', {}) + .then( + () => undefined, + error => error, + ) + + await gate.started + try { + await manager.shutdown() + expect(server.stopCalls).toHaveBeenCalledTimes(1) + expect(await requestOutcome).toBeInstanceOf(Error) + expect(server.server.state).toBe('stopped') + expect(manager.getAllServers().size).toBe(0) + } finally { + gate.release() + await requestOutcome + } + }) +}) + +describe('LSP document identity and diagnostics activity', () => { + test('keeps POSIX document identity canonical and case-sensitive', () => { + if (process.platform === 'win32') return + + const mixedCase = getLspDocumentIdentity('/repo/Source File.ts') + const lowerCase = getLspDocumentIdentity('/repo/source file.ts') + + expect(mixedCase).toEqual({ + resolvedPath: '/repo/Source File.ts', + fileUri: 'file:///repo/Source%20File.ts', + stateKey: 'file:///repo/Source%20File.ts', + activityPath: '/repo/Source File.ts', + }) + expect(lowerCase.stateKey).not.toBe(mixedCase.stateKey) + }) + + test('normalizes Windows drive paths into one stable URI and state key', async () => { + const activityPaths: string[] = [] + const { manager, controls } = await createManager({ + recordFileActivity: filePath => activityPaths.push(filePath), + }) + const server = controls.get('typescript')! + const firstSpelling = String.raw`C:\Repo\Source File.ts` + const secondSpelling = 'c:/repo/source file.ts' + + const firstIdentity = getLspDocumentIdentity(firstSpelling) + const secondIdentity = getLspDocumentIdentity(secondSpelling) + expect(firstIdentity.fileUri).toBe('file:///c:/Repo/Source%20File.ts') + expect(secondIdentity).toMatchObject({ + fileUri: 'file:///c:/repo/source%20file.ts', + stateKey: firstIdentity.stateKey, + }) + + await manager.openFile(firstSpelling, 'one') + await manager.changeFile(secondSpelling, 'two') + + expect(manager.isFileOpen(firstSpelling)).toBe(true) + expect(manager.isFileOpen(secondSpelling)).toBe(true) + expect(documentVersions(server.events, firstIdentity.fileUri)).toEqual([ + { method: 'textDocument/didOpen', version: 1 }, + { method: 'textDocument/didChange', version: 2 }, + ]) + expect(activityPaths).toEqual([ + fileURLToPath(firstIdentity.fileUri), + fileURLToPath(firstIdentity.fileUri), + ]) + }) + + test('case-folds Unicode Windows aliases before URI encoding', () => { + const upper = getLspDocumentIdentity('C:\\Repo\\Ä.ts') + const lower = getLspDocumentIdentity('c:/repo/ä.ts') + + expect(upper.fileUri).not.toBe(lower.fileUri) + expect(upper.stateKey).toBe(lower.stateKey) + }) + + test('reuses the opened Windows URI for requests through a case alias', async () => { + const { manager, controls } = await createManager() + const server = controls.get('typescript')! + const firstSpelling = String.raw`C:\Repo\Source File.ts` + const secondSpelling = 'c:/repo/source file.ts' + const openedUri = getLspDocumentIdentity(firstSpelling).fileUri + const aliasUri = getLspDocumentIdentity(secondSpelling).fileUri + + await manager.openFile(firstSpelling, 'one') + await manager.sendRequest(secondSpelling, 'textDocument/hover', { + textDocument: { uri: aliasUri }, + }) + + expect(server.events.slice(-2)).toMatchObject([ + { + kind: 'notification', + method: 'textDocument/didOpen', + params: { textDocument: { uri: openedUri } }, + }, + { + kind: 'request', + method: 'textDocument/hover', + params: { textDocument: { uri: openedUri } }, + }, + ]) + }) + + test('records activity once for open, change fallback, change, save, and request reopen', async () => { + const recordFileActivity = mock((_filePath: string) => {}) + const { manager, controls } = await createManager({ recordFileActivity }) + const server = controls.get('typescript')! + const file = '/repo/src/activity.ts' + const expectedActivityPath = getLspDocumentIdentity(file).activityPath + + await manager.changeFile(file, 'opened through change') + await manager.changeFile(file, 'changed') + await manager.saveFile(file) + server.replaceGeneration() + await manager.sendRequest(file, 'textDocument/hover', {}) + + expect(recordFileActivity).toHaveBeenCalledTimes(4) + expect(recordFileActivity.mock.calls.map(call => call[0])).toEqual([ + expectedActivityPath, + expectedActivityPath, + expectedActivityPath, + expectedActivityPath, + ]) + }) + + test('records save activity when no document lifecycle is tracked', async () => { + const recordFileActivity = mock((_filePath: string) => {}) + const { manager, controls } = await createManager({ recordFileActivity }) + const server = controls.get('typescript')! + const file = '/repo/src/unopened-save.ts' + const expectedActivityPath = getLspDocumentIdentity(file).activityPath + + await manager.ensureServerStarted(file) + await manager.saveFile(file) + + expect(recordFileActivity).toHaveBeenCalledTimes(1) + expect(recordFileActivity).toHaveBeenCalledWith(expectedActivityPath) + expect( + server.events.some( + event => + event.kind === 'notification' && + event.method === 'textDocument/didSave', + ), + ).toBe(false) + }) + + test('the shared document reader rejects files above the tool limit', async () => { + const directory = mkdtempSync(join(tmpdir(), 'openclaude-lsp-size-')) + const file = join(directory, 'large.ts') + try { + writeFileSync(file, '') + truncateSync(file, MAX_LSP_FILE_SIZE_BYTES + 1) + await expect(readLspDocumentContents(file)).rejects.toBeInstanceOf( + LspDocumentTooLargeError, + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/src/services/lsp/LSPServerManager.ts b/src/services/lsp/LSPServerManager.ts index aadf284c77..36d74b92c1 100644 --- a/src/services/lsp/LSPServerManager.ts +++ b/src/services/lsp/LSPServerManager.ts @@ -1,15 +1,133 @@ -import * as path from 'path' -import { pathToFileURL } from 'url' +import * as path from 'node:path' import { logForDebugging } from '../../utils/debug.js' import { errorMessage } from '../../utils/errors.js' import { logError } from '../../utils/log.js' +import { withTimeout } from '../../utils/sleep.js' import { getAllLspServers } from './config.js' +import { + getLspDocumentIdentity, + readLspDocumentContents, + type LspDocumentIdentity, +} from './documentIdentity.js' import { recordLSPDiagnosticFileActivity } from './LSPDiagnosticRegistry.js' import { createLSPServerInstance, type LSPServerInstance, + type LSPServerInstanceOptions, } from './LSPServerInstance.js' import type { ScopedLspServerConfig } from './types.js' + +type OpenLspDocumentState = { + serverName: string + serverGeneration: number + revision: number + closeEpoch: number + version: number + fileUri: string + activityPath: string +} + +type PendingOpenLspDocumentState = Omit< + OpenLspDocumentState, + 'revision' | 'closeEpoch' +> + +export type LspGenerationRequestResult = { + result: T + serverGeneration: number + documentRevision: number + documentCloseEpoch: number +} + +export const LSP_SERVER_GENERATION_CHANGED = 'LSP_SERVER_GENERATION_CHANGED' +export const LSP_DOCUMENT_REVISION_CHANGED = 'LSP_DOCUMENT_REVISION_CHANGED' +export const LSP_DOCUMENT_CLOSED = 'LSP_DOCUMENT_CLOSED' + +export function isLspServerGenerationChanged(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === LSP_SERVER_GENERATION_CHANGED + ) +} + +export function isLspDocumentRevisionChanged(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === LSP_DOCUMENT_REVISION_CHANGED + ) +} + +function generationChangedError( + serverName: string, + expectedGeneration: number, + actualGeneration: number, +): Error & { code: typeof LSP_SERVER_GENERATION_CHANGED } { + return Object.assign( + new Error( + `LSP server '${serverName}' changed generation from ${expectedGeneration} to ${actualGeneration}`, + ), + { code: LSP_SERVER_GENERATION_CHANGED }, + ) as Error & { code: typeof LSP_SERVER_GENERATION_CHANGED } +} + +function documentRevisionChangedError( + filePath: string, + expectedRevision: number, + actualRevision: number | undefined, +): Error & { code: typeof LSP_DOCUMENT_REVISION_CHANGED } { + return Object.assign( + new Error( + `LSP document '${filePath}' changed document revision from ${expectedRevision} to ${actualRevision ?? 'closed'}`, + ), + { code: LSP_DOCUMENT_REVISION_CHANGED }, + ) as Error & { code: typeof LSP_DOCUMENT_REVISION_CHANGED } +} + +function documentClosedError( + filePath: string, + expectedCloseEpoch: number, + actualCloseEpoch: number, +): Error & { code: typeof LSP_DOCUMENT_CLOSED } { + return Object.assign( + new Error( + `LSP document '${filePath}' was explicitly closed (close epoch changed from ${expectedCloseEpoch} to ${actualCloseEpoch})`, + ), + { code: LSP_DOCUMENT_CLOSED }, + ) as Error & { code: typeof LSP_DOCUMENT_CLOSED } +} + +export type LspGenerationRequestOptions = { + expectedGeneration?: number + expectedDocumentRevision?: number + expectedDocumentCloseEpoch?: number +} + +export type LSPServerManagerDependencies = { + loadServerConfigs: typeof getAllLspServers + createServerInstance: ( + name: string, + config: ScopedLspServerConfig, + options: LSPServerInstanceOptions, + ) => LSPServerInstance + readDocument: (filePath: string) => Promise + recordFileActivity: (filePath: string) => void + lifecycleNotificationTimeoutMs: number +} + +const DEFAULT_LSP_LIFECYCLE_NOTIFICATION_TIMEOUT_MS = 2_000 + +const DEFAULT_DEPENDENCIES: LSPServerManagerDependencies = { + loadServerConfigs: getAllLspServers, + createServerInstance: (name, config, options) => + createLSPServerInstance(name, config, options), + readDocument: readLspDocumentContents, + recordFileActivity: recordLSPDiagnosticFileActivity, + lifecycleNotificationTimeoutMs: + DEFAULT_LSP_LIFECYCLE_NOTIFICATION_TIMEOUT_MS, +} + /** * LSP Server Manager interface returned by createLSPServerManager. * Manages multiple LSP server instances and routes requests based on file extensions. @@ -23,12 +141,19 @@ export type LSPServerManager = { getServerForFile(filePath: string): LSPServerInstance | undefined /** Ensure the appropriate LSP server is started for the given file */ ensureServerStarted(filePath: string): Promise - /** Send a request to the appropriate LSP server for the given file */ + /** Send a request after synchronizing the document to the current server generation */ sendRequest( filePath: string, method: string, params: unknown, ): Promise + /** Send a request and report its server generation and document revision. */ + sendRequestWithGeneration( + filePath: string, + method: string, + params: unknown, + options?: LspGenerationRequestOptions, + ): Promise | undefined> /** Get all running server instances */ getAllServers(): Map /** Synchronize file open to LSP server (sends didOpen notification) */ @@ -39,41 +164,245 @@ export type LSPServerManager = { saveFile(filePath: string): Promise /** Synchronize file close to LSP server (sends didClose notification) */ closeFile(filePath: string): Promise - /** Check if a file is already open on a compatible LSP server */ + /** Check if a file is open on its current compatible LSP server generation */ isFileOpen(filePath: string): boolean } /** * Creates an LSP server manager instance. * - * Manages multiple LSP server instances and routes requests based on file extensions. - * Uses factory function pattern with closures for state encapsulation (avoiding classes). - * - * @returns LSP server manager instance - * - * @example - * const manager = createLSPServerManager() - * await manager.initialize() - * const result = await manager.sendRequest('/path/to/file.ts', 'textDocument/definition', params) - * await manager.shutdown() + * Server processes remain lazy-started. Document lifecycle operations are + * serialized per canonical document key, while unrelated documents remain + * independent after sharing any in-flight server initialization. */ -export function createLSPServerManager(): LSPServerManager { - // Private state managed via closures - const servers: Map = new Map() - const extensionMap: Map = new Map() - // Track which files have been opened on which servers (URI -> server name) - const openedFiles: Map = new Map() - - /** - * Initialize the manager by loading all configured LSP servers. - * - * @throws {Error} If configuration loading fails - */ +export function createLSPServerManager( + dependencyOverrides: Partial = {}, +): LSPServerManager { + const dependencies = { + ...DEFAULT_DEPENDENCIES, + ...dependencyOverrides, + lifecycleNotificationTimeoutMs: + dependencyOverrides.lifecycleNotificationTimeoutMs ?? + DEFAULT_LSP_LIFECYCLE_NOTIFICATION_TIMEOUT_MS, + } + const servers = new Map() + const extensionMap = new Map() + const openedDocuments = new Map() + const documentRevisions = new Map() + const documentCloseEpochs = new Map() + const documentOperations = new Map>() + let shuttingDown = false + + function invalidateServerDocuments( + serverName: string, + serverGeneration: number, + ): void { + for (const [documentKey, state] of openedDocuments) { + if ( + state.serverName === serverName && + state.serverGeneration === serverGeneration + ) { + openedDocuments.delete(documentKey) + } + } + } + + function getDocumentCloseEpoch(documentKey: string): number { + return documentCloseEpochs.get(documentKey) ?? 0 + } + + function advanceDocumentRevision(documentKey: string): number { + const revision = (documentRevisions.get(documentKey) ?? 0) + 1 + documentRevisions.set(documentKey, revision) + return revision + } + + async function withDocumentLock( + documentKey: string, + operation: () => Promise, + ): Promise { + if (shuttingDown) { + throw new Error('LSP server manager is shutting down') + } + const previous = documentOperations.get(documentKey) ?? Promise.resolve() + let release!: () => void + const gate = new Promise(resolve => { + release = resolve + }) + const tail = previous.catch(() => {}).then(() => gate) + documentOperations.set(documentKey, tail) + + await previous.catch(() => {}) + try { + return await operation() + } finally { + release() + if (documentOperations.get(documentKey) === tail) { + documentOperations.delete(documentKey) + } + } + } + + function isCurrentDocumentState( + state: OpenLspDocumentState | undefined, + server: LSPServerInstance, + ): state is OpenLspDocumentState { + return ( + state !== undefined && + state.serverName === server.name && + state.serverGeneration === server.generation && + server.state === 'running' + ) + } + + function getCurrentDocumentState( + identity: LspDocumentIdentity, + server: LSPServerInstance, + ): OpenLspDocumentState | undefined { + const state = openedDocuments.get(identity.stateKey) + if (isCurrentDocumentState(state, server)) return state + if (state) openedDocuments.delete(identity.stateKey) + return undefined + } + + function useDocumentUri(params: unknown, fileUri: string): unknown { + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return params + } + const requestParams = params as Record + const textDocument = requestParams.textDocument + if ( + !textDocument || + typeof textDocument !== 'object' || + Array.isArray(textDocument) + ) { + return params + } + return { + ...requestParams, + textDocument: { + ...(textDocument as Record), + uri: fileUri, + }, + } + } + + async function notifyAndCommit( + server: LSPServerInstance, + identity: LspDocumentIdentity, + description: string, + method: string, + params: unknown, + nextState: (targetGeneration: number) => PendingOpenLspDocumentState, + ): Promise { + const targetGeneration = server.generation + try { + await sendStrictLifecycleNotification( + server, + targetGeneration, + method, + params, + ) + } catch (error) { + openedDocuments.delete(identity.stateKey) + const syncError = new Error(`${description}: ${errorMessage(error)}`) + logError(syncError) + throw syncError + } + + if ( + server.state !== 'running' || + server.generation !== targetGeneration + ) { + openedDocuments.delete(identity.stateKey) + const syncError = new Error( + `${description}: LSP server generation changed during notification`, + ) + logError(syncError) + throw syncError + } + + openedDocuments.set(identity.stateKey, { + ...nextState(targetGeneration), + revision: advanceDocumentRevision(identity.stateKey), + closeEpoch: getDocumentCloseEpoch(identity.stateKey), + }) + return targetGeneration + } + + async function sendStrictLifecycleNotification( + server: LSPServerInstance, + targetGeneration: number, + method: string, + params: unknown, + ): Promise { + try { + await withTimeout( + server.sendNotificationStrict(method, params), + dependencies.lifecycleNotificationTimeoutMs, + `LSP lifecycle notification '${method}' timed out after ${dependencies.lifecycleNotificationTimeoutMs}ms`, + ) + } catch (error) { + if (server.generation === targetGeneration) { + await server.stop().catch(stopError => { + logForDebugging( + `Failed to stop LSP server '${server.name}' after notification failure: ${errorMessage(stopError)}`, + ) + }) + } + throw error + } + } + + async function synchronizeOpenUnlocked( + filePath: string, + content: string, + identity: LspDocumentIdentity, + server: LSPServerInstance, + ): Promise { + if (getCurrentDocumentState(identity, server)) { + logForDebugging( + `LSP: File already open, skipping didOpen for ${filePath}`, + ) + return + } + + const extension = path.extname(filePath).toLowerCase() + const languageId = + server.config.extensionToLanguage[extension] || 'plaintext' + const targetGeneration = await notifyAndCommit( + server, + identity, + `Failed to sync file open ${filePath}`, + 'textDocument/didOpen', + { + textDocument: { + uri: identity.fileUri, + languageId, + version: 1, + text: content, + }, + }, + generation => ({ + serverName: server.name, + serverGeneration: generation, + version: 1, + fileUri: identity.fileUri, + activityPath: identity.activityPath, + }), + ) + logForDebugging( + `LSP: Sent didOpen for ${filePath} (languageId: ${languageId}, generation: ${targetGeneration})`, + ) + } + + /** Initialize the manager by loading configured server definitions. */ async function initialize(): Promise { + shuttingDown = false let serverConfigs: Record try { - const result = await getAllLspServers() + const result = await dependencies.loadServerConfigs() serverConfigs = result.servers logForDebugging( `[LSP SERVER MANAGER] getAllLspServers returned ${Object.keys(serverConfigs).length} server(s)`, @@ -86,10 +415,8 @@ export function createLSPServerManager(): LSPServerManager { throw error } - // Build extension → server mapping for (const [serverName, config] of Object.entries(serverConfigs)) { try { - // Validate config before using it if (!config.command) { throw new Error( `Server ${serverName} missing required 'command' field`, @@ -104,33 +431,29 @@ export function createLSPServerManager(): LSPServerManager { ) } - // Map file extensions to this server (derive from extensionToLanguage) - const fileExtensions = Object.keys(config.extensionToLanguage) - for (const ext of fileExtensions) { - const normalized = ext.toLowerCase() - if (!extensionMap.has(normalized)) { - extensionMap.set(normalized, []) - } - const serverList = extensionMap.get(normalized) - if (serverList) { - serverList.push(serverName) - } + for (const extension of Object.keys(config.extensionToLanguage)) { + const normalized = extension.toLowerCase() + const serverNames = extensionMap.get(normalized) ?? [] + serverNames.push(serverName) + extensionMap.set(normalized, serverNames) } - // Create server instance - const instance = createLSPServerInstance(serverName, config) + const instance = dependencies.createServerInstance( + serverName, + config, + { + onUnavailable: generation => + invalidateServerDocuments(serverName, generation), + }, + ) servers.set(serverName, instance) - // Register handler for workspace/configuration requests from the server - // Some servers (like TypeScript) send these even when we say we don't support them instance.onRequest( 'workspace/configuration', (params: { items: Array<{ section?: string }> }) => { logForDebugging( `LSP: Received workspace/configuration request from ${serverName}`, ) - // Return empty/null config for each requested item - // This satisfies the protocol without providing actual configuration return params.items.map(() => null) }, ) @@ -141,85 +464,70 @@ export function createLSPServerManager(): LSPServerManager { `Failed to initialize LSP server ${serverName}: ${err.message}`, ), ) - // Continue with other servers - don't fail entire initialization } } logForDebugging(`LSP manager initialized with ${servers.size} servers`) } - /** - * Shutdown all running servers and clear state. - * Only servers in 'running' state are explicitly stopped; - * servers in other states are cleared without shutdown. - * - * @throws {Error} If one or more servers fail to stop - */ + /** Stop every server and clear manager-owned state. */ async function shutdown(): Promise { + shuttingDown = true + const operations = Array.from(documentOperations.values()) const toStop = Array.from(servers.entries()).filter( - ([, s]) => s.state === 'running' || s.state === 'error', + ([, server]) => server.state !== 'stopped', ) - const results = await Promise.allSettled( toStop.map(([, server]) => server.stop()), ) + await Promise.allSettled(operations) servers.clear() extensionMap.clear() - openedFiles.clear() + openedDocuments.clear() + documentRevisions.clear() + documentCloseEpochs.clear() + documentOperations.clear() const errors = results - .map((r, i) => - r.status === 'rejected' - ? `${toStop[i]![0]}: ${errorMessage(r.reason)}` + .map((result, index) => + result.status === 'rejected' + ? `${toStop[index]![0]}: ${errorMessage(result.reason)}` : null, ) - .filter((e): e is string => e !== null) + .filter((error): error is string => error !== null) if (errors.length > 0) { - const err = new Error( + const shutdownError = new Error( `Failed to stop ${errors.length} LSP server(s): ${errors.join('; ')}`, ) - logError(err) - throw err + logError(shutdownError) + throw shutdownError } } - /** - * Get the LSP server instance for a given file path. - * If multiple servers handle the same extension, returns the first registered server. - * Returns undefined if no server handles this file type. - */ + /** Return the first configured server for the file extension. */ function getServerForFile(filePath: string): LSPServerInstance | undefined { - const ext = path.extname(filePath).toLowerCase() - const serverNames = extensionMap.get(ext) - - if (!serverNames || serverNames.length === 0) { - return undefined - } - - // Use first server (can add priority later) - const serverName = serverNames[0] - if (!serverName) { - return undefined - } - - return servers.get(serverName) + const extension = path.extname(filePath).toLowerCase() + const serverName = extensionMap.get(extension)?.[0] + return serverName ? servers.get(serverName) : undefined } - /** - * Ensure the appropriate LSP server is started for the given file. - * Returns undefined if no server handles this file type. - * - * @throws {Error} If server fails to start - */ + /** Lazily start or await the server selected for this file. */ async function ensureServerStarted( filePath: string, ): Promise { + if (shuttingDown) { + throw new Error('LSP server manager is shutting down') + } const server = getServerForFile(filePath) if (!server) return undefined - if (server.state === 'stopped' || server.state === 'error') { + if ( + server.state === 'stopped' || + server.state === 'error' || + server.state === 'starting' + ) { try { await server.start() } catch (error) { @@ -233,25 +541,211 @@ export function createLSPServerManager(): LSPServerManager { } } + if (shuttingDown) { + throw new Error('LSP server manager is shutting down') + } + + if (server.state !== 'running') { + throw new Error( + `LSP server '${server.name}' is not ready for file ${filePath}: server is ${server.state}`, + ) + } + return server } - /** - * Send a request to the appropriate LSP server for the given file. - * Returns undefined if no server handles this file type. - * - * @throws {Error} If server fails to start or request fails - */ - async function sendRequest( + /** Synchronize under the document lock, then send without blocking later edits. */ + async function sendRequestWithGeneration( filePath: string, method: string, params: unknown, - ): Promise { - const server = await ensureServerStarted(filePath) - if (!server) return undefined - + options: LspGenerationRequestOptions = {}, + ): Promise | undefined> { + const identity = getLspDocumentIdentity(filePath) + const requestCloseEpoch = + options.expectedDocumentCloseEpoch ?? + getDocumentCloseEpoch(identity.stateKey) try { - return await server.sendRequest(method, params) + for (let generationRetry = 0; generationRetry <= 1; generationRetry++) { + let server: LSPServerInstance | undefined + let requestState: OpenLspDocumentState | undefined + let requestGeneration = 0 + try { + const context = await withDocumentLock( + identity.stateKey, + async () => { + const currentDocumentCloseEpoch = getDocumentCloseEpoch( + identity.stateKey, + ) + if ( + currentDocumentCloseEpoch !== requestCloseEpoch + ) { + throw documentClosedError( + filePath, + requestCloseEpoch, + currentDocumentCloseEpoch, + ) + } + + server = await ensureServerStarted(filePath) + if (!server) return undefined + requestGeneration = server.generation + const expectedGeneration = options.expectedGeneration + if ( + expectedGeneration !== undefined && + server.generation !== expectedGeneration + ) { + throw generationChangedError( + server.name, + expectedGeneration, + server.generation, + ) + } + + if (!getCurrentDocumentState(identity, server)) { + const content = await dependencies.readDocument( + identity.resolvedPath, + ) + dependencies.recordFileActivity(identity.activityPath) + await synchronizeOpenUnlocked( + filePath, + content, + identity, + server, + ) + } + + const state = getCurrentDocumentState(identity, server) + if (!state) { + throw new Error( + `LSP document ${filePath} is not synchronized to server generation ${server.generation}`, + ) + } + requestGeneration = state.serverGeneration + if ( + expectedGeneration !== undefined && + state.serverGeneration !== expectedGeneration + ) { + throw generationChangedError( + server.name, + expectedGeneration, + state.serverGeneration, + ) + } + const expectedDocumentRevision = + options.expectedDocumentRevision + if ( + expectedDocumentRevision !== undefined && + state.revision !== expectedDocumentRevision + ) { + throw documentRevisionChangedError( + filePath, + expectedDocumentRevision, + state.revision, + ) + } + requestState = state + return { server, state } + }, + ) + if (!context) return undefined + + const result = await context.server.sendRequest( + method, + useDocumentUri(params, context.state.fileUri), + ) + return await withDocumentLock(identity.stateKey, async () => { + const currentDocumentCloseEpoch = getDocumentCloseEpoch( + identity.stateKey, + ) + if (currentDocumentCloseEpoch !== requestCloseEpoch) { + throw documentClosedError( + filePath, + requestCloseEpoch, + currentDocumentCloseEpoch, + ) + } + if ( + context.server.state !== 'running' || + context.server.generation !== context.state.serverGeneration + ) { + throw new Error( + `LSP request '${method}' aborted because server '${context.server.name}' changed generation or became unavailable`, + ) + } + if (openedDocuments.get(identity.stateKey) !== context.state) { + throw new Error( + `LSP request '${method}' aborted because document ${filePath} changed while the request was pending`, + ) + } + return { + result, + serverGeneration: context.state.serverGeneration, + documentRevision: context.state.revision, + documentCloseEpoch: context.state.closeEpoch, + } + }) + } catch (error) { + const currentDocumentCloseEpoch = getDocumentCloseEpoch( + identity.stateKey, + ) + if (currentDocumentCloseEpoch !== requestCloseEpoch) { + throw documentClosedError( + filePath, + requestCloseEpoch, + currentDocumentCloseEpoch, + ) + } + if ( + isLspServerGenerationChanged(error) || + isLspDocumentRevisionChanged(error) + ) { + throw error + } + if ( + server && + (server.generation !== requestGeneration || + server.state !== 'running') + ) { + if (openedDocuments.get(identity.stateKey) === requestState) { + openedDocuments.delete(identity.stateKey) + } + if (options.expectedGeneration !== undefined) { + throw generationChangedError( + server.name, + options.expectedGeneration, + server.generation, + ) + } + if (generationRetry === 0) continue + } else if (requestState) { + const currentState = openedDocuments.get(identity.stateKey) + if ( + options.expectedDocumentRevision !== undefined && + currentState !== requestState + ) { + throw documentRevisionChangedError( + filePath, + options.expectedDocumentRevision, + currentState?.revision, + ) + } + if ( + currentState !== undefined && + currentState !== requestState && + options.expectedGeneration === undefined && + generationRetry === 0 + ) { + continue + } + } + throw error + } + } + + throw new Error( + `LSP request failed for file ${filePath}: server generation or document state kept changing`, + ) } catch (error) { const err = error as Error logError( @@ -263,154 +757,153 @@ export function createLSPServerManager(): LSPServerManager { } } - // Return public interface + async function sendRequest( + filePath: string, + method: string, + params: unknown, + ): Promise { + return (await sendRequestWithGeneration(filePath, method, params)) + ?.result + } + function getAllServers(): Map { return servers } async function openFile(filePath: string, content: string): Promise { - const server = await ensureServerStarted(filePath) - if (!server) return - - const resolvedFilePath = path.resolve(filePath) - const fileUri = pathToFileURL(resolvedFilePath).href - recordLSPDiagnosticFileActivity(resolvedFilePath) - - // Skip if already opened on this server - if (openedFiles.get(fileUri) === server.name) { - logForDebugging( - `LSP: File already open, skipping didOpen for ${filePath}`, - ) - return - } - - // Get language ID from server's extensionToLanguage mapping - const ext = path.extname(filePath).toLowerCase() - const languageId = server.config.extensionToLanguage[ext] || 'plaintext' - - try { - await server.sendNotification('textDocument/didOpen', { - textDocument: { - uri: fileUri, - languageId, - version: 1, - text: content, - }, - }) - // Track that this file is now open on this server - openedFiles.set(fileUri, server.name) - logForDebugging( - `LSP: Sent didOpen for ${filePath} (languageId: ${languageId})`, - ) - } catch (error) { - const err = new Error( - `Failed to sync file open ${filePath}: ${errorMessage(error)}`, + const identity = getLspDocumentIdentity(filePath) + await withDocumentLock(identity.stateKey, async () => { + const server = await ensureServerStarted(filePath) + if (!server) return + const state = getCurrentDocumentState(identity, server) + dependencies.recordFileActivity( + state?.activityPath ?? identity.activityPath, ) - logError(err) - // Re-throw to propagate error to caller - throw err - } + await synchronizeOpenUnlocked(filePath, content, identity, server) + }) } async function changeFile(filePath: string, content: string): Promise { - const server = getServerForFile(filePath) - if (!server || server.state !== 'running') { - return openFile(filePath, content) - } - - const resolvedFilePath = path.resolve(filePath) - const fileUri = pathToFileURL(resolvedFilePath).href - - // If file hasn't been opened on this server yet, open it first - // LSP servers require didOpen before didChange - if (openedFiles.get(fileUri) !== server.name) { - return openFile(filePath, content) - } - - recordLSPDiagnosticFileActivity(resolvedFilePath) + const identity = getLspDocumentIdentity(filePath) + await withDocumentLock(identity.stateKey, async () => { + const server = await ensureServerStarted(filePath) + if (!server) return + + const state = getCurrentDocumentState(identity, server) + dependencies.recordFileActivity( + state?.activityPath ?? identity.activityPath, + ) + if (!state) { + await synchronizeOpenUnlocked(filePath, content, identity, server) + return + } - try { - await server.sendNotification('textDocument/didChange', { - textDocument: { - uri: fileUri, - version: 1, + const nextVersion = state.version + 1 + await notifyAndCommit( + server, + identity, + `Failed to sync file change ${filePath}`, + 'textDocument/didChange', + { + textDocument: { + uri: state.fileUri, + version: nextVersion, + }, + contentChanges: [{ text: content }], }, - contentChanges: [{ text: content }], - }) - logForDebugging(`LSP: Sent didChange for ${filePath}`) - } catch (error) { - const err = new Error( - `Failed to sync file change ${filePath}: ${errorMessage(error)}`, + () => ({ + ...state, + version: nextVersion, + }), ) - logError(err) - // Re-throw to propagate error to caller - throw err - } + logForDebugging( + `LSP: Sent didChange for ${filePath} (version: ${nextVersion})`, + ) + }) } - /** - * Save a file in LSP servers (sends didSave notification) - * Called after file is written to disk to trigger diagnostics - */ + /** Send didSave after earlier same-document lifecycle operations settle. */ async function saveFile(filePath: string): Promise { - const server = getServerForFile(filePath) - if (!server || server.state !== 'running') return - - const resolvedFilePath = path.resolve(filePath) - recordLSPDiagnosticFileActivity(resolvedFilePath) - - try { - await server.sendNotification('textDocument/didSave', { - textDocument: { - uri: pathToFileURL(resolvedFilePath).href, - }, - }) - logForDebugging(`LSP: Sent didSave for ${filePath}`) - } catch (error) { - const err = new Error( - `Failed to sync file save ${filePath}: ${errorMessage(error)}`, + const identity = getLspDocumentIdentity(filePath) + await withDocumentLock(identity.stateKey, async () => { + const server = getServerForFile(filePath) + if (!server || server.state !== 'running') return + const state = getCurrentDocumentState(identity, server) + dependencies.recordFileActivity( + state?.activityPath ?? identity.activityPath, ) - logError(err) - // Re-throw to propagate error to caller - throw err - } + if (!state) return + + // Best-effort by design: didSave carries no version or content, so a + // dropped notification cannot desynchronize the document version space. + try { + await withTimeout( + server.sendNotification('textDocument/didSave', { + textDocument: { + uri: state.fileUri, + }, + }), + dependencies.lifecycleNotificationTimeoutMs, + `LSP lifecycle notification 'textDocument/didSave' timed out after ${dependencies.lifecycleNotificationTimeoutMs}ms`, + ) + logForDebugging(`LSP: Sent didSave for ${filePath}`) + } catch (error) { + logForDebugging( + `LSP: Best-effort didSave failed for ${filePath}: ${errorMessage(error)}`, + ) + } + }) } - /** - * Close a file in LSP servers (sends didClose notification) - * - * NOTE: Currently available but not yet integrated with compact flow. - * TODO: Integrate with compact - call closeFile() when compact removes files from context - * This will notify LSP servers that files are no longer in active use. - */ + /** Close the current document lifecycle, forgetting local state on every outcome. */ async function closeFile(filePath: string): Promise { - const server = getServerForFile(filePath) - if (!server || server.state !== 'running') return - - const fileUri = pathToFileURL(path.resolve(filePath)).href + const identity = getLspDocumentIdentity(filePath) + documentCloseEpochs.set( + identity.stateKey, + getDocumentCloseEpoch(identity.stateKey) + 1, + ) + await withDocumentLock(identity.stateKey, async () => { + const state = openedDocuments.get(identity.stateKey) + if (!state) return + openedDocuments.delete(identity.stateKey) + + const server = servers.get(state.serverName) + if ( + !server || + server.state !== 'running' || + server.generation !== state.serverGeneration + ) { + return + } - try { - await server.sendNotification('textDocument/didClose', { - textDocument: { - uri: fileUri, - }, - }) - // Remove from tracking so file can be reopened later - openedFiles.delete(fileUri) - logForDebugging(`LSP: Sent didClose for ${filePath}`) - } catch (error) { - const err = new Error( - `Failed to sync file close ${filePath}: ${errorMessage(error)}`, - ) - logError(err) - // Re-throw to propagate error to caller - throw err - } + try { + await sendStrictLifecycleNotification( + server, + state.serverGeneration, + 'textDocument/didClose', + { + textDocument: { uri: state.fileUri }, + }, + ) + logForDebugging(`LSP: Sent didClose for ${filePath}`) + } catch (error) { + const syncError = new Error( + `Failed to sync file close ${filePath}: ${errorMessage(error)}`, + ) + logError(syncError) + throw syncError + } + }) } function isFileOpen(filePath: string): boolean { - const fileUri = pathToFileURL(path.resolve(filePath)).href - return openedFiles.has(fileUri) + const identity = getLspDocumentIdentity(filePath) + const state = openedDocuments.get(identity.stateKey) + if (!state) return false + const server = servers.get(state.serverName) + if (server && isCurrentDocumentState(state, server)) return true + openedDocuments.delete(identity.stateKey) + return false } return { @@ -419,6 +912,7 @@ export function createLSPServerManager(): LSPServerManager { getServerForFile, ensureServerStarted, sendRequest, + sendRequestWithGeneration, getAllServers, openFile, changeFile, diff --git a/src/services/lsp/documentIdentity.ts b/src/services/lsp/documentIdentity.ts new file mode 100644 index 0000000000..7b98d1be98 --- /dev/null +++ b/src/services/lsp/documentIdentity.ts @@ -0,0 +1,119 @@ +import { constants } from 'node:fs' +import { open } from 'node:fs/promises' +import * as path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +export const MAX_LSP_FILE_SIZE_BYTES = 10_000_000 + +export class LspDocumentTooLargeError extends Error { + readonly sizeBytes: number + + constructor(sizeBytes: number) { + super( + `File too large for LSP analysis (${Math.ceil(sizeBytes / 1_000_000)}MB exceeds 10MB limit)`, + ) + this.name = 'LspDocumentTooLargeError' + this.sizeBytes = sizeBytes + } +} + +export type LspDocumentIdentity = { + resolvedPath: string + fileUri: string + stateKey: string + activityPath: string +} + +const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[\\/]/ + +function encodeWindowsFileUri(filePath: string): string { + const normalized = filePath.replaceAll('\\', '/') + const [drive, ...segments] = normalized.split('/') + const encodedSegments = segments.map(segment => + encodeURIComponent(segment).replace(/[!'()*]/g, character => + `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ), + ) + return `file:///${drive?.toLowerCase()}/${encodedSegments.join('/')}` +} + +/** + * Resolve the filesystem path and stable URI/key used for one LSP document. + * Windows document identity is case-insensitive; POSIX identity is not. + */ +export function getLspDocumentIdentity( + filePath: string, +): LspDocumentIdentity { + if (process.platform === 'win32' || WINDOWS_DRIVE_PATH.test(filePath)) { + const resolvedPath = path.win32.resolve(filePath) + if (WINDOWS_DRIVE_PATH.test(resolvedPath)) { + const fileUri = encodeWindowsFileUri(resolvedPath) + return { + resolvedPath, + fileUri, + stateKey: encodeWindowsFileUri(resolvedPath.toLowerCase()), + activityPath: fileURLToPath(fileUri), + } + } + + const fileUri = pathToFileURL(resolvedPath).href + return { + resolvedPath, + fileUri, + stateKey: pathToFileURL(resolvedPath.toLowerCase()).href, + activityPath: fileURLToPath(fileUri), + } + } + + const resolvedPath = path.resolve(filePath) + const fileUri = pathToFileURL(resolvedPath).href + return { + resolvedPath, + fileUri, + stateKey: fileUri, + activityPath: resolvedPath, + } +} + +/** Read a complete LSP document without bypassing the tool's size policy. */ +export async function readLspDocumentContents( + filePath: string, +): Promise { + const handle = await open( + filePath, + constants.O_RDONLY | constants.O_NONBLOCK, + ) + try { + const stats = await handle.stat() + if (!stats.isFile()) { + throw new Error(`LSP document is not a regular file: ${filePath}`) + } + if (stats.size > MAX_LSP_FILE_SIZE_BYTES) { + throw new LspDocumentTooLargeError(stats.size) + } + + const chunks: Buffer[] = [] + let totalBytes = 0 + while (totalBytes <= MAX_LSP_FILE_SIZE_BYTES) { + const bytesRemaining = MAX_LSP_FILE_SIZE_BYTES + 1 - totalBytes + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, bytesRemaining)) + const { bytesRead } = await handle.read( + buffer, + 0, + buffer.length, + null, + ) + if (bytesRead === 0) break + + totalBytes += bytesRead + if (totalBytes > MAX_LSP_FILE_SIZE_BYTES) { + throw new LspDocumentTooLargeError(totalBytes) + } + chunks.push(buffer.subarray(0, bytesRead)) + } + + return Buffer.concat(chunks, totalBytes).toString('utf-8') + } finally { + await handle.close() + } +} diff --git a/src/services/lsp/manager.availability.test.ts b/src/services/lsp/manager.availability.test.ts new file mode 100644 index 0000000000..e446c5ecf3 --- /dev/null +++ b/src/services/lsp/manager.availability.test.ts @@ -0,0 +1,134 @@ +import { execFileSync } from 'node:child_process' +import { expect, test } from 'bun:test' + +// Keep a pristine Bun module graph: tools.lsp.test.ts installs a process-wide +// mock for manager.js, so in-process behavior would depend on test-file order. +// Allow assertions and cleanup to finish after the child's 30-second deadline. +test('bounds failed starts before advertising server availability', () => { + const managerModuleUrl = new URL('./manager.ts', import.meta.url).href + const instanceModuleUrl = new URL( + './LSPServerInstance.ts', + import.meta.url, + ).href + const output = execFileSync( + process.execPath, + [ + '-e', + ` + const manager = await import(${JSON.stringify(managerModuleUrl)}) + const { createLSPServerInstance } = await import(${JSON.stringify(instanceModuleUrl)}) + const { + _resetLspManagerForTesting, + _setLspManagerForTesting, + isLspConnected, + } = manager + + const config = { + command: 'test-lsp', + extensionToLanguage: { '.ts': 'typescript' }, + maxRestarts: 1, + scope: 'project', + source: 'test', + } + + const runScenario = async failurePhase => { + let failureEnabled = true + let initialized = false + let startCalls = 0 + let initializeCalls = 0 + const client = { + get capabilities() { return {} }, + get isInitialized() { return initialized }, + async start() { + startCalls++ + if (failureEnabled && failurePhase === 'spawn') { + throw new Error('spawn failed') + } + }, + async initialize() { + initializeCalls++ + if (failureEnabled && failurePhase === 'initialize-timeout') { + return await new Promise(() => {}) + } + initialized = true + return { capabilities: {} } + }, + async sendRequest() {}, + async sendNotification() {}, + async sendNotificationStrict() {}, + onNotification() {}, + onRequest() {}, + async stop() { initialized = false }, + } + const instance = createLSPServerInstance( + 'typescript', + config, + { + createClient: () => client, + defaultStartupTimeoutMs: 0, + }, + ) + _setLspManagerForTesting({ + getAllServers: () => new Map([['typescript', instance]]), + }) + + const availability = [isLspConnected()] + const errors = [] + for (let attempt = 0; attempt < 2; attempt++) { + await instance.start().catch(error => errors.push(error.message)) + availability.push(isLspConnected()) + } + await instance.start().catch(error => errors.push(error.message)) + availability.push(isLspConnected()) + + failureEnabled = false + await instance.restart() + availability.push(isLspConnected()) + + return { + availability, + errors, + generation: instance.generation, + initializeCalls, + startCalls, + } + } + + const results = { + initiallyAvailable: isLspConnected(), + spawn: await runScenario('spawn'), + timeout: await runScenario('initialize-timeout'), + } + _resetLspManagerForTesting() + process.stdout.write(JSON.stringify(results)) + `, + ], + { encoding: 'utf8', timeout: 30_000 }, + ) + + expect(JSON.parse(output)).toEqual({ + initiallyAvailable: false, + spawn: { + availability: [true, true, false, false, true], + errors: [ + 'spawn failed', + 'spawn failed', + "LSP server 'typescript' exceeded max automatic recovery attempts (1)", + ], + generation: 1, + initializeCalls: 1, + startCalls: 3, + }, + timeout: { + availability: [true, true, false, false, true], + errors: [ + "LSP server 'typescript' timed out after 0ms during initialization", + "LSP server 'typescript' timed out after 0ms during initialization", + "LSP server 'typescript' exceeded max automatic recovery attempts (1)", + ], + generation: 1, + initializeCalls: 3, + startCalls: 3, + }, + }) +}, 35_000) diff --git a/src/services/lsp/manager.ts b/src/services/lsp/manager.ts index b9dc623c02..99641df117 100644 --- a/src/services/lsp/manager.ts +++ b/src/services/lsp/manager.ts @@ -46,12 +46,24 @@ let initializationPromise: Promise | undefined * tests on the same shard. */ export function _resetLspManagerForTesting(): void { + lspManagerInstance = undefined initializationState = 'not-started' initializationError = undefined initializationPromise = undefined initializationGeneration++ } +/** Install a complete manager double without mutating the module graph. */ +export function _setLspManagerForTesting( + manager: LSPServerManager | undefined, +): void { + lspManagerInstance = manager + initializationState = manager ? 'success' : 'not-started' + initializationError = undefined + initializationPromise = undefined + initializationGeneration++ +} + /** * Get the singleton LSP server manager instance. * Returns undefined if not yet initialized, initialization failed, or still pending. @@ -94,17 +106,15 @@ export function getInitializationStatus(): } /** - * Check whether at least one language server is connected and healthy. - * Backs LSPTool.isEnabled(). + * Check whether at least one language server is configured and can be started + * or restarted lazily. Backs LSPTool.isEnabled(). */ export function isLspConnected(): boolean { if (initializationState === 'failed') return false const manager = getLspServerManager() if (!manager) return false - const servers = manager.getAllServers() - if (servers.size === 0) return false - for (const server of servers.values()) { - if (server.state !== 'error') return true + for (const server of manager.getAllServers().values()) { + if (!server.isCrashRecoveryExhausted) return true } return false } diff --git a/src/services/lsp/passiveFeedback.test.ts b/src/services/lsp/passiveFeedback.test.ts new file mode 100644 index 0000000000..30987c406c --- /dev/null +++ b/src/services/lsp/passiveFeedback.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' +import { resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import type { Diagnostic } from 'vscode-languageserver-protocol' +import { formatDiagnosticsForAttachment } from './passiveFeedback.js' + +describe('formatDiagnosticsForAttachment', () => { + test.each([ + ['plain text', 'Missing import'], + ['markdown', { kind: 'markdown', value: '**Missing import**' }], + ['plaintext markup', { kind: 'plaintext', value: 'Missing import' }], + ] satisfies [string, Diagnostic['message']][])( + 'preserves diagnostic text and metadata for %s messages', + (_label, message) => { + const uri = pathToFileURL(resolve('example.ts')).href + const range = { + start: { line: 2, character: 3 }, + end: { line: 2, character: 9 }, + } + + expect(formatDiagnosticsForAttachment({ + uri, + diagnostics: [{ message, range, severity: 2, source: 'typescript', code: 2304 }], + })).toEqual([{ + uri: fileURLToPath(uri), + diagnostics: [{ + message: typeof message === 'string' ? message : message.value, + range, + severity: 'Warning', + source: 'typescript', + code: '2304', + }], + }]) + }, + ) +}) diff --git a/src/services/lsp/passiveFeedback.ts b/src/services/lsp/passiveFeedback.ts index 53de258f67..edc5163c65 100644 --- a/src/services/lsp/passiveFeedback.ts +++ b/src/services/lsp/passiveFeedback.ts @@ -61,17 +61,8 @@ export function formatDiagnosticsForAttachment( } const diagnostics = params.diagnostics.map( - (diag: { - message: string - severity?: number - range: { - start: { line: number; character: number } - end: { line: number; character: number } - } - source?: string - code?: string | number - }) => ({ - message: diag.message, + diag => ({ + message: typeof diag.message === 'string' ? diag.message : diag.message.value, severity: mapLSPSeverity(diag.severity), range: { start: { diff --git a/src/services/lsp/types.ts b/src/services/lsp/types.ts index bed0cd5f7e..d10724f0b1 100644 --- a/src/services/lsp/types.ts +++ b/src/services/lsp/types.ts @@ -22,7 +22,7 @@ export type LspServerConfig = { settings?: unknown /** Workspace folder path to use for the server */ workspaceFolder?: string - /** Maximum time to wait for server startup (ms) */ + /** Maximum time to wait for each server startup phase (default: 30000 ms) */ startupTimeout?: number /** Maximum time to wait for graceful shutdown (ms) */ shutdownTimeout?: number diff --git a/src/services/mcp/channelNotification.test.ts b/src/services/mcp/channelNotification.test.ts index cd7cc6e4a7..5da1b6b708 100644 --- a/src/services/mcp/channelNotification.test.ts +++ b/src/services/mcp/channelNotification.test.ts @@ -26,14 +26,14 @@ import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' // module-level mock.module() overrides in bun (the registry is process-global), // so without this, neighboring test files that import the real module would // fail with "Export named 'getChannelAllowlist' not found". -const _realChannelAllowlist = await import( - `./channelAllowlist.js?real=${Date.now()}-${Math.random()}` -) +const _realChannelAllowlist = { + ...await import(`./channelAllowlist.js?real=${Date.now()}-${Math.random()}`), +} // Real auth module — captured before mocking so afterAll can restore it. -const _realAuth = await import( - `../../utils/auth.js?real=${Date.now()}-${Math.random()}` -) +const _realAuth = { + ...await import(`../../utils/auth.js?real=${Date.now()}-${Math.random()}`), +} // Module-level mocks for the GrowthBook-backed helpers. The gate // reads these on every call; resetting between tests keeps the diff --git a/src/services/mcp/config.test.ts b/src/services/mcp/config.test.ts index cb413d21e7..679292de8b 100644 --- a/src/services/mcp/config.test.ts +++ b/src/services/mcp/config.test.ts @@ -1,31 +1,47 @@ -import { feature } from 'bun:bundle' -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' -import { - getCurrentProjectConfig, - getGlobalConfig, - saveCurrentProjectConfig, - saveGlobalConfig, -} from '../../utils/config.js' -import { isMcpServerDisabled, setMcpServerEnabled } from './config.js' +type ConfigModule = typeof import('../../utils/config.js') +type McpConfigModule = typeof import('./config.js') + +let realConfig: ConfigModule +let incomingConfig: ConfigModule +let isMcpServerDisabled: McpConfigModule['isMcpServerDisabled'] +let setMcpServerEnabled: McpConfigModule['setMcpServerEnabled'] + +beforeAll(async () => { + await acquireSharedMutationLock('services/mcp/config.test.ts') + incomingConfig = { ...await import('../../utils/config.js') } + realConfig = { ...await import(`../../utils/config.js?real=${Date.now()}-${Math.random()}`) } + mock.module('../../utils/config.js', () => realConfig) + const mcpConfig = await import(`./config.js?test=${Date.now()}-${Math.random()}`) + isMcpServerDisabled = mcpConfig.isMcpServerDisabled + setMcpServerEnabled = mcpConfig.setMcpServerEnabled +}) + +afterAll(() => { + try { + mock.module('../../utils/config.js', () => incomingConfig) + } finally { + releaseSharedMutationLock() + } +}) let savedNodeEnv: string | undefined let savedGlobalDisabled: string[] | undefined let savedProjectDisabled: string[] | undefined let savedProjectEnabled: string[] | undefined -beforeEach(async () => { - await acquireSharedMutationLock('services/mcp/config.test.ts') +beforeEach(() => { savedNodeEnv = process.env.NODE_ENV process.env.NODE_ENV = 'test' - savedGlobalDisabled = getGlobalConfig().disabledMcpServers - savedProjectDisabled = getCurrentProjectConfig().disabledMcpServers - savedProjectEnabled = getCurrentProjectConfig().enabledMcpServers - saveGlobalConfig(c => ({ ...c, disabledMcpServers: undefined })) - saveCurrentProjectConfig(c => ({ + savedGlobalDisabled = realConfig.getGlobalConfig().disabledMcpServers + savedProjectDisabled = realConfig.getCurrentProjectConfig().disabledMcpServers + savedProjectEnabled = realConfig.getCurrentProjectConfig().enabledMcpServers + realConfig.saveGlobalConfig(c => ({ ...c, disabledMcpServers: undefined })) + realConfig.saveCurrentProjectConfig(c => ({ ...c, disabledMcpServers: undefined, enabledMcpServers: undefined, @@ -33,18 +49,14 @@ beforeEach(async () => { }) afterEach(() => { - try { - saveGlobalConfig(c => ({ ...c, disabledMcpServers: savedGlobalDisabled })) - saveCurrentProjectConfig(c => ({ - ...c, - disabledMcpServers: savedProjectDisabled, - enabledMcpServers: savedProjectEnabled, - })) - if (savedNodeEnv === undefined) delete process.env.NODE_ENV - else process.env.NODE_ENV = savedNodeEnv - } finally { - releaseSharedMutationLock() - } + realConfig.saveGlobalConfig(c => ({ ...c, disabledMcpServers: savedGlobalDisabled })) + realConfig.saveCurrentProjectConfig(c => ({ + ...c, + disabledMcpServers: savedProjectDisabled, + enabledMcpServers: savedProjectEnabled, + })) + if (savedNodeEnv === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = savedNodeEnv }) test('without project or global lists, servers are enabled', () => { @@ -53,32 +65,32 @@ test('without project or global lists, servers are enabled', () => { }) test('an undefined project list inherits only global disables', () => { - getGlobalConfig().disabledMcpServers = ['a', 'b'] + realConfig.getGlobalConfig().disabledMcpServers = ['a', 'b'] expect(isMcpServerDisabled('a')).toBe(true) expect(isMcpServerDisabled('b')).toBe(true) expect(isMcpServerDisabled('other')).toBe(false) }) test.each([{ list: [] }, { list: ['local'] }])('an explicit project list %j overrides global disables', ({ list }) => { - getGlobalConfig().disabledMcpServers = ['global'] - saveCurrentProjectConfig(c => ({ ...c, disabledMcpServers: [...list] })) + realConfig.getGlobalConfig().disabledMcpServers = ['global'] + realConfig.saveCurrentProjectConfig(c => ({ ...c, disabledMcpServers: [...list] })) expect(isMcpServerDisabled('global')).toBe(false) expect(isMcpServerDisabled('local')).toBe(list.length > 0) }) test('disabling preserves inherited entries and propagates globally without duplicates', () => { - getGlobalConfig().disabledMcpServers = ['a'] + realConfig.getGlobalConfig().disabledMcpServers = ['a'] setMcpServerEnabled('b', false) setMcpServerEnabled('b', false) expect(isMcpServerDisabled('a')).toBe(true) expect(isMcpServerDisabled('b')).toBe(true) - expect(getCurrentProjectConfig().disabledMcpServers).toEqual(['a', 'b']) - expect(getGlobalConfig().disabledMcpServers).toEqual(['a', 'b']) + expect(realConfig.getCurrentProjectConfig().disabledMcpServers).toEqual(['a', 'b']) + expect(realConfig.getGlobalConfig().disabledMcpServers).toEqual(['a', 'b']) }) test('toggling an explicit project list preserves its overrides and enables locally', () => { - getGlobalConfig().disabledMcpServers = ['global'] - saveCurrentProjectConfig(c => ({ ...c, disabledMcpServers: ['local'] })) + realConfig.getGlobalConfig().disabledMcpServers = ['global'] + realConfig.saveCurrentProjectConfig(c => ({ ...c, disabledMcpServers: ['local'] })) setMcpServerEnabled('target', false) expect(isMcpServerDisabled('target')).toBe(true) setMcpServerEnabled('target', true) @@ -86,33 +98,22 @@ test('toggling an explicit project list preserves its overrides and enables loca expect(isMcpServerDisabled('target')).toBe(false) expect(isMcpServerDisabled('local')).toBe(true) expect(isMcpServerDisabled('global')).toBe(false) - expect(getGlobalConfig().disabledMcpServers).toEqual(['global', 'target']) + expect(realConfig.getGlobalConfig().disabledMcpServers).toEqual(['global', 'target']) }) test('enabling one inherited server preserves other disables and stays local', () => { - getGlobalConfig().disabledMcpServers = ['a', 'b'] + realConfig.getGlobalConfig().disabledMcpServers = ['a', 'b'] setMcpServerEnabled('a', true) setMcpServerEnabled('a', true) expect(isMcpServerDisabled('a')).toBe(false) expect(isMcpServerDisabled('b')).toBe(true) - expect(getGlobalConfig().disabledMcpServers).toEqual(['a', 'b']) + expect(realConfig.getGlobalConfig().disabledMcpServers).toEqual(['a', 'b']) }) test('enabling the last inherited server keeps an explicit empty override', () => { - getGlobalConfig().disabledMcpServers = ['a'] + realConfig.getGlobalConfig().disabledMcpServers = ['a'] setMcpServerEnabled('a', true) - expect(getCurrentProjectConfig().disabledMcpServers).toEqual([]) + expect(realConfig.getCurrentProjectConfig().disabledMcpServers).toEqual([]) expect(isMcpServerDisabled('a')).toBe(false) - expect(getGlobalConfig().disabledMcpServers).toEqual(['a']) -}) - -test.skipIf(feature('CHICAGO_MCP') ? false : true)('builtin toggles leave disabled lists unchanged', () => { - getGlobalConfig().disabledMcpServers = ['computer-use', 'other'] - expect(isMcpServerDisabled('computer-use')).toBe(true) - setMcpServerEnabled('computer-use', true) - expect(isMcpServerDisabled('computer-use')).toBe(false) - setMcpServerEnabled('computer-use', false) - expect(isMcpServerDisabled('computer-use')).toBe(true) - expect(getGlobalConfig().disabledMcpServers).toEqual(['computer-use', 'other']) - expect(getCurrentProjectConfig().disabledMcpServers).toBeUndefined() + expect(realConfig.getGlobalConfig().disabledMcpServers).toEqual(['a']) }) diff --git a/src/services/mcp/headersHelper.test.ts b/src/services/mcp/headersHelper.test.ts new file mode 100644 index 0000000000..f4bedd5a37 --- /dev/null +++ b/src/services/mcp/headersHelper.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'bun:test' + +import { + AIMLAPI_MCP_URL, + DEFAULT_PARTNER_ID, +} from '../../integrations/aimlapi/config.js' +import { getMcpServerHeaders } from './headersHelper.js' + +test('requests to the AI/ML API MCP host carry the attribution headers', async () => { + const headers = await getMcpServerHeaders('aimlapi', { + type: 'http', + url: AIMLAPI_MCP_URL, + headers: { Authorization: 'Bearer test-key' }, + }) + + expect(headers).toEqual({ + Authorization: 'Bearer test-key', + 'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID, + 'X-AIMLAPI-Source': 'agent/openclaude', + }) +}) + +test('other MCP servers never receive AI/ML API attribution', async () => { + for (const url of [ + 'https://mcp.example.test/mcp', + 'https://mcp.aimlapi.com.attacker.test/mcp', + ]) { + const headers = await getMcpServerHeaders('other', { + type: 'http', + url, + headers: { Authorization: 'Bearer other-key' }, + }) + expect(headers).toEqual({ Authorization: 'Bearer other-key' }) + } +}) diff --git a/src/services/mcp/headersHelper.ts b/src/services/mcp/headersHelper.ts index 7fea3ccdc6..c827ddbcbf 100644 --- a/src/services/mcp/headersHelper.ts +++ b/src/services/mcp/headersHelper.ts @@ -1,4 +1,5 @@ import { getIsNonInteractiveSession } from '../../bootstrap/state.js' +import { resolveAimlapiMcpHeaders } from '../../integrations/aimlapi/config.js' import { checkHasTrustDialogAccepted } from '../../utils/config.js' import { logAntError } from '../../utils/debug.js' import { errorMessage } from '../../utils/errors.js' @@ -131,9 +132,13 @@ export async function getMcpServerHeaders( const dynamicHeaders = (await getMcpHeadersFromHelper(serverName, config)) || {} - // Dynamic headers override static headers if both are present - return { - ...staticHeaders, - ...dynamicHeaders, - } + // Dynamic headers override static headers if both are present. Requests to + // the AI/ML API MCP host (exact hostname) also carry its attribution headers. + return resolveAimlapiMcpHeaders( + { + ...staticHeaders, + ...dynamicHeaders, + }, + config.url, + ) } diff --git a/src/services/mcp/officialRegistry.test.ts b/src/services/mcp/officialRegistry.test.ts index 261e5ec20f..05ebbb9d1f 100644 --- a/src/services/mcp/officialRegistry.test.ts +++ b/src/services/mcp/officialRegistry.test.ts @@ -34,9 +34,11 @@ afterEach(() => { }) async function importActualProviders(): Promise { - return import( - `../../utils/model/providers.ts?officialRegistryActual=${Date.now()}-${Math.random()}` - ) + return { + ...await import( + `../../utils/model/providers.ts?officialRegistryActual=${Date.now()}-${Math.random()}` + ), + } } async function mockApiProvider(provider: string): Promise { diff --git a/src/services/mcp/types.ts b/src/services/mcp/types.ts index b98821deec..3f3d776a5f 100644 --- a/src/services/mcp/types.ts +++ b/src/services/mcp/types.ts @@ -44,9 +44,7 @@ const McpOAuthConfigSchema = lazySchema(() => z.object({ clientId: z.string().optional(), callbackPort: z.number().int().positive().optional(), - authServerMetadataUrl: z - .string() - .url() + authServerMetadataUrl: z.url() .startsWith('https://', { message: 'authServerMetadataUrl must use https://', }) diff --git a/src/services/mcp/vscodeSdkMcp.ts b/src/services/mcp/vscodeSdkMcp.ts index a15329cfbe..23e75d1d91 100644 --- a/src/services/mcp/vscodeSdkMcp.ts +++ b/src/services/mcp/vscodeSdkMcp.ts @@ -24,7 +24,7 @@ export const LogEventNotificationSchema = lazySchema(() => method: z.literal('log_event'), params: z.object({ eventName: z.string(), - eventData: z.object({}).passthrough(), + eventData: z.looseObject({}), }), }), ) diff --git a/src/services/tips/sponsoredTips.test.ts b/src/services/tips/sponsoredTips.test.ts index ab6859b133..266ac9d3ea 100644 --- a/src/services/tips/sponsoredTips.test.ts +++ b/src/services/tips/sponsoredTips.test.ts @@ -20,6 +20,10 @@ const configRef: { // mock.module is process-global — install once, then mutate the refs per test. await acquireSharedMutationLock('services/tips/sponsoredTips.test.ts') +// Bun repoints module namespaces when mocked; preserve the original exports. +const realSettings = { ...actualSettings } +const realConfig = { ...actualConfig } + mock.module('../../utils/settings/settings.js', () => ({ getSettings_DEPRECATED: () => settingsRef.value, getInitialSettings: () => settingsRef.value, @@ -36,8 +40,15 @@ mock.module('../../utils/config.js', () => ({ afterAll(() => { try { mock.restore() - mock.module('../../utils/settings/settings.js', () => actualSettings) - mock.module('../../utils/config.js', () => actualConfig) + mock.module('../../utils/settings/settings.js', () => realSettings) + mock.module('../../utils/config.js', () => realConfig) + expect(actualSettings.getSettings_DEPRECATED).toBe( + realSettings.getSettings_DEPRECATED, + ) + expect(actualSettings.getInitialSettings).toBe(realSettings.getInitialSettings) + expect(actualSettings.getSettingsForSource).toBe(realSettings.getSettingsForSource) + expect(actualConfig.getGlobalConfig).toBe(realConfig.getGlobalConfig) + expect(actualConfig.saveGlobalConfig).toBe(realConfig.saveGlobalConfig) } finally { releaseSharedMutationLock() } diff --git a/src/services/tips/tipScheduler.test.ts b/src/services/tips/tipScheduler.test.ts index f7c7d53711..0f13e17fab 100644 --- a/src/services/tips/tipScheduler.test.ts +++ b/src/services/tips/tipScheduler.test.ts @@ -3,11 +3,21 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' -import * as actualAnalytics from '../analytics/index.js' -import * as actualConfig from '../../utils/config.js' -import * as actualSettings from '../../utils/settings/settings.js' import type { Tip } from './types.js' -import * as actualTipRegistry from './tipRegistry.js' + +const nonce = `${Date.now()}-${Math.random()}` +const actualAnalytics = { + ...await import(`../analytics/index.js?real=${nonce}`), +} +const actualConfig = { + ...await import(`../../utils/config.js?real=${nonce}`), +} +const actualTipRegistry = { + ...await import(`./tipRegistry.js?real=${nonce}`), +} +const actualSettings = { + ...await import(`../../utils/settings/settings.js?real=${nonce}`), +} const settingsRef: { value: { @@ -43,6 +53,7 @@ mock.module('../../utils/config.js', () => ({ })) mock.module('./tipRegistry.js', () => ({ + ...actualTipRegistry, getRelevantTips: async () => relevantTipsRef.value, })) diff --git a/src/services/tokenEstimation.test.ts b/src/services/tokenEstimation.test.ts index 3a2bfb2c4e..6cd43a793f 100644 --- a/src/services/tokenEstimation.test.ts +++ b/src/services/tokenEstimation.test.ts @@ -1,7 +1,26 @@ import type { Anthropic } from '@anthropic-ai/sdk' import { expect, mock, test } from 'bun:test' import { jsonStringify } from '../utils/slowOperations.js' -import { __test, roughTokenCountEstimation } from './tokenEstimation.js' +import { __test, roughTokenCountEstimation, roughTokenCountEstimationForMessage } from './tokenEstimation.js' + +test('rough estimation handles browser state alongside text, images and tool references in tool results', () => { + const browserState: Anthropic.BrowserStateBlockParam = { type: 'browser_state', tabs: [] } + const reference: Anthropic.ToolReferenceBlockParam = { type: 'tool_reference', tool_name: 'lookup_docs' } + const toolResult: Anthropic.ToolResultBlockParam = { + type: 'tool_result', tool_use_id: 'tool_browser', + content: [ + { type: 'text', text: 'page loaded' }, browserState, reference, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'A'.repeat(10000) } }, + ], + } + expect(roughTokenCountEstimationForMessage({ + type: 'user', message: { content: [toolResult] }, + })).toBe( + roughTokenCountEstimation('page loaded') + + roughTokenCountEstimation(jsonStringify(browserState)) + + roughTokenCountEstimation(jsonStringify(reference)) + 2000, + ) +}) function createTextTool(): Anthropic.Beta.Messages.BetaToolUnion { return { diff --git a/src/services/tokenEstimation.ts b/src/services/tokenEstimation.ts index 2aeac095df..d5f61fc5c3 100644 --- a/src/services/tokenEstimation.ts +++ b/src/services/tokenEstimation.ts @@ -582,13 +582,16 @@ export function roughTokenCountEstimationForMessage(message: { return 0 } +type ToolResultContentBlock = Exclude< + Anthropic.ToolResultBlockParam['content'], string | undefined +>[number] + function roughTokenCountEstimationForContent( content: | string | Array - // ToolReferenceBlockParam appears nested in tool_result content but is - // not part of the top-level ContentBlockParam union. - | Array + // Nested tool results can include blocks absent from the top-level union. + | Array | undefined, ): number { if (!content) { @@ -610,7 +613,7 @@ function roughTokenCountEstimationForBlock( | Anthropic.ContentBlock | Anthropic.ContentBlockParam // Nested tool_result content block; falls through to the stringify path. - | Anthropic.ToolReferenceBlockParam, + | ToolResultContentBlock, ): number { if (typeof block === 'string') { return roughTokenCountEstimation(block) diff --git a/src/skills/loadSkillsDir.test.ts b/src/skills/loadSkillsDir.test.ts index 34b9c4d7fc..974bad9909 100644 --- a/src/skills/loadSkillsDir.test.ts +++ b/src/skills/loadSkillsDir.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, readdirSync, readFileSync, + realpathSync, rmSync, statSync, symlinkSync, @@ -131,17 +132,32 @@ function setRealFilesystemForTest(): ReturnType { return originalFs } -function setConfigDirEnv(configDir: string): void { +function setConfigDirEnv(configDir: string, homeDir = join(configDir, 'home')): void { + mkdirSync(homeDir, { recursive: true }) + process.env.HOME = homeDir + process.env.USERPROFILE = homeDir + // Bun caches os.homedir(), so changing HOME alone does not isolate siblings. + setSiblingSkillsHomeDirForTesting(homeDir) setClaudeConfigHomeDirForTesting(undefined) process.env.OPENCLAUDE_CONFIG_DIR = configDir delete process.env.CLAUDE_CONFIG_DIR } function restoreConfigDirEnv(original: { + home: string | undefined + userProfile: string | undefined openClaudeConfigDir: string | undefined claudeConfigDir: string | undefined configHomeOverride: string | undefined }): void { + for (const [key, value] of Object.entries({ + HOME: original.home, + USERPROFILE: original.userProfile, + })) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + setSiblingSkillsHomeDirForTesting(undefined) setClaudeConfigHomeDirForTesting(original.configHomeOverride) if (original.openClaudeConfigDir === undefined) { @@ -162,6 +178,8 @@ test.serial('loads flat and nested skills with colon namespaces', async () => { const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-')) const cwd = join(configDir, 'workspace') const originalConfigDir = { + home: process.env.HOME, + userProfile: process.env.USERPROFILE, openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR, claudeConfigDir: process.env.CLAUDE_CONFIG_DIR, configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(), @@ -179,6 +197,10 @@ test.serial('loads flat and nested skills with colon namespaces', async () => { clearSkillAndConfigCaches() const skills = await getSkillDirCommands(cwd) + assert.equal( + skills.some(skill => skill.type === 'prompt' && skill.siblingSkillsHome), + false, + ) const fixtureSkillsRoot = join(configDir, '.openclaude', 'skills') const promptSkills = skills.filter( ( @@ -227,6 +249,8 @@ test.serial('ignores legacy .claude project skills when .openclaude skills exist const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-')) const cwd = join(configDir, 'workspace') const originalConfigDir = { + home: process.env.HOME, + userProfile: process.env.USERPROFILE, openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR, claudeConfigDir: process.env.CLAUDE_CONFIG_DIR, configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(), @@ -275,6 +299,8 @@ test.serial('loads persisted registry trust metadata from skill.json', async () const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-')) const cwd = join(configDir, 'workspace') const originalConfigDir = { + home: process.env.HOME, + userProfile: process.env.USERPROFILE, openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR, claudeConfigDir: process.env.CLAUDE_CONFIG_DIR, configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(), @@ -323,6 +349,8 @@ test.serial('project skills are ordered before user skills with the same name', const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-')) const cwd = join(configDir, 'workspace') const originalConfigDir = { + home: process.env.HOME, + userProfile: process.env.USERPROFILE, openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR, claudeConfigDir: process.env.CLAUDE_CONFIG_DIR, configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(), @@ -452,6 +480,28 @@ function promptSkillsNamed( ) } +async function waitForReadiness( + readiness: Promise, + description: string, + timeoutMs = 1000, +): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + readiness, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error( + `Timed out after ${timeoutMs}ms waiting for ${description}`, + )) + }, timeoutMs) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + async function withSiblingHomeFixture( run: (fixture: SiblingHomeFixture) => Promise, ): Promise { @@ -459,28 +509,31 @@ async function withSiblingHomeFixture( const rootDir = mkdtempSync(join(tmpdir(), 'openclaude-sibling-skills-')) const homeDir = join(rootDir, 'home') const configDir = join(homeDir, '.openclaude') - // Inside the fake home so project-dir traversal stops at it. + // Keep project and user skills in the same disposable fixture. const cwd = join(homeDir, 'workspace') const originalConfigDir = { + home: process.env.HOME, + userProfile: process.env.USERPROFILE, openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR, claudeConfigDir: process.env.CLAUDE_CONFIG_DIR, configHomeOverride: getClaudeConfigHomeDirOverrideForTesting(), } const originalOriginalCwd = getOriginalCwd() + const originalCwd = process.cwd() const originalSettingsState = enableUserAndProjectSettingSources() const originalFs = setRealFilesystemForTest() try { mkdirSync(cwd, { recursive: true }) mkdirSync(configDir, { recursive: true }) - setSiblingSkillsHomeDirForTesting(homeDir) - setConfigDirEnv(configDir) + setConfigDirEnv(configDir, homeDir) + process.chdir(cwd) setOriginalCwd(cwd) clearSkillAndConfigCaches() await run({ homeDir, configDir, cwd }) } finally { try { - setSiblingSkillsHomeDirForTesting(undefined) + process.chdir(originalCwd) restoreConfigDirEnv(originalConfigDir) setOriginalCwd(originalOriginalCwd) setFsImplementation(originalFs) @@ -494,6 +547,27 @@ async function withSiblingHomeFixture( } } +test.serial('releases the sibling home fixture lock when readiness times out', async () => { + await assert.rejects( + withSiblingHomeFixture(async () => { + await waitForReadiness( + new Promise(() => {}), + 'missing sibling skill readiness', + 50, + ) + }), + { message: 'Timed out after 50ms waiting for missing sibling skill readiness' }, + ) + + let lockAcquired = false + try { + await acquireSharedMutationLock('loadSkillsDir.test.ts readiness cleanup', 250) + lockAcquired = true + } finally { + if (lockAcquired) releaseSharedMutationLock() + } +}) + test.serial('loads ~/.agents/skills by default and keeps ~/.claude/skills opt-in', async () => { await withSiblingHomeFixture(async ({ homeDir }) => { writeSiblingSkill(homeDir, '.agents', 'agent-skill') @@ -699,7 +773,10 @@ for (const home of ['.agents', '.claude'] as const) { } }) const oldScan = getSkillDirCommands(cwd) try { - await reading + await waitForReadiness( + reading, + `both SKILL.md reads for ${home} before ${invalidation}`, + ) if (invalidation === 'disable') { writeFileSync(join(configDir, 'settings.json'), JSON.stringify({ agentsSkillsEnabled: false, claudeCodeSkillsEnabled: false, @@ -1103,6 +1180,7 @@ for (const home of ['.agents', '.claude'] as const) { const plainFile = join(writeSiblingSkill(homeDir, home, 'plain'), 'SKILL.md') symlinkSync(target, join(homeDir, home, 'skills', 'linked'), process.platform === 'win32' ? 'junction' : 'dir') const ready: Promise[] = [] + const changedPaths = new Set() await detector.resetForTesting({ stabilityThreshold: 10, pollInterval: 10, chokidarInterval: 10, reloadDebounce: 10, reloadCooldown: 10, @@ -1111,6 +1189,7 @@ for (const home of ['.agents', '.claude'] as const) { executeConfigChangeHooks: async () => [], watch: (paths, options) => { const watcher = chokidar.watch(paths, options) + watcher.on('change', path => changedPaths.add(realpathSync(path))) ready.push(new Promise(resolve => watcher.once('ready', resolve))) return watcher }, @@ -1127,16 +1206,28 @@ for (const home of ['.agents', '.claude'] as const) { const before = notifications const deadline = Date.now() + 2500 let edit = 0 - while (Date.now() < deadline) { + const watchedFile = realpathSync(file) + changedPaths.delete(watchedFile) + // Retry only until Chokidar observes the write. Retrying until reload + // can enqueue another edit or continually restart the reload debounce. + while (!changedPaths.has(watchedFile) && Date.now() < deadline) { writeFileSync(file, `---\ndescription: ${marker}\n---\nInstructions ${marker} ${edit++}\n`) await Bun.sleep(50) - if (notifications > before && (await invokedText(name)).includes(marker)) return } - assert.fail(`No refreshed invocation for ${name}: ${marker}`) + assert.ok(changedPaths.has(watchedFile), `No file event for ${name}: ${marker}`) + while (notifications === before && Date.now() < deadline) { + await Bun.sleep(10) + } + assert.ok(notifications > before, `No reload for ${name}: ${marker}`) + assert.ok((await invokedText(name)).includes(marker)) } try { await detector.initialize() - await Promise.all(ready) + await waitForReadiness( + Promise.all(ready), + `initial watcher readiness for ${home}`, + 3_000, + ) assert.ok((await invokedText('linked')).includes('Instructions initial linked')) await editUntilRefreshed(plainFile, 'plain', 'plain control') await editUntilRefreshed(targetFile, 'linked', 'revised linked') @@ -1153,7 +1244,11 @@ for (const home of ['.agents', '.claude'] as const) { writeFileSync(join(configDir, 'settings.json'), JSON.stringify({ [settingKey]: true })) resetSettingsCache() await detector.reloadAfterSkillSourceSettingsChange() - await Promise.all(ready) + await waitForReadiness( + Promise.all(ready), + `watcher readiness for ${home} after re-enabling`, + 3_000, + ) await editUntilRefreshed(targetFile, 'linked', 're-enabled linked') await detector.dispose() const disposedCount = notifications diff --git a/src/state/teammateViewHelpers.interruptionTrace.test.ts b/src/state/teammateViewHelpers.interruptionTrace.test.ts index 928635ac4c..7c1d9a9939 100644 --- a/src/state/teammateViewHelpers.interruptionTrace.test.ts +++ b/src/state/teammateViewHelpers.interruptionTrace.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' +import { snapshotEnv } from '../test/envSnapshot.js' import type { AppState } from './AppState.js' import { getDefaultAppState } from './AppStateStore.js' import { stopOrDismissAgent } from './teammateViewHelpers.js' @@ -12,22 +13,20 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock.js' -const originalTrace = process.env.OPENCLAUDE_INTERRUPT_TRACE +let restoreEnv: () => void beforeEach(async () => { await acquireSharedMutationLock( 'state/teammateViewHelpers.interruptionTrace.test.ts', ) + restoreEnv = snapshotEnv(['OPENCLAUDE_INTERRUPT_TRACE']) + __resetInterruptionTraceForTests() }) afterEach(() => { try { __resetInterruptionTraceForTests() - if (originalTrace === undefined) { - delete process.env.OPENCLAUDE_INTERRUPT_TRACE - } else { - process.env.OPENCLAUDE_INTERRUPT_TRACE = originalTrace - } + restoreEnv() } finally { releaseSharedMutationLock() } diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index 747d18ea32..079c2bf721 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -12,6 +12,8 @@ import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/Synt import { asAgentId } from '../../types/ids.js'; import type { Message } from '../../types/message.js'; import { createAbortController, createChildAbortController } from '../../utils/abortController.js'; +import { raceAbort } from '../../utils/boundedAsync.js'; +import { AbortError } from '../../utils/errors.js'; import { registerCleanup } from '../../utils/cleanupRegistry.js'; import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js'; import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; @@ -122,6 +124,13 @@ export type LocalAgentTaskState = TaskStateBase & { agentType: string; model?: string; abortController?: AbortController; + // Stable across progress/terminal updates, fresh for each registration. + registration?: object; + outputReady?: Promise; + // Destination captured when the producer submitted its latest transcript. + outputDestination?: string; + // No more transcript writes; completion may still be waiting for output setup. + producerFinished?: boolean; unregisterCleanup?: () => void; error?: string; result?: AgentToolResult; @@ -160,11 +169,14 @@ export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { export function isPanelAgentTask(t: unknown): t is LocalAgentTaskState { return isLocalAgentTask(t) && t.agentType !== 'main-session'; } -export function queuePendingMessage(taskId: string, msg: string, setAppState: (f: (prev: AppState) => AppState) => void): void { - updateTaskState(taskId, setAppState, task => ({ - ...task, - pendingMessages: [...task.pendingMessages, msg] - })); +export function queuePendingMessage(taskId: string, msg: string, setAppState: (f: (prev: AppState) => AppState) => void): boolean { + let accepted = false; + updateTaskState(taskId, setAppState, task => { + if (task.status !== 'running' || task.producerFinished) return task; + accepted = true; + return { ...task, pendingMessages: [...task.pendingMessages, msg] }; + }); + return accepted; } /** @@ -307,6 +319,9 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState, trace: endTime: Date.now(), evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, + outputReady: undefined, + outputDestination: undefined, + producerFinished: undefined, unregisterCleanup: undefined, selectedAgent: undefined }; @@ -420,15 +435,74 @@ export function updateAgentSummary(taskId: string, summary: string, setAppState: } } +// Only pending link mutations are retained here. Task records may be killed or +// evicted while an uncancellable filesystem operation is still running. +const pendingOutputLinks = new Map>(); +export function initAgentTaskOutput(taskId: string, destination: string): Promise { + const previous = pendingOutputLinks.get(taskId); + const ready = previous + ? previous.catch(() => {}).then(() => initTaskOutputAsSymlink(taskId, destination)) + : initTaskOutputAsSymlink(taskId, destination); + pendingOutputLinks.set(taskId, ready); + void ready.finally(() => { + if (pendingOutputLinks.get(taskId) === ready) pendingOutputLinks.delete(taskId); + }).catch(() => {}); + return ready; +} + /** * Complete an agent task with result. */ -export function completeAgentTask(result: AgentToolResult, setAppState: SetAppState): void { +export async function completeAgentTask(result: AgentToolResult, setAppState: SetAppState): Promise { const taskId = result.agentId; + let runningTask: LocalAgentTaskState | undefined; updateTaskState(taskId, setAppState, task => { - if (task.status !== 'running') { + runningTask = task.status === 'running' && !task.abortController?.signal.aborted + ? { ...task, registration: task.registration ?? {}, producerFinished: true } + : task; + return runningTask; + }); + const signal = runningTask?.abortController?.signal; + if (signal?.aborted || runningTask?.status === 'killed') throw new AbortError(); + if (runningTask?.status !== 'running') return false; + + const registration = runningTask.registration; + const ownsRegistration = () => { + let owns = false; + updateTaskState(taskId, setAppState, task => { + owns = task.registration === registration; return task; + }); + return owns; + }; + // Enqueue reconciliation before yielding: a successor must join it even if + // cancellation releases this waiter or terminal eviction removes the task. + try { + let outputReady = runningTask.outputReady; + if (runningTask.outputDestination) { + outputReady = initAgentTaskOutput(taskId, runningTask.outputDestination); + const ready = outputReady; + updateTaskState(taskId, setAppState, task => + task.registration === registration ? { ...task, outputReady: ready } : task); } + if (outputReady) await raceAbort(outputReady, signal); + if (!ownsRegistration()) return false; + await raceAbort(evictTaskOutput(taskId), signal); + } catch (error) { + // Lifecycle error handlers act by ID. An obsolete waiter must not kill or + // fail a replacement, including one that has already reached terminal state. + if (!ownsRegistration()) return false; + if (signal?.aborted) throw new AbortError(); + throw error; + } + if (!ownsRegistration()) return false; + if (signal?.aborted) throw new AbortError(); + let completed = false; + updateTaskState(taskId, setAppState, task => { + if (task.status !== 'running' || task.registration !== registration) { + return task; + } + completed = true; task.unregisterCleanup?.(); return { ...task, @@ -437,12 +511,14 @@ export function completeAgentTask(result: AgentToolResult, setAppState: SetAppSt endTime: Date.now(), evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, + outputReady: undefined, + outputDestination: undefined, + producerFinished: undefined, unregisterCleanup: undefined, selectedAgent: undefined }; }); - void evictTaskOutput(taskId); - // Note: Notification is sent by AgentTool via enqueueAgentNotification + return completed; } /** @@ -461,6 +537,9 @@ export function failAgentTask(taskId: string, error: string, setAppState: SetApp endTime: Date.now(), evictAfter: task.retain ? undefined : Date.now() + PANEL_GRACE_MS, abortController: undefined, + outputReady: undefined, + outputDestination: undefined, + producerFinished: undefined, unregisterCleanup: undefined, selectedAgent: undefined }; @@ -494,7 +573,8 @@ export function registerAsyncAgent({ parentAbortController?: AbortController; toolUseId?: string; }): LocalAgentTaskState { - void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); + const outputReady = initAgentTaskOutput(agentId, getAgentTranscriptPath(asAgentId(agentId))); + void outputReady.catch(() => {}); // Completion observes initialization failure. // Create abort controller - if parent provided, create child that auto-aborts with parent const abortController = parentAbortController @@ -513,6 +593,8 @@ export function registerAsyncAgent({ selectedAgent, agentType: selectedAgent.agentType ?? 'general-purpose', abortController, + registration: {}, + outputReady, retrieved: false, lastReportedToolCount: 0, lastReportedTokenCount: 0, @@ -564,7 +646,8 @@ export function registerAgentForeground({ backgroundSignal: Promise; cancelAutoBackground?: () => void; } { - void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); + const outputReady = initAgentTaskOutput(agentId, getAgentTranscriptPath(asAgentId(agentId))); + void outputReady.catch(() => {}); // Completion observes initialization failure. const abortController = createAbortController(); const unregisterCleanup = registerCleanup(async () => { killAsyncAgent(agentId, setAppState); @@ -578,6 +661,8 @@ export function registerAgentForeground({ selectedAgent, agentType: selectedAgent.agentType ?? 'general-purpose', abortController, + registration: {}, + outputReady, unregisterCleanup, retrieved: false, lastReportedToolCount: 0, diff --git a/src/tasks/LocalAgentTask/completionBarrier.test.ts b/src/tasks/LocalAgentTask/completionBarrier.test.ts new file mode 100644 index 0000000000..7a115f7f7e --- /dev/null +++ b/src/tasks/LocalAgentTask/completionBarrier.test.ts @@ -0,0 +1,199 @@ +import { REAL_PROVIDER_TEST_CHILD_ENV, REAL_PROVIDER_TEST_TIMEOUT_MS, runTestFileWithRealProviders } from '../../test/providerModuleIsolation.js' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { asAgentId } from '../../types/ids.js' +import type { ToolUseContext } from '../../Tool.js' +import type { AppState } from '../../state/AppState.js' +import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +import { AbortError } from '../../utils/errors.js' +import type { LocalAgentTaskState } from './LocalAgentTask.js' + +function deferred() { + let resolve!: () => void + const promise = new Promise(res => { resolve = res }) + return { promise, resolve } +} + +const actualDiskOutput = { ...await import('../../utils/task/diskOutput.js') } +const settle = () => new Promise(res => setTimeout(res, 0)) + +// Use the existing clean-child harness: other agent tests replace these +// production modules globally, and this test needs real registration/storage. +if (process.env[REAL_PROVIDER_TEST_CHILD_ENV] !== '1') { + test('agent output regression cases in a clean process', () => + runTestFileWithRealProviders(import.meta.path), REAL_PROVIDER_TEST_TIMEOUT_MS + 5_000) +} else { +beforeEach(() => acquireSharedMutationLock('tasks/LocalAgentTask/completionBarrier.test.ts')) +afterEach(() => { + mock.module('../../utils/task/diskOutput.js', () => actualDiskOutput) + releaseSharedMutationLock() +}) + +function state() { + let appState = { tasks: {}, agentNameRegistry: new Map() } as AppState + return { + getAppState: () => appState, + getTask: (id: string) => appState.tasks[id] as LocalAgentTaskState, + setAppState: (updater: (prev: AppState) => AppState) => { appState = updater(appState) }, + } +} + +describe('local-agent completion output barrier', () => { + for (const foreground of [false, true]) { + test(`waits for ${foreground ? 'foreground' : 'async'} output initialization and eviction`, async () => { + const link = deferred() + const eviction = deferred() + const evict = mock(() => eviction.promise) + mock.module('../../utils/task/diskOutput.js', () => ({ + ...actualDiskOutput, + initTaskOutputAsSymlink: () => link.promise.then(() => '/output'), + evictTaskOutput: evict, + })) + const { registerAsyncAgent, registerAgentForeground, completeAgentTask } = await import('./LocalAgentTask.js') + const { getTask, getAppState, setAppState } = state() + const agentId = `barrier-${foreground}` + const register = foreground ? registerAgentForeground : registerAsyncAgent + register({ agentId, description: 'test', prompt: 'test', selectedAgent: { agentType: 'test' } as AgentDefinition, setAppState }) + getAppState().agentNameRegistry.set('target', asAgentId(agentId)) + const { SendMessageTool } = await import('../../tools/SendMessageTool/SendMessageTool.js') + const send = (message: string) => SendMessageTool.call( + { to: 'target', message }, { getAppState, setAppState } as unknown as ToolUseContext, + async () => ({ behavior: 'allow' }), {} as never, + ) + const { drainPendingMessages } = await import('./LocalAgentTask.js') + expect((await send('live input')).data.success).toBe(true) + expect(drainPendingMessages(agentId, getAppState, setAppState)).toEqual(['live input']) + // Execute the actual REPL callback with simulated UI dependencies, avoiding + // a full terminal mount while checking the admission/clear-input decision. + const source = readFileSync(new URL('../../screens/REPL.tsx', import.meta.url), 'utf8') + const start = source.indexOf('const onAgentSubmit = useCallback') + const end = source.indexOf('// Handlers for auto-run', start) + const callback = ts.transpileModule(source.slice(start, end) + '\nreturn onAgentSubmit;', { + compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022 }, + }).outputText + const localAgents = await import('./LocalAgentTask.js') + const cleared = mock(() => {}) + const notified = mock(() => {}) + const resumed = mock(async () => {}) + const dependencies = { + ...localAgents, useCallback: (fn: unknown) => fn, setAppState, + setInputValue: cleared, getToolUseContext: () => ({}), canUseTool: () => {}, mainLoopModel: 'test', + addNotification: notified, resumeAgentBackground: resumed, messagesRef: { current: [] }, + createUserMessage: (args: unknown) => args, logForDebugging: () => {}, errorMessage: String, + injectUserMessageToTeammate: () => {}, React: { createElement: (...args: unknown[]) => args }, Text: 'Text', + } + const submit = new Function(...Object.keys(dependencies), callback)(...Object.values(dependencies)) + const assertRefusal = async () => { + const result = await send('FOLLOW-UP') + expect(result.data.success).toBe(false) + expect(result.data.message).toContain('retry') + await submit('typed follow-up', getTask(agentId), { setCursorOffset: cleared, clearBuffer: cleared }) + expect(notified).toHaveBeenCalled() + expect(cleared).not.toHaveBeenCalled() + expect(resumed).not.toHaveBeenCalled() + expect(getTask(agentId).pendingMessages).toEqual([]) + expect(getTask(agentId).messages ?? []).toEqual([]) + } + const result = { agentId, content: [] } as unknown as AgentToolResult + const completion = completeAgentTask(result, setAppState) + try { + await settle() + expect(getTask(agentId).status).toBe('running') + expect(evict).not.toHaveBeenCalled() + await assertRefusal() + link.resolve() + await settle() + expect(evict).toHaveBeenCalledTimes(1) + expect(getTask(agentId).status).toBe('running') + await assertRefusal() + eviction.resolve() + await completion + expect(getTask(agentId).status).toBe('completed') + expect(getTask(agentId).result).toBe(result) + expect(getTask(agentId).evictAfter).toBeGreaterThan(Date.now()) + await submit('retry after completion', getTask(agentId), { setCursorOffset: cleared, clearBuffer: cleared }) + expect(resumed).toHaveBeenCalledTimes(1) + expect(cleared).toHaveBeenCalled() + } finally { + link.resolve() + eviction.resolve() + await completion + } + }) + } + + test('cancellation interrupts pending output readiness instead of reporting success', async () => { + const link = deferred() + mock.module('../../utils/task/diskOutput.js', () => ({ + ...actualDiskOutput, + initTaskOutputAsSymlink: () => link.promise.then(() => '/output'), + evictTaskOutput: async () => {}, + })) + const { registerAsyncAgent, completeAgentTask, killAsyncAgent } = await import('./LocalAgentTask.js') + const { getTask, setAppState } = state() + const agentId = 'barrier-cancel' + registerAsyncAgent({ agentId, description: 'test', prompt: 'test', selectedAgent: { agentType: 'test' } as AgentDefinition, setAppState }) + const completion = completeAgentTask({ agentId, content: [] } as unknown as AgentToolResult, setAppState) + const outcome = completion.then(() => undefined, error => error) + try { + killAsyncAgent(agentId, setAppState) + await settle() + expect(getTask(agentId).status).toBe('killed') + expect(await Promise.race([outcome, Promise.resolve('still waiting')])).toBeInstanceOf(AbortError) + } finally { + link.resolve() + await outcome + } + }) + for (const foreground of [false, true]) { + for (const outcome of ['resolve', 'reject', 'abort'] as const) { + test(`obsolete ${foreground ? 'foreground' : 'async'} completion cannot own a replacement after ${outcome}`, async () => { + const link = deferred() + const failure = new Error('old initialization failed') + let count = 0 + mock.module('../../utils/task/diskOutput.js', () => ({ + ...actualDiskOutput, + initTaskOutputAsSymlink: () => ++count === 1 ? link.promise.then(() => { + if (outcome === 'reject') throw failure + return '/old' + }) : Promise.resolve('/new'), + evictTaskOutput: async () => {}, + })) + const { registerAsyncAgent, registerAgentForeground, completeAgentTask, killAsyncAgent } = await import('./LocalAgentTask.js') + const { getTask, setAppState } = state() + const agentId = `replace-${foreground}-${outcome}` + const register = foreground ? registerAgentForeground : registerAsyncAgent + const params = { agentId, description: 'old', prompt: 'old', selectedAgent: { agentType: 'test' } as AgentDefinition, setAppState } + register(params) + const old = getTask(agentId) + const completion = completeAgentTask({ agentId, content: [] } as unknown as AgentToolResult, setAppState).catch(error => error) + register({ ...params, description: 'replacement' }) + const replacement = getTask(agentId) + try { + if (outcome === 'abort') old.abortController!.abort() + link.resolve() + expect(await completion).toBe(false) + expect(getTask(agentId).status).toBe('running') + expect(getTask(agentId).result).toBeUndefined() + expect(getTask(agentId).abortController).toBe(replacement.abortController) + expect(getTask(agentId).unregisterCleanup).toBe(replacement.unregisterCleanup) + killAsyncAgent(agentId, setAppState) + expect(replacement.abortController!.signal.aborted).toBe(true) + } finally { + link.resolve() + await completion + await replacement.outputReady + old.unregisterCleanup?.() + killAsyncAgent(agentId, setAppState) + } + }) + } + } + +}) + +} diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 0c87ab76d2..860e5e9dbe 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -475,8 +475,10 @@ export function startBackgroundSession({ // Persist the pre-backgrounding conversation to the task's isolated // transcript so TaskOutput shows context immediately. Subsequent - // messages are written incrementally below. - void recordSidechainTranscript(messages, taskId).catch(err => + // messages are written incrementally below. Unlike regular agents, this + // lifecycle follows clear's live output link and does not reconcile a + // submission-time destination on completion. Keep its late path selection. + void recordSidechainTranscript(messages, taskId, undefined, { captureDestination: false }).catch(err => logForDebugging(`bg-session initial transcript write failed: ${err}`), ) @@ -516,7 +518,7 @@ export function startBackgroundSession({ // Per-message write (matches runAgent.ts pattern) — gives live // TaskOutput progress and keeps the transcript file current even if // /clear re-links the symlink mid-run. - void recordSidechainTranscript([event], taskId, lastRecordedUuid).catch( + void recordSidechainTranscript([event], taskId, lastRecordedUuid, { captureDestination: false }).catch( err => logForDebugging(`bg-session transcript write failed: ${err}`), ) lastRecordedUuid = event.uuid diff --git a/src/test/envSnapshot.test.ts b/src/test/envSnapshot.test.ts new file mode 100644 index 0000000000..11934e97e4 --- /dev/null +++ b/src/test/envSnapshot.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test' +import { snapshotEnv } from './envSnapshot.js' + +const KEYS = [ + 'TA001_ENV_SNAPSHOT_TEST_POPULATED', + 'TA001_ENV_SNAPSHOT_TEST_EMPTY', + 'TA001_ENV_SNAPSHOT_TEST_UNDEFINED', + 'TA001_ENV_SNAPSHOT_TEST_ABSENT', + 'TA001_ENV_SNAPSHOT_TEST_UNLISTED', +] as const + +function withTestEnv(run: () => void): void { + const incoming = KEYS.map(key => [key, process.env[key]] as const) + try { + run() + } finally { + for (const [key, value] of incoming) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } +} + +describe('snapshotEnv', () => { + test('leaves values unchanged by default and restores exact strings', () => { + withTestEnv(() => { + const [populated, empty, literalUndefined] = KEYS + process.env[populated] = 'original' + process.env[empty] = '' + process.env[literalUndefined] = 'undefined' + + const restoreEnv = snapshotEnv([populated, empty, literalUndefined]) + expect(process.env[populated]).toBe('original') + expect(process.env[empty]).toBe('') + expect(process.env[literalUndefined]).toBe('undefined') + + process.env[populated] = 'changed' + process.env[empty] = 'changed' + delete process.env[literalUndefined] + restoreEnv() + + expect(process.env[populated]).toBe('original') + expect(process.env[empty]).toBe('') + expect(process.env[literalUndefined]).toBe('undefined') + }) + }) + + test('clears only named keys and saves their values before clearing', () => { + withTestEnv(() => { + const [populated, empty, , , unlisted] = KEYS + process.env[populated] = 'original' + process.env[empty] = '' + process.env[unlisted] = 'untouched' + + const restoreEnv = snapshotEnv([populated, empty], { clear: true }) + expect(Object.hasOwn(process.env, populated)).toBe(false) + expect(Object.hasOwn(process.env, empty)).toBe(false) + expect(process.env[unlisted]).toBe('untouched') + + restoreEnv() + expect(process.env[populated]).toBe('original') + expect(process.env[empty]).toBe('') + expect(process.env[unlisted]).toBe('untouched') + }) + }) + + test('deletes keys that were previously absent', () => { + withTestEnv(() => { + const absent = KEYS[3] + delete process.env[absent] + const restoreEnv = snapshotEnv([absent]) + process.env[absent] = 'temporary' + + restoreEnv() + expect(process.env[absent]).toBeUndefined() + expect(Object.hasOwn(process.env, absent)).toBe(false) + }) + }) + + test('preserves changes to unlisted variables during restoration', () => { + withTestEnv(() => { + const [populated, , , , unlisted] = KEYS + process.env[populated] = 'original' + process.env[unlisted] = 'original unlisted' + const restoreEnv = snapshotEnv([populated]) + process.env[populated] = 'changed' + process.env[unlisted] = 'changed unlisted' + + restoreEnv() + expect(process.env[populated]).toBe('original') + expect(process.env[unlisted]).toBe('changed unlisted') + }) + }) +}) diff --git a/src/test/envSnapshot.ts b/src/test/envSnapshot.ts new file mode 100644 index 0000000000..aabec450f0 --- /dev/null +++ b/src/test/envSnapshot.ts @@ -0,0 +1,24 @@ +export function snapshotEnv( + keys: readonly string[], + options: { clear?: boolean } = {}, +): () => void { + const saved = new Map( + keys.map(key => [key, process.env[key]] as const), + ) + + if (options.clear) { + for (const key of saved.keys()) { + delete process.env[key] + } + } + + return () => { + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } +} diff --git a/src/test/fixtures/cliHighlight.baseline.json b/src/test/fixtures/cliHighlight.baseline.json new file mode 100644 index 0000000000..50d9d55487 --- /dev/null +++ b/src/test/fixtures/cliHighlight.baseline.json @@ -0,0 +1,80 @@ +[ + { + "name": "javascript", + "language": "javascript", + "code": "const value = true;\nconsole.log(\"<&> \\\"quotes\\\"\", 42); // comment\nfunction sum(a) { return /ab+/g.test(a); }", + "output": "\u001b[34mconst\u001b[39m value = \u001b[34mtrue\u001b[39m;\n\u001b[36mconsole\u001b[39m.log(\u001b[31m\"<&> \\\"quotes\\\"\"\u001b[39m, \u001b[32m42\u001b[39m); \u001b[32m// comment\u001b[39m\n\u001b[33m\u001b[34mfunction\u001b[39m\u001b[33m sum(a) \u001b[39m{ \u001b[34mreturn\u001b[39m \u001b[31m/ab+/g\u001b[39m.test(a); }" + }, + { + "name": "typescript", + "language": "ts", + "code": "interface Result { name: string; count: number }\nclass Example { value: Result | null = null; }", + "output": "\u001b[34minterface\u001b[39m Result { name: \u001b[36mstring\u001b[39m; count: \u001b[36mnumber\u001b[39m }\n\u001b[34m\u001b[34mclass\u001b[39m\u001b[34m Example \u001b[39m{ value: Result | \u001b[34mnull\u001b[39m = \u001b[34mnull\u001b[39m; }" + }, + { + "name": "json", + "language": "json", + "code": "{\"key\": \"value\", \"count\": 42, \"ok\": true}", + "output": "{\u001b[36m\"key\"\u001b[39m: \u001b[31m\"value\"\u001b[39m, \u001b[36m\"count\"\u001b[39m: \u001b[32m42\u001b[39m, \u001b[36m\"ok\"\u001b[39m: \u001b[34mtrue\u001b[39m}" + }, + { + "name": "markup", + "language": "xml", + "code": "

\">hello & world
", + "output": "\u001b[90m<\u001b[34msection\u001b[39m\u001b[90m \u001b[36mtitle\u001b[39m\u001b[90m=\u001b[31m\"<&>\"\u001b[39m\u001b[90m>\u001b[39mhello & world\u001b[90m\u001b[39m" + }, + { + "name": "sublanguages", + "language": "xml", + "code": "\n", + "output": "\u001b[90m<\u001b[34mscript\u001b[39m\u001b[90m>\u001b[39m\u001b[34mconst\u001b[39m n = \u001b[32m2\u001b[39m;\u001b[90m\u001b[39m\n\u001b[90m<\u001b[34mstyle\u001b[39m\u001b[90m>\u001b[39m.item { color: red; }\u001b[90m\u001b[39m" + }, + { + "name": "templates", + "language": "javascript", + "code": "const value = `hello ${Math.max(1, 2)}`;", + "output": "\u001b[34mconst\u001b[39m value = \u001b[31m`hello ${\u001b[36mMath\u001b[39m\u001b[31m.max(\u001b[32m1\u001b[39m\u001b[31m, \u001b[32m2\u001b[39m\u001b[31m)}`\u001b[39m;" + }, + { + "name": "markdown styles", + "language": "markdown", + "code": "# Heading\n**bold** *italic* [label](target)\n> quote\n- list\n`code`", + "output": "# Heading\n\u001b[1m**bold**\u001b[22m \u001b[3m*italic*\u001b[23m [\u001b[31mlabel\u001b[39m](\u001b[4mtarget\u001b[24m)\n> quote\n- list\n`code`" + }, + { + "name": "diff colors", + "language": "diff", + "code": "@@ -1 +1 @@\n-removed\n+added", + "output": "@@ -1 +1 @@\n\u001b[31m-removed\u001b[39m\n\u001b[32m+added\u001b[39m" + }, + { + "name": "meta", + "language": "cpp", + "code": "#include \n#define COUNT 12\nint main() { return 0; }", + "output": "\u001b[90m#\u001b[90minclude\u001b[39m\u001b[90m \u001b[90m\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m#\u001b[90mdefine\u001b[39m\u001b[90m COUNT 12\u001b[39m\n\u001b[33m\u001b[34mint\u001b[39m\u001b[33m main() \u001b[39m{ \u001b[34mreturn\u001b[39m \u001b[32m0\u001b[39m; }" + }, + { + "name": "type style", + "language": "haskell", + "code": "value :: Int\nvalue = 42", + "output": "value :: \u001b[36m\u001b[2mInt\u001b[22m\u001b[39m\nvalue = \u001b[32m42\u001b[39m" + }, + { + "name": "plaintext escapes", + "language": "plaintext", + "code": " & ' > ' 😀\n", + "output": " & ' > ' 😀\n" + }, + { + "name": "line endings", + "language": "plaintext", + "code": "first\r\nsecond\rthird\u0000last\n", + "output": "first\nsecond\nthirdlast\n" + }, + { + "name": "empty", + "language": "plaintext", + "code": "", + "output": "" + } +] diff --git a/src/test/fixtures/markdownRendering.baseline.json b/src/test/fixtures/markdownRendering.baseline.json new file mode 100644 index 0000000000..fa0b016056 --- /dev/null +++ b/src/test/fixtures/markdownRendering.baseline.json @@ -0,0 +1,88 @@ +{ + "inline": { + "input": "Plain **bold**, *italic*, `code`, ~~approximate~~ and \\*literal\\*.", + "terminal": "Plain bold, italic, code, ~~approximate~~ and *literal*.", + "codes": [] + }, + "headings": { + "input": "# Heading\n\nBody\n\n## Subheading\n\nNext", + "terminal": "Heading\n\nBody\n\nSubheading\n\nNext", + "codes": [] + }, + "blocks": { + "input": "> A quote\n> with **bold**\n\n---\n\nAfter", + "terminal": "▎ A quote\n▎ with bold\n\n---\nAfter", + "codes": [] + }, + "code": { + "input": "Before\n\n```ts\nconst n = 1\nconsole.log(n)\n```\n\nAfter\n\n indented()\n", + "terminal": "Before\n\nconst n = 1\nconsole.log(n)\n\nAfter\n\nindented()", + "codes": [ + { + "code": "const n = 1\nconsole.log(n)", + "lang": "ts" + }, + { + "code": "indented()" + } + ] + }, + "links": { + "input": "[Guide](https://example.com/guide) and and ![alt](image.png)", + "terminal": "https://example.com/guide and dev@example.com and image.png", + "codes": [] + }, + "tight": { + "input": "- First **bold**\n- Second\n continued\n 1. Nested one\n 2. Nested two\n- Last", + "terminal": "- First bold\n- Second\ncontinued\n a. Nested one\n b. Nested two\n- Last", + "codes": [] + }, + "loose": { + "input": "3. First **bold**\n\n4. Second\n\n Another paragraph\n\n5. Last", + "terminal": "3. First bold\n4. Second\n\n4. Another paragraph\n5. Last", + "codes": [] + }, + "tasks": { + "input": "- [ ] Pending\n- [x] Done\n\n- [ ] Loose task\n\n More text", + "terminal": "- Pending\n- Done\n- Loose task\n\n- More text", + "codes": [] + }, + "table": { + "input": "| Name | Value |\n| :--- | ---: |\n| **one** | `two` |\n| 三 | 42 |\n\nAfter", + "terminal": "| Name | Value |\n|------|-------|\n| one | two |\n| 三 | 42 |\n\nAfter", + "codes": [] + }, + "spacing": { + "input": "# Title\n\n\nBody\n\n```\ncode\n```\n\n\nAfter\n", + "terminal": "Title\n\nBody\n\ncode\n\nAfter", + "codes": [ + { + "code": "code", + "lang": "" + } + ] + }, + "listBlocks": { + "input": "- Intro\n\n ```\n code\n ```\n\n > quote\n\n- End", + "terminal": "- Intro\n\ncode\n\n▎ quote\n- End", + "codes": [] + }, + "tableComponent": "┌──────┬───────┐\n│ Name │ Value │\n├──────┼───────┤\n│ one │ two │\n├──────┼───────┤\n│ 三 │ 42 │\n└──────┴───────┘", + "Markdown": "Heading\n\nBody\n\nSubheading\n\nNext\n\n- First bold\n- Second\ncontinued\n a. Nested one\n b. Nested two\n- Last\n\n┌──────┬───────┐\n│ Name │ Value │\n├──────┼───────┤\n│ one │ two │\n├──────┼───────┤\n│ 三 │ 42 │\n└──────┴───────┘\n\nAfter", + "StreamingMarkdown": "Heading\n\nBody\n\nSubheading\n\nNext\n\n- First bold\n- Second\ncontinued\n a. Nested one\n b. Nested two\n- Last\n\n┌──────┬───────┐\n│ Name │ Value │\n├──────┼───────┤\n│ one │ two │\n├──────┼───────┤\n│ 三 │ 42 │\n└──────┴───────┘\n\nAfter", + "memory": { + "input": "# Rules\n\n\n\nUse **Bun**.\n\n```md\n\n@./ignored.md\n```\n\n@./included.md\n", + "content": "# Rules\n\nUse **Bun**.\n\n```md\n\n@./ignored.md\n```\n\n@./included.md\n", + "stripped": true + }, + "memoryFiles": [ + { + "name": "CLAUDE.md", + "content": "# Rules\n\nUse **Bun**.\n\n```md\n\n@./ignored.md\n```\n\n@./included.md\n" + }, + { + "name": "included.md", + "content": "Included rule\n" + } + ] +} diff --git a/src/test/fixtures/markdownRendering.fixture.tsx b/src/test/fixtures/markdownRendering.fixture.tsx new file mode 100644 index 0000000000..ef82112cdf --- /dev/null +++ b/src/test/fixtures/markdownRendering.fixture.tsx @@ -0,0 +1,68 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { stripVTControlCharacters as stripAnsi } from 'node:util' +import { marked, type Tokens } from 'marked' +import React from 'react' +import { call as copy } from '../../commands/copy/copy.js' +import { Markdown, StreamingMarkdown } from '../../components/Markdown.js' +import { MarkdownTable } from '../../components/MarkdownTable.js' +import { AppStateProvider, getDefaultAppState } from '../../state/AppState.js' +import { processMemoryFile, stripHtmlComments } from '../../utils/claudemd.js' +import { enableConfigs } from '../../utils/config.js' +import { applyMarkdown } from '../../utils/markdown.js' +import { renderToString } from '../../utils/staticRender.js' +import baseline from './markdownRendering.baseline.json' + +// Run in a fresh process: exercise real rendering/configuration without shared +// module mocks or settings mutations from the rest of the unit suite. +enableConfigs() +const output: Record = {} +for (const [name, fixture] of Object.entries(baseline)) { + if (typeof fixture !== 'object' || !('terminal' in fixture)) continue + let codes: { code: string; lang?: string }[] = [] + if (fixture.codes.length) { + const picker = await copy( + () => { throw new Error('Expected the code block picker') }, + { + messages: [{ + type: 'assistant', + message: { content: [{ type: 'text', text: fixture.input }] }, + }], + } as unknown as Parameters[1], + '', + ) + if (!React.isValidElement<{ codeBlocks: typeof codes }>(picker)) { + throw new Error('Missing code block picker') + } + codes = picker.props.codeBlocks + } + output[name] = { + input: fixture.input, + terminal: stripAnsi(applyMarkdown(fixture.input, 'dark')), + codes, + } +} +const table = marked.lexer(baseline.table.input).find(t => t.type === 'table') as Tokens.Table +output.tableComponent = await renderToString( + , 80, +) +const defaultState = getDefaultAppState() +const state = { + ...defaultState, + settings: { ...defaultState.settings, syntaxHighlightingDisabled: true }, +} +const combined = [baseline.headings.input, baseline.tight.input, baseline.table.input].join('\n\n') +for (const Component of [Markdown, StreamingMarkdown]) { + output[Component.name] = await renderToString( + {combined}, 80, + ) +} +output.memory = { input: baseline.memory.input, ...stripHtmlComments(baseline.memory.input) } +const dir = process.argv[2]! +await mkdir(dir, { recursive: true }) +await writeFile(join(dir, 'CLAUDE.md'), baseline.memory.input) +await writeFile(join(dir, 'included.md'), 'Included rule\n') +output.memoryFiles = ( + await processMemoryFile(join(dir, 'CLAUDE.md'), 'Project', new Set(), true) +).map(f => ({ name: f.path.split(/[\\/]/).at(-1), content: f.content })) +console.log(JSON.stringify(output)) diff --git a/src/test/fixtures/providerModuleIsolation.fixture.ts b/src/test/fixtures/providerModuleIsolation.fixture.ts new file mode 100644 index 0000000000..378fecb794 --- /dev/null +++ b/src/test/fixtures/providerModuleIsolation.fixture.ts @@ -0,0 +1,110 @@ +// Imported only by disposable test entrypoints; never discovered as a suite. +import { expect, mock, test } from 'bun:test' +import { + attributionModulesNeedIsolation, + providerModuleIsMocked, + REAL_PROVIDER_TEST_CHILD_ENV, + REAL_PROVIDER_TEST_TIMEOUT_MS, +} from '../providerModuleIsolation.js' + +const scenario = process.env.ISOLATION_FIXTURE_SCENARIO +const inChild = process.env[REAL_PROVIDER_TEST_CHILD_ENV] === '1' + +if (scenario === 'assertion-failure') { + test('child assertion failure sentinel', () => { + expect('actual child value').toBe('expected child value') + }) +} else if (scenario?.startsWith('suite-')) { + if (!inChild) { + const auth = await import('../../utils/auth.js') + mock.module('../../utils/auth.js', () => ({ + ...auth, + isClaudeAISubscriber: () => false, + })) + // Any real-suite hook in the parent must fail, including cleanup hooks. + mock.module('../sharedMutationLock.js', () => ({ + acquireSharedMutationLock: () => { + throw new Error('parent setup sentinel') + }, + releaseSharedMutationLock: () => { + throw new Error('parent cleanup sentinel') + }, + })) + } + if (scenario === 'suite-sideQuery') { + await import('../../utils/sideQuery.attribution.test.js') + } else { + await import('../../services/api/authRouting.attribution.test.js') + } +} else { + if (scenario === 'auth') { + const auth = await import('../../utils/auth.js') + mock.module('../../utils/auth.js', () => ({ + ...auth, + isClaudeAISubscriber: () => false, + })) + // Provider restoration must not hide independent auth pollution. + const realProviders = await import( + `../../utils/model/providers.js?fixtureReal=${Math.random()}` + ) + mock.module('../../utils/model/providers.js', () => ({ ...realProviders })) + const providers = await import('../../utils/model/providers.js') + expect(providerModuleIsMocked(providers, realProviders)).toBe(false) + } else if (scenario === 'betas') { + const betas = await import('../../utils/betas.js') + mock.module('../../utils/betas.js', () => ({ + ...betas, + shouldUseGlobalCacheScope: () => false, + })) + } else if (scenario === 'routing') { + const routing = await import('../../services/api/authRouting.js') + mock.module('../../services/api/authRouting.js', () => ({ + ...routing, + shouldUseCustomAnthropicBearerAuth: () => true, + })) + } else if (scenario === 'metadata') { + const metadata = await import('../../integrations/routeMetadata.js') + mock.module('../../integrations/routeMetadata.js', () => ({ + ...metadata, + getRouteLabel: () => 'polluted route', + })) + } else if (scenario === 'client') { + const client = await import('../../services/api/client.js') + mock.module('../../services/api/client.js', () => ({ + ...client, + CLIENT_REQUEST_ID_HEADER: 'polluted-header', + })) + } else if (scenario === 'missing') { + // Register before loading: Bun retains omitted exports when replacing an + // already-loaded namespace, which would not exercise a missing surface. + mock.module('../../utils/model/providers.js', () => ({})) + const loaded = await import('../../utils/model/providers.js') + expect(Object.keys(loaded)).toHaveLength(0) + expect(loaded.isFirstPartyAnthropicBaseUrl).toBeUndefined() + } else if (scenario === 'unsupported' || scenario === 'native-function') { + const providers = await import('../../utils/model/providers.js') + mock.module('../../utils/model/providers.js', () => ({ + ...providers, + getAPIProvider: scenario === 'unsupported' + ? {} + : providers.getAPIProvider.bind(null), + })) + } else if (scenario === 'import-failure') { + mock.module('../../utils/model/providers.js', () => { + throw new Error('module inspection sentinel') + }) + } else if (scenario === 'child-marker') { + process.env[REAL_PROVIDER_TEST_CHILD_ENV] = '1' + mock.module('../../utils/model/providers.js', () => { + throw new Error('child detector imported providers') + }) + } + + // Cold attribution imports can exceed the default test timeout under shard + // load. Use the isolation budget, below the outer fixture process deadline. + test(`attribution isolation detector: ${scenario}`, async () => { + expect(await attributionModulesNeedIsolation()).toBe( + scenario !== 'clean' && scenario !== 'child-marker', + ) + }, { timeout: REAL_PROVIDER_TEST_TIMEOUT_MS }) +} diff --git a/src/test/fixtures/sentryShutdown.fixture.ts b/src/test/fixtures/sentryShutdown.fixture.ts new file mode 100644 index 0000000000..de012814ea --- /dev/null +++ b/src/test/fixtures/sentryShutdown.fixture.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { jest, spyOn } from 'bun:test' +import * as Sentry from '@sentry/node' +import { snapshotEnv } from '../envSnapshot.js' +import { + getPendingShutdownForTesting, + gracefulShutdown, + gracefulShutdownSync, + resetShutdownState, +} from '../../utils/gracefulShutdown.js' +import { initializeSentry } from '../../utils/sentry.js' +import { registerCleanup } from '../../utils/cleanupRegistry.js' +import { sleep } from '../../utils/sleep.js' + +const mode = process.argv[2] +const restoreEnv = snapshotEnv([ + 'NODE_ENV', + 'SENTRY_DSN', + 'DISABLE_TELEMETRY', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + 'CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS', +], { clear: true }) +const originalExit = process.exit +const exitError = new Error('mocked process exit') +let exitCode: number | undefined +let flushCalls = 0 +let flushTimeout: number | undefined +let flushed = false +let flushedBeforeExit = false +let unregisterCleanup: (() => void) | undefined +let restoreHooks: (() => void) | undefined +let sessionEndHooksStarted = false +let notifyExit!: () => void +const exitObserved = new Promise(resolve => { notifyExit = resolve }) +let notifyFlushStarted!: () => void +const flushStarted = new Promise(resolve => { notifyFlushStarted = resolve }) +let releaseFlush!: () => void +const pendingFlush = new Promise(resolve => { + releaseFlush = () => { + flushed = true + resolve(true) + } +}) + +try { + process.env.NODE_ENV = 'test' + process.env.SENTRY_DSN = 'https://example@o0.ingest.sentry.io/0' + await initializeSentry() + const client = Sentry.getClient() + assert(client, 'SDK was not initialized') + // Keep the real SDK and application modules, replacing only the flush I/O. + // No events are sent to the network in this fixture. + client.flush = timeout => { + flushCalls++ + flushTimeout = timeout + if (mode === 'enabled' || mode === 'blocked') jest.useFakeTimers() + notifyFlushStarted() + if (mode === 'throws') throw new Error('flush threw') + if (mode === 'rejects') return Promise.reject(new Error('flush rejected')) + return pendingFlush + } + if (mode === 'dsn-unset') delete process.env.SENTRY_DSN + if (mode === 'telemetry-disabled') process.env.DISABLE_TELEMETRY = '1' + if (mode === 'nonessential-disabled') { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' + } + if (mode === 'default-budget') { + const hooks = await import('../../utils/hooks.js') + assert.equal(hooks.getSessionEndHookTimeoutMs(), 1500) + // Consume the real cleanup and default hook budgets before the Sentry drain. + const hookSpy = spyOn(hooks, 'executeSessionEndHooks') + .mockImplementation(() => sleep(1500)) + restoreHooks = () => { hookSpy.mockRestore() } + unregisterCleanup = registerCleanup(() => sleep(2000)) + jest.useFakeTimers() + } + if (mode === 'failsafe' || mode === 'sync-rejection') { + const hooks = await import('../../utils/hooks.js') + if (mode === 'failsafe') { + // A hook that ignores its abort signal leaves only the failsafe able to exit. + const hookSpy = spyOn(hooks, 'executeSessionEndHooks').mockImplementation(() => { + sessionEndHooksStarted = true + return new Promise(() => {}) + }) + restoreHooks = () => { hookSpy.mockRestore() } + } else { + // Fail before the failsafe is armed or the normal-path flush can run. + const timeoutSpy = spyOn(hooks, 'getSessionEndHookTimeoutMs').mockImplementation(() => { + throw new Error('shutdown setup failed') + }) + restoreHooks = () => { timeoutSpy.mockRestore() } + } + jest.useFakeTimers() + } + + process.exit = ((code?: number) => { + exitCode = code + flushedBeforeExit = flushed + notifyExit() + // The failsafe runs in an async timer callback; avoid an unhandled rejection. + if (mode === 'failsafe') return undefined as never + throw exitError + }) as typeof process.exit + + let shutdown: Promise + if (mode === 'sync-rejection') { + gracefulShutdownSync(7) + const pending = getPendingShutdownForTesting() + assert(pending, 'sync shutdown must expose its catch-handler completion') + shutdown = pending + } else { + shutdown = gracefulShutdown(7).catch(error => { + if (error !== exitError) throw error + }) + } + if (mode === 'failsafe') { + // Drive the actual armed timer while the normal shutdown stays in its hook. + for (let elapsed = 0; elapsed < 10_000 && flushCalls === 0; elapsed += 10) { + jest.advanceTimersByTime(10) + for (let i = 0; i < 20; i++) await Promise.resolve() + } + assert(sessionEndHooksStarted, 'normal shutdown must be stuck in its hook') + assert.equal(flushCalls, 1, 'failsafe must begin a Sentry drain') + } + if (mode === 'default-budget') { + // Advance through shutdown until the normal-path flush signals readiness. + // Yield between ticks so each awaited shutdown stage can schedule its timers. + for (let elapsed = 0; elapsed < 5000 && flushCalls === 0; elapsed += 10) { + jest.advanceTimersByTime(10) + for (let i = 0; i < 20; i++) await Promise.resolve() + } + assert.equal(flushCalls, 1, 'normal-path Sentry flush must start') + assert.equal(exitCode, undefined, 'shutdown must wait for flush') + // Cross the old 5s failsafe deadline while the normal flush is still pending. + jest.advanceTimersByTime(1999) + for (let i = 0; i < 20; i++) await Promise.resolve() + assert.equal(flushCalls, 1, 'failsafe must not start another flush during the normal drain') + assert.equal(exitCode, undefined, 'shutdown must not exit before the normal flush finishes') + releaseFlush() + } + if (mode === 'enabled' || mode === 'blocked' || mode === 'failsafe' || mode === 'sync-rejection') { + await Promise.race([ + flushStarted, + shutdown.then(() => { throw new Error('exited without flushing Sentry') }), + ]) + assert.equal(exitCode, undefined, 'shutdown must wait for flush') + jest.advanceTimersByTime(1999) + // Let an incorrectly early deadline propagate through the awaited helpers. + for (let i = 0; i < 5; i++) await Promise.resolve() + assert.equal(exitCode, undefined, 'flush deadline must not expire early') + if (mode === 'blocked') jest.advanceTimersByTime(1) + else releaseFlush() + } + // The failsafe exits despite the deliberately hung hook in normal shutdown. + if (mode === 'failsafe') await exitObserved + else await shutdown + console.log(JSON.stringify({ flushCalls, flushTimeout, exitCode, flushedBeforeExit })) +} finally { + unregisterCleanup?.() + restoreHooks?.() + jest.useRealTimers() + process.exit = originalExit + process.exitCode = 0 + resetShutdownState() + restoreEnv() +} diff --git a/src/test/gatewayCatalogTestHelpers.ts b/src/test/gatewayCatalogTestHelpers.ts new file mode 100644 index 0000000000..d30ca8cac5 --- /dev/null +++ b/src/test/gatewayCatalogTestHelpers.ts @@ -0,0 +1,132 @@ +import { expect } from 'bun:test' + +import type { + GatewayDescriptor, + ModelCatalogConfig, + ModelDiscoveryConfig, + OpenAIShimTransportConfig, + ValidationMetadata, + ValidationRoutingMetadata, +} from '../integrations/descriptors.js' +import { parseDurationString } from '../integrations/discoveryCache.js' +import { + ensureIntegrationsLoaded, + getCatalogForGateway, + getGateway, + getModel, +} from '../integrations/index.js' + +type CredentialValidation = Extract + +export type GatewayCatalogExpectations = { + baseUrl?: GatewayDescriptor['defaultBaseUrl'] + defaultModel?: GatewayDescriptor['defaultModel'] + category?: GatewayDescriptor['category'] + supportsModelRouting?: GatewayDescriptor['supportsModelRouting'] + setup?: Partial + preset?: Partial> + validation?: Omit, 'routing'> & { + routing?: Partial + } + openaiShim?: Partial + discovery?: Partial + catalogSource: ModelCatalogConfig['source'] + discoveryRefreshMode?: ModelCatalogConfig['discoveryRefreshMode'] + allowManualRefresh?: ModelCatalogConfig['allowManualRefresh'] + usage?: Partial> + allowEmptyCatalog?: boolean +} + +// Iterating supplied keys preserves explicit undefined expectations. +function expectFields( + actual: T | undefined, + expected: Partial, +): void { + expect(actual).toBeDefined() + if (!actual) throw new Error('Expected gateway metadata to exist') + for (const key of Object.keys(expected) as (keyof T)[]) { + expect(actual[key]).toEqual(expected[key]) + } +} + +export function expectGatewayCatalog( + routeId: string, + expected: GatewayCatalogExpectations, +): void { + ensureIntegrationsLoaded() + const gateway = getGateway(routeId) + const catalog = getCatalogForGateway(routeId) + expect(gateway).toBeDefined() + expect(catalog).toBeDefined() + if (!gateway || !catalog) throw new Error(`Missing gateway catalog: ${routeId}`) + + expect(gateway.id).toBe(routeId) + expect(gateway.label.trim().length).toBeGreaterThan(0) + if ('baseUrl' in expected) expect(gateway.defaultBaseUrl).toBe(expected.baseUrl) + if ('defaultModel' in expected) { + expect(gateway.defaultModel).toBe(expected.defaultModel) + } + if ('category' in expected) expect(gateway.category).toBe(expected.category) + if ('supportsModelRouting' in expected) { + expect(gateway.supportsModelRouting).toBe(expected.supportsModelRouting) + } + if (expected.setup) expectFields(gateway.setup, expected.setup) + if (expected.preset) expectFields(gateway.preset, expected.preset) + if (expected.validation) { + const { routing, ...validation } = expected.validation + expectFields(gateway.validation, validation) + if (routing) expectFields(gateway.validation?.routing, routing) + } + if (expected.usage) expectFields(gateway.usage, expected.usage) + expect(gateway.transportConfig.kind).toBe('openai-compatible') + if (expected.openaiShim) { + expectFields(gateway.transportConfig.openaiShim, expected.openaiShim) + } + + expect(catalog.source).toBe(expected.catalogSource) + if (catalog.source === 'dynamic' || catalog.source === 'hybrid') { + expect(catalog.discovery?.kind).toBe('openai-compatible') + expect(typeof catalog.discovery?.mapModel).toBe('function') + } + if (expected.discovery) { + const { mapModel, ...discovery } = expected.discovery + expectFields(catalog.discovery, discovery) + if ('mapModel' in expected.discovery) { + expect(catalog.discovery?.mapModel).toBe(mapModel) + } + } + if (catalog.discoveryCacheTtl !== undefined) { + const ttl = parseDurationString(catalog.discoveryCacheTtl) + expect(Number.isFinite(ttl)).toBe(true) + expect(ttl).toBeGreaterThan(0) + } + if ('discoveryRefreshMode' in expected) { + expect(catalog.discoveryRefreshMode).toBe(expected.discoveryRefreshMode) + } + if ('allowManualRefresh' in expected) { + expect(catalog.allowManualRefresh).toBe(expected.allowManualRefresh) + } + + // Raw entries include hidden and expired models filtered out by route lookup. + const entries = catalog.models ?? [] + if (!expected.allowEmptyCatalog) { + expect(entries.length).toBeGreaterThan(0) + if (gateway.defaultModel !== undefined) { + expect( + entries.some(entry => entry.apiName === gateway.defaultModel), + ).toBe(true) + } + } + expect(new Set(entries.map(entry => entry.id)).size).toBe(entries.length) + expect(new Set(entries.map(entry => entry.apiName)).size).toBe(entries.length) + for (const entry of entries) { + expect(entry.id.trim().length).toBeGreaterThan(0) + expect(entry.apiName.trim().length).toBeGreaterThan(0) + if (entry.label !== undefined) { + expect(entry.label.trim().length).toBeGreaterThan(0) + } + if (entry.modelDescriptorId !== undefined) { + expect(getModel(entry.modelDescriptorId)).toBeDefined() + } + } +} diff --git a/src/test/gitEnv.ts b/src/test/gitEnv.ts new file mode 100644 index 0000000000..40cdb1ecad --- /dev/null +++ b/src/test/gitEnv.ts @@ -0,0 +1,18 @@ +/** Keep fixture Git commands independent of the invoking shell's repository. */ +export function gitFixtureEnv(): NodeJS.ProcessEnv { + const env = { ...process.env } + for (const key of [ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_GRAFT_FILE', + 'GIT_SHALLOW_FILE', + 'GIT_PREFIX', + ]) { + delete env[key] + } + return env +} diff --git a/src/test/processExitCode.ts b/src/test/processExitCode.ts new file mode 100644 index 0000000000..82bcdc2b25 --- /dev/null +++ b/src/test/processExitCode.ts @@ -0,0 +1,23 @@ +export function isolateProcessExitCode(initialCode = process.exitCode): () => void { + const originalProcess = globalThis.process + let exitCode = initialCode + + globalThis.process = new Proxy(originalProcess, { + get(target, property) { + return property === 'exitCode' + ? exitCode + : Reflect.get(target, property, target) + }, + set(target, property, value) { + if (property === 'exitCode') { + exitCode = value + return true + } + return Reflect.set(target, property, value, target) + }, + }) + + return () => { + globalThis.process = originalProcess + } +} diff --git a/src/test/providerModuleIsolation.test.ts b/src/test/providerModuleIsolation.test.ts new file mode 100644 index 0000000000..575877879d --- /dev/null +++ b/src/test/providerModuleIsolation.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + REAL_PROVIDER_TEST_CHILD_ENV, + REAL_PROVIDER_TEST_TIMEOUT_MS, +} from './providerModuleIsolation.js' + +async function withFixture( + scenario: string, + run: (entry: string, env: NodeJS.ProcessEnv) => Promise, +): Promise { + const root = mkdtempSync(join(tmpdir(), 'provider-isolation-')) + const entry = join(root, 'isolation.test.ts') + const fixture = join(import.meta.dir, 'fixtures/providerModuleIsolation.fixture.ts') + writeFileSync(entry, `await import(${JSON.stringify(fixture)})\n`) + try { + await run(entry, { + ...process.env, + [REAL_PROVIDER_TEST_CHILD_ENV]: undefined, + ISOLATION_FIXTURE_SCENARIO: scenario, + CLAUDE_CONFIG_DIR: root, + OPENCLAUDE_CONFIG_DIR: root, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +// The fixture may itself run a child with a 30-second deadline. Give the outer +// process and test additional time for diagnostics and cleanup. +const fixtureTimeout = REAL_PROVIDER_TEST_TIMEOUT_MS + 10_000 +const testTimeout = fixtureTimeout + 5_000 + +for (const scenario of [ + 'clean', + 'auth', + 'betas', + 'routing', + 'metadata', + 'client', + 'missing', + 'unsupported', + 'native-function', + 'import-failure', + 'child-marker', + 'suite-sideQuery', + 'suite-authRouting', +]) { + test(`isolates attribution modules: ${scenario}`, async () => { + await withFixture(scenario, async (entry, env) => { + const child = Bun.spawn({ + cmd: [process.execPath, 'test', entry], + cwd: process.cwd(), + env, + stdout: 'pipe', + stderr: 'pipe', + timeout: fixtureTimeout, + killSignal: process.platform === 'win32' ? undefined : 'SIGKILL', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + const output = `${stdout}\n${stderr}` + expect(output).not.toContain('parent setup sentinel') + expect(output).not.toContain('parent cleanup sentinel') + expect(output).toMatch(/^\s*1 pass$/m) + expect(output).toMatch(/^\s*0 fail$/m) + expect(output).not.toMatch(/\d+ skip/) + expect(exitCode).toBe(0) + }) + }, { timeout: testTimeout }) +} + +test('propagates child assertion failures through the launcher', async () => { + await withFixture('assertion-failure', async (entry, env) => { + // Run the real launcher in a disposable process so coordinator env and mocks + // remain untouched even when other suites share this process. + const runner = join(entry, '..', 'launcher.ts') + writeFileSync(runner, ` + import { runTestFileWithRealProviders } from ${JSON.stringify( + join(import.meta.dir, 'providerModuleIsolation.ts'), + )} + await runTestFileWithRealProviders(${JSON.stringify(entry)}) + `) + const child = Bun.spawn({ + cmd: [process.execPath, runner], + env, + stdout: 'pipe', + stderr: 'pipe', + timeout: fixtureTimeout, + killSignal: process.platform === 'win32' ? undefined : 'SIGKILL', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect(exitCode).not.toBe(0) + expect(`${stdout}\n${stderr}`).toContain('provider-isolated test process failed') + expect(`${stdout}\n${stderr}`).toContain('child assertion failure sentinel') + expect(`${stdout}\n${stderr}`).toContain('expected child value') + }) +}, { timeout: testTimeout }) diff --git a/src/test/providerModuleIsolation.ts b/src/test/providerModuleIsolation.ts index e7a329a183..938812350e 100644 --- a/src/test/providerModuleIsolation.ts +++ b/src/test/providerModuleIsolation.ts @@ -32,6 +32,62 @@ export function providerModuleIsMocked( ) } +function exportSurfacesDiffer(loaded: object, real: object): boolean { + const loadedKeys = Reflect.ownKeys(loaded) + const realKeys = Reflect.ownKeys(real) + if ( + loadedKeys.length !== realKeys.length || + realKeys.some(key => !Object.hasOwn(loaded, key)) + ) return true + + return realKeys.some(key => { + const actual: unknown = Reflect.get(loaded, key) + const expected: unknown = Reflect.get(real, key) + if (typeof actual !== typeof expected) return true + if (typeof actual === 'function' && typeof expected === 'function') { + const actualSource = Function.prototype.toString.call(actual) + const expectedSource = Function.prototype.toString.call(expected) + // Native/bound functions cannot be inspected reliably. + return actualSource.includes('[native code]') || + expectedSource.includes('[native code]') || actualSource !== expectedSource + } + // These modules currently export functions and primitives. Unknown mutable + // objects (including symbols) cannot establish a clean comparison. + if ((typeof actual === 'object' && actual !== null) || + typeof actual === 'symbol') return true + return !Object.is(actual, expected) + }) +} + +export async function attributionModulesNeedIsolation(): Promise { + // Check before any imports: a clean child must never recurse. + if (process.env[REAL_PROVIDER_TEST_CHILD_ENV] === '1') return false + + // This detects replaced exports, not arbitrary stale dependency caches. + // Auth is checked independently even when routing has unchanged source. + const modulePaths = [ + '../utils/model/providers.js', + '../utils/auth.js', + '../utils/betas.js', + '../services/api/authRouting.js', + '../integrations/routeMetadata.js', + '../services/api/client.js', + ] + try { + for (const path of modulePaths) { + const loaded = await import(path) + const real = await import( + `${path}?attributionReal=${Date.now()}-${Math.random()}` + ) + if (exportSurfacesDiffer(loaded, real)) return true + } + return false + } catch { + // Missing exports, throwing getters, and failed imports require isolation. + return true + } +} + export async function runTestFileWithRealProviders( testFile: string, ): Promise { diff --git a/src/test/tlsFixture.ts b/src/test/tlsFixture.ts new file mode 100644 index 0000000000..e7dc9da4a1 --- /dev/null +++ b/src/test/tlsFixture.ts @@ -0,0 +1,77 @@ +import { generateKeyPairSync, sign, X509Certificate } from 'node:crypto' + +// Minimal DER encoding for this test certificate, including long-form lengths. +function der(tag: number, ...parts: Buffer[]): Buffer { + const content = Buffer.concat(parts) + const length: number[] = [] + if (content.length < 128) { + length.push(content.length) + } else { + for (let remaining = content.length; remaining > 0; remaining >>>= 8) { + length.unshift(remaining & 0xff) + } + length.unshift(0x80 | length.length) + } + return Buffer.concat([Buffer.from([tag, ...length]), content]) +} + +const sequence = (...parts: Buffer[]) => der(0x30, ...parts) +const oid = (hex: string) => der(0x06, Buffer.from(hex, 'hex')) +const booleanTrue = der(0x01, Buffer.from([0xff])) + +function certificateTime(date: Date): Buffer { + const digits = date.toISOString().replace(/[-:T]/g, '').replace(/\.\d{3}Z$/, 'Z') + // X.509 uses UTCTime for 1950–2049 and GeneralizedTime otherwise. + const utc = date.getUTCFullYear() >= 1950 && date.getUTCFullYear() < 2050 + return der(utc ? 0x17 : 0x18, Buffer.from(utc ? digits.slice(2) : digits)) +} + +function extension(oidHex: string, value: Buffer, critical = false): Buffer { + return sequence( + oid(oidHex), + ...(critical ? [booleanTrue] : []), + der(0x04, value), + ) +} + +export function createTestTlsFixture() { + const { privateKey, publicKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }) + // AlgorithmIdentifier for ecdsa-with-SHA256 (1.2.840.10045.4.3.2). + const algorithm = sequence(oid('2a8648ce3d040302')) + const name = sequence(der(0x31, sequence( + oid('550403'), // commonName (2.5.4.3) + der(0x0c, Buffer.from('localhost')), + ))) + const now = Date.now() + const day = 24 * 60 * 60 * 1000 + const tbsCertificate = sequence( + der(0xa0, der(0x02, Buffer.from([2]))), // explicit v3 version + der(0x02, Buffer.from([1])), // positive serial, unique for this fresh issuer + algorithm, + name, // issuer and subject are identical for a self-signed certificate + sequence(certificateTime(new Date(now - day)), certificateTime(new Date(now + day))), + name, + publicKey.export({ type: 'spki', format: 'der' }), + der(0xa3, sequence( + extension('551d13', sequence(booleanTrue), true), // basicConstraints: CA + // keyUsage: digitalSignature + keyCertSign; two unused bits. + extension('551d0f', der(0x03, Buffer.from([2, 0x84])), true), + extension('551d11', sequence( // subjectAltName + der(0x82, Buffer.from('localhost')), + der(0x87, Buffer.from([127, 0, 0, 1])), + )), + )), + ) + const certificate = sequence( + tbsCertificate, + algorithm, + der(0x03, Buffer.from([0]), sign('sha256', tbsCertificate, privateKey)), + ) + return { + key: privateKey.export({ type: 'pkcs8', format: 'pem' }), + cert: new X509Certificate(certificate).toString(), + publicKey, + } +} diff --git a/src/test/toolSchemas.fixture.ts b/src/test/toolSchemas.fixture.ts new file mode 100644 index 0000000000..b3839ea78f --- /dev/null +++ b/src/test/toolSchemas.fixture.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import type { Tool } from '../Tool.js' +import { zodToJsonSchema } from '../utils/zodToJsonSchema.js' + +// A fresh process avoids shared mocks and registry filters hiding schemas. +const toolsDir = new URL('../tools/', import.meta.url) +const files = [...new Bun.Glob('**/*Tool.{ts,tsx}').scanSync(fileURLToPath(toolsDir))] + .map(file => file.replaceAll('\\', '/')) + .sort() +const schemas: { id: string; input: unknown; advertised: unknown; output: unknown }[] = [] +for (const file of files) { + const exports = await import(new URL(file, toolsDir).href) + const seen = new Set() + for (const [name, value] of Object.entries(exports).sort(([a], [b]) => a.localeCompare(b))) { + if (!value || typeof value !== 'object' || !('inputSchema' in value) || seen.has(value)) continue + seen.add(value) + const tool = value as Tool + const input = zodToJsonSchema(tool.inputSchema) + schemas.push({ + id: `${file}:${name}`, + input, + advertised: tool.inputJSONSchema ?? input, + output: tool.outputSchema ? zodToJsonSchema(tool.outputSchema) : null, + }) + } +} +const output = process.argv.includes('--full') ? schemas : schemas.map(schema => ({ + id: fingerprint(schema.id), + input: fingerprint(schema.input), + advertised: fingerprint(schema.advertised), + output: schema.output ? fingerprint(schema.output) : null, +})) +await Bun.write(Bun.stdout, JSON.stringify(output)) + +function fingerprint(schema: unknown): string { + // Sort keys recursively so snapshots measure schema content, not key order. + return createHash('sha256').update(JSON.stringify(schema, (_key, value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + )).digest('hex') +} diff --git a/src/tools.lsp.test.ts b/src/tools.lsp.test.ts index 169ef71eda..3729261d45 100644 --- a/src/tools.lsp.test.ts +++ b/src/tools.lsp.test.ts @@ -1,4 +1,17 @@ +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' import { afterAll, beforeEach, expect, mock, test } from 'bun:test' +import { + getLspDocumentIdentity, + MAX_LSP_FILE_SIZE_BYTES, +} from './services/lsp/documentIdentity.js' +import { + LSP_DOCUMENT_REVISION_CHANGED, + LSP_SERVER_GENERATION_CHANGED, + type LSPServerManager, +} from './services/lsp/LSPServerManager.js' import { getEmptyToolPermissionContext } from './Tool.js' import { acquireSharedMutationLock, @@ -6,6 +19,53 @@ import { } from './test/sharedMutationLock.js' let lspConnected = false +let lspManager: LSPServerManager | undefined + +function createLspManagerDouble( + overrides: Partial = {}, +): LSPServerManager { + return { + async initialize() {}, + async shutdown() {}, + getServerForFile: () => undefined, + ensureServerStarted: async () => undefined, + async sendRequest() { + return undefined as T | undefined + }, + async sendRequestWithGeneration() { + return undefined as + | { + result: T + serverGeneration: number + documentRevision: number + documentCloseEpoch: number + } + | undefined + }, + getAllServers: () => new Map(), + async openFile() {}, + async changeFile() {}, + async saveFile() {}, + async closeFile() {}, + isFileOpen: () => false, + ...overrides, + } +} + +function createSendRequestDouble(): { + sendRequest: LSPServerManager['sendRequest'] + calls: ReturnType +} { + const calls = mock( + async (_filePath: string, _method: string, _params: unknown) => null, + ) + const sendRequest: LSPServerManager['sendRequest'] = async ( + filePath: string, + method: string, + params: unknown, + ) => (await calls(filePath, method, params)) as T + return { sendRequest, calls } +} const HOOK_EVENTS = [ 'PreToolUse', 'PostToolUse', @@ -43,7 +103,7 @@ mock.module('src/entrypoints/agentSdkTypes.js', () => ({ HOOK_EVENTS })) mock.module('./services/lsp/manager.js', () => ({ getInitializationStatus: () => ({ status: 'success' }), - getLspServerManager: () => undefined, + getLspServerManager: () => lspManager, initializeLspServerManager: async () => {}, isLspConnected: () => lspConnected, reinitializeLspServerManager: () => {}, @@ -53,9 +113,11 @@ mock.module('./services/lsp/manager.js', () => ({ })) const { getAllBaseTools, getTools } = await import('./tools.js') +const { LSPTool } = await import('./tools/LSPTool/LSPTool.js') afterAll(() => { try { + lspManager = undefined mock.restore() } finally { releaseSharedMutationLock() @@ -64,18 +126,261 @@ afterAll(() => { beforeEach(() => { lspConnected = false + lspManager = undefined }) test('LSPTool is part of the base tool pool', () => { - expect(getAllBaseTools().map(tool => tool.name)).toContain('LSP') + expect(getAllBaseTools().map((tool) => tool.name)).toContain('LSP') }) test('LSPTool is filtered from usable tools until a server is connected', () => { const permissionContext = getEmptyToolPermissionContext() - expect(getTools(permissionContext).map(tool => tool.name)).not.toContain('LSP') + expect(getTools(permissionContext).map((tool) => tool.name)).not.toContain( + 'LSP', + ) lspConnected = true - expect(getTools(permissionContext).map(tool => tool.name)).toContain('LSP') + expect(getTools(permissionContext).map((tool) => tool.name)).toContain('LSP') +}) + +test('LSPTool keeps the 10 MB guard ahead of open and request', async () => { + const directory = mkdtempSync(join(tmpdir(), 'openclaude-lsp-tool-size-')) + const filePath = join(directory, 'large.ts') + const openFile = mock(async () => {}) + const { sendRequest, calls: sendRequestCalls } = createSendRequestDouble() + try { + writeFileSync(filePath, '') + truncateSync(filePath, MAX_LSP_FILE_SIZE_BYTES + 1) + lspManager = createLspManagerDouble({ + isFileOpen: () => false, + openFile, + sendRequest, + }) + + const result = await LSPTool.call( + { operation: 'hover', filePath, line: 1, character: 1 }, + {} as never, + ) + + expect(result.data.result).toBe( + 'File too large for LSP analysis (11MB exceeds 10MB limit)', + ) + expect(openFile).not.toHaveBeenCalled() + expect(sendRequestCalls).not.toHaveBeenCalled() + } finally { + rmSync(directory, { recursive: true, force: true }) + } }) + +// The test budget leaves cleanup time after the mkfifo subprocess deadline. +test('LSPTool opens FIFO recovery paths nonblocking before validation', async () => { + if (process.platform === 'win32') return + + const directory = mkdtempSync(join(tmpdir(), 'openclaude-lsp-tool-fifo-')) + const filePath = join(directory, 'stream.ts') + const openFile = mock(async () => {}) + const { sendRequest, calls: sendRequestCalls } = createSendRequestDouble() + try { + execFileSync('mkfifo', [filePath], { timeout: 5_000 }) + lspManager = createLspManagerDouble({ + isFileOpen: () => false, + openFile, + sendRequest, + }) + + const result = await LSPTool.call( + { operation: 'hover', filePath, line: 1, character: 1 }, + {} as never, + ) + + expect(result.data.result).toContain('not a regular file') + expect(openFile).not.toHaveBeenCalled() + expect(sendRequestCalls).not.toHaveBeenCalled() + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 10_000) + +test('LSPTool does not redundantly open a current document', async () => { + const openFile = mock(async () => {}) + const { sendRequest, calls: sendRequestCalls } = createSendRequestDouble() + lspManager = createLspManagerDouble({ + isFileOpen: () => true, + openFile, + sendRequest, + }) + + await LSPTool.call( + { + operation: 'hover', + filePath: '/repo/src/current.ts', + line: 1, + character: 1, + }, + {} as never, + ) + + expect(openFile).not.toHaveBeenCalled() + expect(sendRequestCalls).toHaveBeenCalledTimes(1) +}) + +test('LSPTool request params use the shared canonical document URI', async () => { + const { sendRequest, calls: sendRequestCalls } = createSendRequestDouble() + lspManager = createLspManagerDouble({ + isFileOpen: () => true, + openFile: mock(async () => {}), + sendRequest, + }) + + await LSPTool.call( + { + operation: 'hover', + filePath: '/repo/Source File.ts', + line: 1, + character: 1, + }, + {} as never, + ) + + expect(sendRequestCalls).toHaveBeenCalledWith( + '/repo/Source File.ts', + 'textDocument/hover', + { + textDocument: { + uri: getLspDocumentIdentity('/repo/Source File.ts').fileUri, + }, + position: { line: 0, character: 0 }, + }, + ) +}) + +for (const [contextName, contextCode] of [ + ['replacement generation', LSP_SERVER_GENERATION_CHANGED], + ['document revision change', LSP_DOCUMENT_REVISION_CHANGED], +] as const) { + for (const operation of ['incomingCalls', 'outgoingCalls'] as const) { + test(`LSPTool retries ${operation} after one ${contextName}`, async () => { + const requests: Array<{ + method: string + expectedGeneration: number | undefined + expectedDocumentRevision: number | undefined + expectedDocumentCloseEpoch: number | undefined + itemGeneration: number | undefined + }> = [] + let prepareGeneration = 0 + const sendRequestWithGeneration: LSPServerManager['sendRequestWithGeneration'] = + async ( + _filePath: string, + method: string, + params: unknown, + options?: { + expectedGeneration?: number + expectedDocumentRevision?: number + expectedDocumentCloseEpoch?: number + }, + ) => { + const itemGeneration = ( + params as { item?: { data?: { generation?: number } } } + ).item?.data?.generation + requests.push({ + method, + expectedGeneration: options?.expectedGeneration, + expectedDocumentRevision: options?.expectedDocumentRevision, + expectedDocumentCloseEpoch: + options?.expectedDocumentCloseEpoch, + itemGeneration, + }) + + if (method === 'textDocument/prepareCallHierarchy') { + prepareGeneration++ + return { + result: [ + { + name: 'target', + kind: 12, + uri: 'file:///repo/src/current.ts', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 6 }, + }, + selectionRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 6 }, + }, + data: { generation: prepareGeneration }, + }, + ] as T, + serverGeneration: prepareGeneration, + documentRevision: prepareGeneration, + documentCloseEpoch: prepareGeneration, + } + } + + if (options?.expectedGeneration === 1) { + throw Object.assign(new Error('request context changed'), { + code: contextCode, + }) + } + + return { + result: [] as T, + serverGeneration: options?.expectedGeneration ?? 0, + documentRevision: options?.expectedDocumentRevision ?? 0, + documentCloseEpoch: options?.expectedDocumentCloseEpoch ?? 0, + } + } + + lspManager = createLspManagerDouble({ + isFileOpen: () => true, + sendRequestWithGeneration, + }) + + await LSPTool.call( + { + operation, + filePath: '/repo/src/current.ts', + line: 1, + character: 1, + }, + {} as never, + ) + + const callMethod = + operation === 'incomingCalls' + ? 'callHierarchy/incomingCalls' + : 'callHierarchy/outgoingCalls' + expect(requests).toEqual([ + { + method: 'textDocument/prepareCallHierarchy', + expectedGeneration: undefined, + expectedDocumentRevision: undefined, + expectedDocumentCloseEpoch: undefined, + itemGeneration: undefined, + }, + { + method: callMethod, + expectedGeneration: 1, + expectedDocumentRevision: 1, + expectedDocumentCloseEpoch: 1, + itemGeneration: 1, + }, + { + method: 'textDocument/prepareCallHierarchy', + expectedGeneration: undefined, + expectedDocumentRevision: undefined, + expectedDocumentCloseEpoch: undefined, + itemGeneration: undefined, + }, + { + method: callMethod, + expectedGeneration: 2, + expectedDocumentRevision: 2, + expectedDocumentCloseEpoch: 2, + itemGeneration: 2, + }, + ]) + }) + } +} diff --git a/src/tools/AgentTool/AgentTool.schema.test.ts b/src/tools/AgentTool/AgentTool.schema.test.ts index 2eb9051170..05acc677f5 100644 --- a/src/tools/AgentTool/AgentTool.schema.test.ts +++ b/src/tools/AgentTool/AgentTool.schema.test.ts @@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' +import { fileURLToPath } from 'node:url' import { AgentTool, assertAgentToolCwdAllowed, @@ -32,6 +33,54 @@ afterAll(() => { } }) +describe('AgentTool input schema background gates', () => { + for (const { name, disableBackground, fork, interactive, backgroundField } of [ + { name: 'default', disableBackground: false, fork: false, interactive: true, backgroundField: true }, + { name: 'background tasks disabled', disableBackground: true, fork: false, interactive: true, backgroundField: false }, + { name: 'interactive fork', disableBackground: false, fork: true, interactive: true, backgroundField: false }, + { name: 'noninteractive fork', disableBackground: false, fork: true, interactive: false, backgroundField: true }, + ]) { + test(`${name} loads the module and preserves cwd validation`, () => { + // Set session state before importing: buildTool reads the gated schema + // during module initialization, and lazySchema memoizes that choice. + const child = Bun.spawnSync([ + process.execPath, + ...(fork ? ['--feature=FORK_SUBAGENT'] : []), + '--eval', + ` + import assert from 'node:assert/strict' + import { setIsInteractive } from ${JSON.stringify(new URL('../../bootstrap/state.ts', import.meta.url).href)} + setIsInteractive(${interactive}) + const { inputSchema, fullInputSchema } = await import(${JSON.stringify(new URL('./AgentTool.tsx', import.meta.url).href)}) + assert.equal('run_in_background' in inputSchema().shape, ${backgroundField}) + assert.equal('run_in_background' in fullInputSchema().shape, true) + const baseInput = ${JSON.stringify(baseInput)} + for (const schema of [inputSchema(), fullInputSchema()]) { + assert.equal(schema.safeParse(baseInput).success, true) + assert.equal(schema.safeParse({ ...baseInput, cwd: ${JSON.stringify(existingCwd)} }).success, true) + const invalid = schema.safeParse({ ...baseInput, cwd: 'relative/path' }) + assert.equal(invalid.success, false) + assert.deepEqual(invalid.error.issues, [{ + code: 'custom', path: ['cwd'], message: 'cwd must be an absolute path.' + }]) + } + console.log('ok') + `, + ], { + cwd: fileURLToPath(new URL('../../../', import.meta.url)), + env: { + ...process.env, + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: String(disableBackground), + }, + timeout: 20_000, + }) + expect(child.stderr.toString()).toBe('') + expect(child.exitCode).toBe(0) + expect(child.stdout.toString().trim()).toBe('ok') + }, 25_000) + } +}) + describe('AgentTool input schema model override', () => { test('accepts aliases and custom provider-supported model IDs', () => { const acceptedModels = [ diff --git a/src/tools/AgentTool/AgentTool.teammateModel.test.ts b/src/tools/AgentTool/AgentTool.teammateModel.test.ts index 4d7a472138..52545012ab 100644 --- a/src/tools/AgentTool/AgentTool.teammateModel.test.ts +++ b/src/tools/AgentTool/AgentTool.teammateModel.test.ts @@ -71,15 +71,20 @@ function restoreEnv(key: keyof typeof originalEnv): void { } async function importActualModelAllowlist(): Promise { - return import( - `../../utils/model/modelAllowlist.ts?agentToolActual=${Date.now()}-${Math.random()}` - ) + // Bun repoints module namespaces when mocked; keep the original exports. + return { + ...await import( + `../../utils/model/modelAllowlist.ts?agentToolActual=${Date.now()}-${Math.random()}` + ), + } } async function importActualSettings(): Promise { - return import( - `../../utils/settings/settings.ts?agentToolActual=${Date.now()}-${Math.random()}` - ) + return { + ...await import( + `../../utils/settings/settings.ts?agentToolActual=${Date.now()}-${Math.random()}` + ), + } } async function importActualSpawnMultiAgent(): Promise { diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 069058254b..811ffe1cf5 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -100,22 +100,30 @@ const baseInputSchema = lazySchema(() => z.object({ })); // Full schema combining base + multi-agent params + isolation -export const fullInputSchema = lazySchema(() => { +const fullInputObjectSchema = lazySchema(() => { // Multi-agent parameters const multiAgentInputSchema = z.object({ name: z.string().optional().describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), mode: permissionModeSchema().optional().describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).') }); - return baseInputSchema().merge(multiAgentInputSchema).extend({ + return baseInputSchema().extend(multiAgentInputSchema.shape).extend({ isolation: z.enum(['worktree']).optional().describe('Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo. When the session is outside a git repository (for example a parent of multiple repos), pass cwd set to the target repository root so the worktree is created from that repo.'), cwd: z.string().optional().describe('Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. When isolation is "worktree", cwd selects which git repository to create the worktree from — use this when the session cwd is not itself a git repo (for example a folder parenting multiple repos).') - }).refine(input => input.cwd === undefined || isAbsolute(input.cwd), { - path: ['cwd'], - message: 'cwd must be an absolute path.', }); }); +const isAbsoluteCwd = (input: { cwd?: string }): boolean => + input.cwd === undefined || isAbsolute(input.cwd); +const cwdRefinementParams = { + path: ['cwd'], + message: 'cwd must be an absolute path.', +}; + +export const fullInputSchema = lazySchema(() => + fullInputObjectSchema().refine(isAbsoluteCwd, cwdRefinementParams) +); + // Strip optional fields from the schema when the backing feature is off so // the model never sees them. Done via .omit() rather than conditional spread // inside .extend() because the spread-ternary breaks Zod's type inference @@ -125,7 +133,8 @@ export const fullInputSchema = lazySchema(() => { export const inputSchema = lazySchema(() => { // cwd stays available in the open build so multi-repo parent sessions can // pin subagents (and worktree isolation) to a child repository (#2052). - const schema = fullInputSchema(); + // Zod requires object omission before adding refinements. + const schema = fullInputObjectSchema(); // GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which // was removed in 906da6c723): the divergence window is one-session-per- @@ -134,9 +143,10 @@ export const inputSchema = lazySchema(() => { // by forceAsync) or "schema hides a param that would've worked" (gate // flips off mid-session: everything still runs async via memoized // forceAsync). No Zod rejection, no crash — unlike required→optional. - return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ + const gatedSchema = isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema; + return gatedSchema.refine(isAbsoluteCwd, cwdRefinementParams); }); type InputSchema = ReturnType; @@ -1151,11 +1161,9 @@ export const AgentTool = buildTool({ } const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); - // Mark task completed FIRST so TaskOutput(block=true) - // unblocks immediately. classifyHandoffIfNeeded and - // cleanupWorktreeIfNeeded can hang — they must not gate - // the status transition (gh-20236). - completeAsyncAgent(agentResult, rootSetAppState); + // Transcript writes are awaited by runAgent; completion joins the output link. + // Keep notification embellishments after the terminal state (gh-20236). + if (!await completeAsyncAgent(agentResult, rootSetAppState)) return; // Extract text from agent result content for the notification let finalMessage = extractTextContent(agentResult.content, '\n'); diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index bedcbd5ef4..41a35d446c 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -280,6 +280,8 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage content: completionMessage, usage: { ...usage, + output_tokens_details: null, + fallback_credit: null, inference_geo: null, iterations: null, speed: null diff --git a/src/tools/AgentTool/UI.usage.test.ts b/src/tools/AgentTool/UI.usage.test.ts new file mode 100644 index 0000000000..5041558f98 --- /dev/null +++ b/src/tools/AgentTool/UI.usage.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'bun:test' +import { Children, isValidElement, type ReactNode } from 'react' +import { Message } from '../../components/Message.js' +import { EMPTY_USAGE } from '../../services/api/emptyUsage.js' +import type { AssistantMessage } from '../../types/message.js' +import { renderToolResultMessage } from './UI.js' + +function findAssistantMessage(node: ReactNode): AssistantMessage | undefined { + for (const child of Children.toArray(node)) { + if (!isValidElement<{ children?: ReactNode; message?: AssistantMessage }>(child)) continue + if (child.type === Message) return child.props.message + const message = findAssistantMessage(child.props.children) + if (message) return message + } +} + +test('completed agent summaries supply null details when tool-result usage omits them', () => { + const { output_tokens_details: _details, ...usage } = EMPTY_USAGE + const tree = renderToolResultMessage({ + status: 'completed', + agentId: 'test-agent', + content: [], + prompt: '', + totalToolUseCount: 1, + totalDurationMs: 100, + totalTokens: 7, + usage: { ...usage, input_tokens: 3, output_tokens: 4 }, + }, [], { tools: [], verbose: false, theme: 'dark' }) + const message = findAssistantMessage(tree) + expect(message).toBeDefined() + expect(message?.message.usage.output_tokens_details).toBeNull() + expect(message?.message.usage.output_tokens).toBe(4) +}) diff --git a/src/tools/AgentTool/agentMemorySnapshot.ts b/src/tools/AgentTool/agentMemorySnapshot.ts index 155d1b622a..ee3f72b6c9 100644 --- a/src/tools/AgentTool/agentMemorySnapshot.ts +++ b/src/tools/AgentTool/agentMemorySnapshot.ts @@ -42,7 +42,7 @@ function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string { async function readJsonFile( path: string, - schema: z.ZodType, + schema: Pick, 'safeParse'>, ): Promise { try { const content = await readFile(path, { encoding: 'utf-8' }) diff --git a/src/tools/AgentTool/agentToolUtils.ts b/src/tools/AgentTool/agentToolUtils.ts index 64e4a473d9..299e1c5872 100644 --- a/src/tools/AgentTool/agentToolUtils.ts +++ b/src/tools/AgentTool/agentToolUtils.ts @@ -615,11 +615,9 @@ export async function runAsyncAgentLifecycle({ const agentResult = finalizeAgentTool(agentMessages, taskId, metadata) - // Mark task completed FIRST so TaskOutput(block=true) unblocks - // immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult - // (git exec) are notification embellishments that can hang — they must - // not gate the status transition (gh-20236). - completeAsyncAgent(agentResult, rootSetAppState) + // Transcript writes are awaited by runAgent; completion joins the output link. + // Keep notification embellishments after the terminal state (gh-20236). + if (!await completeAsyncAgent(agentResult, rootSetAppState)) return let finalMessage = extractTextContent(agentResult.content, '\n') diff --git a/src/tools/AgentTool/runAgent.output.test.ts b/src/tools/AgentTool/runAgent.output.test.ts new file mode 100644 index 0000000000..b5f9d782dc --- /dev/null +++ b/src/tools/AgentTool/runAgent.output.test.ts @@ -0,0 +1,835 @@ +import { REAL_PROVIDER_TEST_CHILD_ENV, REAL_PROVIDER_TEST_TIMEOUT_MS, runTestFileWithRealProviders } from '../../test/providerModuleIsolation.js' +import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test' +import { randomUUID } from 'node:crypto' +import { getSessionId, getSessionProjectDir, switchSession } from '../../bootstrap/state.js' +import { getDefaultAppState } from '../../state/AppStateStore.js' +import { clearConversation } from '../../commands/clear/conversation.js' +import { runForkedAgent } from '../../utils/forkedAgent.js' +import { AbortError } from '../../utils/errors.js' +import { spawn } from 'node:child_process' +import type { ShellCommand } from '../../utils/ShellCommand.js' +import { TaskOutput } from '../../utils/task/TaskOutput.js' +import { registerForeground } from '../../tasks/LocalShellTask/LocalShellTask.js' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join, sep } from 'node:path' +import { tmpdir } from 'node:os' +import type { AppState } from '../../state/AppState.js' +import type { ToolUseContext } from '../../Tool.js' +import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +import { asAgentId, asSessionId } from '../../types/ids.js' +import { createAssistantMessage, createUserMessage } from '../../utils/messages.js' +import { FileStateCache, createFileStateCacheWithSizeLimit, READ_FILE_STATE_CACHE_SIZE } from '../../utils/fileStateCache.js' +import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' +import { getAgentTranscript, readAgentMetadata, writeAgentMetadata, setAgentTranscriptSubdir, getAgentTranscriptPath, getProjectDir, resetSessionFilePointer, recordSidechainTranscript, recordTranscript, setInternalEventWriter, resetProjectForTesting, flushSessionStorage } from '../../utils/sessionStorage.js' +import { withTranscriptFileLock } from '../../utils/transcriptFileLock.js' +import { registerAsyncAgent, registerAgentForeground, backgroundAgentTask, completeAgentTask, killAsyncAgent } from '../../tasks/LocalAgentTask/LocalAgentTask.js' +import { getTaskOutputPath, _clearOutputsForTest } from '../../utils/task/diskOutput.js' +import { clearPendingNotifications, getPendingNotificationsSnapshot } from '../../utils/messageQueueManager.js' +import { runAsyncAgentLifecycle } from './agentToolUtils.js' +import { runAgent } from './runAgent.js' +import { executeStopHooks } from '../../utils/hooks.js' +import { addSessionHook } from '../../utils/hooks/sessionHooks.js' +import { resumeAgentBackground } from './resumeAgent.js' +import { startBackgroundSession } from '../../tasks/LocalMainSessionTask.js' +import type { AgentDefinition } from './loadAgentsDir.js' + +const actualStorage = { ...await import('../../utils/sessionStorage.js') } +const actualDiskOutput = { ...await import('../../utils/task/diskOutput.js') } +const actualUuid = { ...await import('../../utils/uuid.js') } +let originalSession: ReturnType +let originalProjectDir: ReturnType +let oldSimple: string | undefined +const actualGit = { ...await import('../../utils/git.js') } +const actualQuery = { ...await import('../../query.js') } +let configDir: string +let oldPersistence: string | undefined + +function deferred() { + let resolve!: () => void + const promise = new Promise(res => { resolve = res }) + return { promise, resolve } +} + +async function waitFor(condition: () => boolean) { + const deadline = Date.now() + 2000 + while (!condition()) { + if (Date.now() > deadline) throw new Error('Timed out waiting for agent lifecycle checkpoint') + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +// Use the existing clean-child harness: other agent tests replace these +// production modules globally, and this test needs real registration/storage. +if (process.env[REAL_PROVIDER_TEST_CHILD_ENV] !== '1') { + test('agent output regression cases in a clean process', () => + runTestFileWithRealProviders(import.meta.path), REAL_PROVIDER_TEST_TIMEOUT_MS + 5_000) +} else { +beforeEach(async () => { + await acquireSharedMutationLock('tools/AgentTool/runAgent.output.test.ts') + originalSession = getSessionId() + originalProjectDir = getSessionProjectDir() + switchSession(originalSession, undefined) + oldSimple = process.env.CLAUDE_CODE_SIMPLE + process.env.CLAUDE_CODE_SIMPLE = '1' + oldPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + configDir = await mkdtemp(join(tmpdir(), 'agent-output-')) + setClaudeConfigHomeDirForTesting(configDir) + resetSettingsCache() + resetProjectForTesting() + getProjectDir.cache.clear?.() + clearPendingNotifications() + expect(getAgentTranscriptPath(asAgentId('fixture-check')).startsWith(configDir + sep)).toBe(true) +}) +afterEach(async () => { + try { + mock.module('../../utils/sessionStorage.js', () => actualStorage) + mock.module('../../query.js', () => actualQuery) + mock.module('../../utils/git.js', () => actualGit) + mock.module('../../utils/uuid.js', () => actualUuid) + mock.module('../../utils/task/diskOutput.js', () => actualDiskOutput) + await flushSessionStorage() + await _clearOutputsForTest() + clearPendingNotifications() + resetProjectForTesting() + switchSession(originalSession, originalProjectDir) + getProjectDir.cache.clear?.() + setClaudeConfigHomeDirForTesting(undefined) + if (oldSimple === undefined) delete process.env.CLAUDE_CODE_SIMPLE + else process.env.CLAUDE_CODE_SIMPLE = oldSimple + resetSettingsCache() + if (oldPersistence === undefined) delete process.env.TEST_ENABLE_SESSION_PERSISTENCE + else process.env.TEST_ENABLE_SESSION_PERSISTENCE = oldPersistence + await rm(configDir, { recursive: true, force: true }) + } finally { releaseSharedMutationLock() } +}) + +for (const phase of ['success', 'initial', 'incremental'] as const) { + const cancel = phase !== 'success' + test(`agent ${cancel ? `stops during its ${phase}` : 'completes after its'} real transcript append while unrelated persistence is blocked`, async () => { + const remoteEntered = deferred() + const remoteRelease = deferred() + setInternalEventWriter(async () => { remoteEntered.resolve(); await remoteRelease.promise }) + const foreground = recordTranscript([createUserMessage({ content: 'unrelated foreground' })]) + await remoteEntered.promise + const agentId = asAgentId(`output-${phase}`) + const transcript = getAgentTranscriptPath(agentId) + await mkdir(dirname(transcript), { recursive: true }) + const locked = deferred() + const release = deferred() + const holdTranscript = () => withTranscriptFileLock(transcript, async () => { locked.resolve(); await release.promise }) + let holder: Promise | undefined + if (phase !== 'incremental') { + holder = holdTranscript() + await locked.promise + } + const submitted = deferred() + let branchCalls = 0 + mock.module('../../utils/git.js', () => ({ ...actualGit, getBranch: async () => { + // This checkpoint is inside the submitted storage chain, after metadata + // persistence. The held file lock still prevents its physical append. + if (++branchCalls === (phase === 'incremental' ? 2 : 1)) submitted.resolve() + return 'test' + } })) + let shell: ShellCommand | undefined + let childExit: Promise | undefined + const shellId = `shell-${phase}` + let queried = false + mock.module('../../query.js', () => ({ + ...actualQuery, + query: async function* () { + queried = true + if (phase === 'incremental') { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + childExit = new Promise((resolve, reject) => { child.once('exit', () => resolve()); child.once('error', reject) }) + // A minimal shell adapter isolates this regression from ShellCommand's + // existing cleanup/exit race; the child and agent cleanup are real. + shell = { + status: 'backgrounded', + taskOutput: new TaskOutput(shellId, null), + background: () => true, + kill: () => { child.kill(); shell!.status = 'killed' }, + cleanup: () => {}, + result: childExit.then(() => ({ stdout: '', stderr: '', code: 0, interrupted: true })), + } + registerForeground({ command: 'test child', description: 'test child', shellCommand: shell, agentId }, setAppState) + holder = holdTranscript() + await locked.promise + } + yield createAssistantMessage({ content: 'finished output' }) + return { reason: 'completed' } + }, + })) + const selectedAgent = { agentType: 'general-purpose', source: 'built-in', baseDir: 'built-in', whenToUse: 'test', getSystemPrompt: () => 'test' } as AgentDefinition + let appState = { + tasks: {}, todos: { [agentId]: [] }, speculation: { status: 'idle' }, mainLoopModel: 'test-model', mainLoopModelForSession: 'test-model', + toolPermissionContext: { mode: 'default', additionalWorkingDirectories: new Map(), alwaysAllowRules: {}, alwaysDenyRules: {}, alwaysAskRules: {} }, + mcp: { clients: [], tools: [] }, + } as unknown as AppState + const setAppState = (updater: (prev: AppState) => AppState) => { appState = updater(appState) } + const task = registerAsyncAgent({ agentId, description: 'test output', prompt: 'initial prompt', selectedAgent, setAppState }) + const context = { + options: { commands: [], tools: [], mainLoopModel: 'test-model', thinkingConfig: { type: 'disabled' }, mcpClients: [], mcpResources: {}, agentDefinitions: { activeAgents: [], allAgents: [] } }, + abortController: task.abortController, + readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), + messages: [], getAppState: () => appState, setAppState, + setInProgressToolUseIDs: () => {}, setResponseLength: () => {}, updateFileHistoryState: () => {}, updateAttributionState: () => {}, + } as unknown as ToolUseContext + const lifecycle = runAsyncAgentLifecycle({ + taskId: agentId, abortController: task.abortController!, + makeStream: () => runAgent({ + agentDefinition: selectedAgent, promptMessages: [createUserMessage({ content: 'initial prompt' }), createUserMessage({ content: 'remaining initial history' })], + toolUseContext: context, canUseTool: async () => ({ behavior: 'allow' }), + isAsync: true, querySource: 'agent:builtin:general-purpose', availableTools: [], + override: { agentId, abortController: task.abortController, userContext: {}, systemContext: {}, systemPrompt: ['test'] as never }, + }), + metadata: { prompt: 'initial prompt', resolvedAgentModel: 'test-model', isBuiltInAgent: true, startTime: Date.now(), agentType: 'general-purpose', isAsync: true }, + description: 'test output', toolUseContext: context, rootSetAppState: setAppState, + agentIdForCleanup: agentId, enableSummarization: false, getWorktreeResult: async () => ({}), + }) + let cancellationDeadline: ReturnType | undefined + try { + await locked.promise + await submitted.promise + expect(appState.tasks[agentId]?.status).toBe('running') + expect(queried).toBe(phase === 'incremental') + if (cancel) { + killAsyncAgent(agentId, setAppState) + expect(appState.tasks[agentId]?.status).toBe('killed') + // Keep storage locked until stopped handling and runner cleanup finish. + await Promise.race([ + lifecycle, + new Promise((_, reject) => { + cancellationDeadline = setTimeout(() => reject(new Error('cancellation is still waiting for transcript storage')), 2_000) + }), + ]) + expect(appState.todos).not.toHaveProperty(agentId) + if (phase === 'incremental') { + expect(appState.tasks[shellId]?.status).toBe('killed') + expect(shell?.status).toBe('killed') + await childExit + } + } else { + release.resolve() + await lifecycle + } + expect(appState.tasks[agentId]?.status).toBe(cancel ? 'killed' : 'completed') + const notifications = getPendingNotificationsSnapshot().map(item => item.value).join('\n') + expect(notifications).toContain(`${cancel ? 'killed' : 'completed'}`) + if (cancel) expect(notifications).not.toContain('completed') + else { + const output = await readFile(getTaskOutputPath(agentId), 'utf8') + expect(output).toContain('initial prompt') + expect(output).toContain('finished output') + expect(output.indexOf('initial prompt')).toBeLessThan(output.indexOf('finished output')) + } + } finally { + clearTimeout(cancellationDeadline) + release.resolve() + remoteRelease.resolve() + if (shell?.status === 'running' || shell?.status === 'backgrounded') { + shell.kill() + shell.cleanup() + } + await childExit + await holder + await foreground + await lifecycle + } + // Cancellation releases the runner, not the tracked producer or its messages. + await flushSessionStorage() + const persisted = await readFile(transcript, 'utf8') + expect(persisted).toContain('initial prompt') + expect(persisted).toContain('remaining initial history') + if (phase === 'incremental') expect(persisted).toContain('finished output') + }) +} + + +test('replacement runs join earlier transcript chains for the same agent only', async () => { + const entered = deferred() + const release = deferred() + const replacementEntered = deferred() + const replacementRelease = deferred() + let branchCalls = 0 + mock.module('../../utils/git.js', () => ({ + ...actualGit, + getBranch: async () => { + branchCalls++ + if (branchCalls === 1) { entered.resolve(); await release.promise } + if (branchCalls === 3) { replacementEntered.resolve(); await replacementRelease.promise } + return 'test' + }, + })) + const earlier = recordSidechainTranscript([createUserMessage({ content: 'earlier history' })], 'same-agent') + await entered.promise + let replacementFinished = false + const replacement = recordSidechainTranscript([createAssistantMessage({ content: 'replacement answer' })], 'same-agent') + .then(() => { replacementFinished = true }) + try { + await recordSidechainTranscript([createUserMessage({ content: 'other output' })], 'other-agent') + expect(await readFile(getAgentTranscriptPath(asAgentId('other-agent')), 'utf8')).toContain('other output') + await new Promise(resolve => setTimeout(resolve, 25)) + expect(replacementFinished).toBe(false) + let flushed = false + const flush = flushSessionStorage().then(() => { flushed = true }) + release.resolve() + await replacementEntered.promise + await new Promise(resolve => setTimeout(resolve, 0)) + expect(flushed).toBe(false) + replacementRelease.resolve() + await Promise.all([earlier, replacement, flush]) + const output = await readFile(getAgentTranscriptPath(asAgentId('same-agent')), 'utf8') + expect(output.indexOf('earlier history')).toBeLessThan(output.indexOf('replacement answer')) + } finally { + release.resolve() + replacementRelease.resolve() + await Promise.all([earlier, replacement]) + } +}) + +// Retain the standalone physical-readiness oracle in this real-module process. +test('sidechain write resolves only after its file is readable, without a session flush', async () => { + const agentId = asAgentId('durable-agent') + const path = getAgentTranscriptPath(agentId) + const release = deferred() + const locked = deferred() + let holder: Promise | undefined + let writing: Promise | undefined + try { + await mkdir(dirname(path), { recursive: true }) + holder = withTranscriptFileLock(path, async () => { locked.resolve(); await release.promise }) + await locked.promise + let settled = false + writing = recordSidechainTranscript([createUserMessage({ content: 'durable output' })], agentId) + .then(() => { settled = true }) + await new Promise(resolve => setTimeout(resolve, 150)) + expect(settled).toBe(false) + release.resolve() + await writing + expect(await readFile(path, 'utf8')).toContain('durable output') + } finally { + release.resolve() + await holder + await writing + } +}) + +test('live-destination sidechain chains preserve submission order', async () => { + const agentId = asAgentId('live-chain-order') + const entered = deferred() + const release = deferred() + let calls = 0 + mock.module('../../utils/git.js', () => ({ ...actualGit, getBranch: async () => { + if (++calls === 1) { entered.resolve(); await release.promise } + return 'test' + } })) + const first = createUserMessage({ content: 'first live chain' }) + const second = createAssistantMessage({ content: 'second live chain' }) + const writing = recordSidechainTranscript([first], agentId, null, { captureDestination: false }) + await entered.promise + const queued = recordSidechainTranscript([second], agentId, first.uuid, { captureDestination: false }) + try { + release.resolve() + await Promise.all([writing, queued]) + const entries = (await readFile(getAgentTranscriptPath(agentId), 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + expect(entries.map(entry => entry.uuid)).toEqual([first.uuid, second.uuid]) + } finally { + release.resolve() + await Promise.all([writing, queued]) + } +}) + +test('queued sidechain chains keep their destination and session across clear pointer reset', async () => { + const agentId = asAgentId('session-boundary') + const sessionA = getSessionId() + const pathA = getAgentTranscriptPath(agentId) + const first = createUserMessage({ content: 'A initial' }) + const tail = createAssistantMessage({ content: 'A tail' }) + const answer = createAssistantMessage({ content: 'A queued answer' }) + const release = deferred() + const locked = deferred() + let holder: Promise | undefined + let initial: Promise | undefined + let queued: Promise | undefined + try { + await mkdir(dirname(pathA), { recursive: true }) + holder = withTranscriptFileLock(pathA, async () => { locked.resolve(); await release.promise }) + await locked.promise + initial = recordSidechainTranscript([first, tail], agentId) + queued = recordSidechainTranscript([answer], agentId, tail.uuid) + await new Promise(resolve => setTimeout(resolve, 150)) + switchSession(asSessionId(randomUUID())) + await resetSessionFilePointer() + const sessionB = getSessionId() + const pathB = getAgentTranscriptPath(agentId) + await recordSidechainTranscript([createUserMessage({ content: 'B new output' })], agentId) + expect(await readFile(pathB, 'utf8')).toContain('B new output') + release.resolve() + await Promise.all([initial, queued]) + const a = (await readFile(pathA, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + const b = (await readFile(pathB, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + expect(a.map(entry => entry.uuid)).toEqual([first.uuid, tail.uuid, answer.uuid]) + expect(a.map(entry => entry.sessionId)).toEqual([sessionA, sessionA, sessionA]) + expect(a.map(entry => entry.parentUuid)).toEqual([null, first.uuid, tail.uuid]) + expect(b).toHaveLength(1) + expect(b[0].sessionId).toBe(sessionB) + } finally { + release.resolve() + await Promise.all([holder, initial, queued]) + } +}) + +for (const foreground of [false, true]) { + test(`clear preserves finished ${foreground ? 'foreground' : 'async'} output while initialization is pending`, async () => { + let state = getDefaultAppState() + const setAppState = (update: (prev: AppState) => AppState) => { state = update(state) } + const agentId = asAgentId(`finished-${foreground}`) + const activeId = asAgentId(`active-${foreground}`) + const release = deferred() + const links: Promise[] = [] + const liveLinks: Promise[] = [] + const releaseRetarget = deferred() + const releaseInitial = deferred() + mock.module('../../utils/task/diskOutput.js', () => ({ + ...actualDiskOutput, + initTaskOutputAsSymlink: (id: string, path: string) => { + const ready = id === agentId ? (links[0] ?? release.promise) : liveLinks.length ? releaseRetarget.promise : foreground ? releaseInitial.promise : Promise.resolve() + const link = ready.then(() => actualDiskOutput.initTaskOutputAsSymlink(id, path)) + if (id === agentId) links.push(link) + else liveLinks.push(link) + return link + }, + })) + const selectedAgent = { agentType: 'test' } as AgentDefinition + const register = foreground ? registerAgentForeground : registerAsyncAgent + register({ agentId, description: 'finished', prompt: 'test', selectedAgent, setAppState }) + if (foreground) backgroundAgentTask(agentId, () => state, setAppState) + const active = registerAsyncAgent({ agentId: activeId, description: 'active', prompt: 'test', selectedAgent, setAppState }) + if (!foreground) await active.outputReady + await recordSidechainTranscript([createUserMessage({ content: 'finished readable output' })], agentId) + const finish = () => completeAgentTask({ agentId, content: [] } as never, setAppState) + let completion = foreground ? undefined : finish() + let activeCompletion: Promise | undefined + try { + expect(state.tasks[agentId]?.status).toBe('running') + await clearConversation({ setMessages: () => { completion ??= finish() }, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: () => state, setAppState }) + release.resolve() + await completion + await Promise.all(links) + expect(state.tasks[agentId]?.status).toBe('completed') + expect(await readFile(getTaskOutputPath(agentId), 'utf8')).toContain('finished readable output') + expect(links).toHaveLength(1) + // A genuine live producer must still migrate and expose new-session output. + await recordSidechainTranscript([createUserMessage({ content: 'new active output' })], activeId) + activeCompletion = completeAgentTask({ agentId: activeId, content: [] } as never, setAppState) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(state.tasks[activeId]?.status).toBe('running') + releaseRetarget.resolve() + if (foreground) { + await new Promise(resolve => setTimeout(resolve, 20)) + expect(state.tasks[activeId]?.status).toBe('running') + } + releaseInitial.resolve() + await activeCompletion + expect(state.tasks[activeId]?.status).toBe('completed') + expect(liveLinks).toHaveLength(2) + expect(await readFile(getTaskOutputPath(activeId), 'utf8')).toContain('new active output') + } finally { + release.resolve() + await completion + await Promise.all(links) + releaseRetarget.resolve() + releaseInitial.resolve() + await activeCompletion + await Promise.all(liveLinks) + killAsyncAgent(activeId, setAppState) + } + }) +} + +for (const foreground of [false, true]) { + for (const continuesInB of [false, true]) { + test(`final ${foreground ? 'foreground' : 'async'} write spanning clear stays readable, continued=${continuesInB}`, async () => { + const agentId = asAgentId(`final-clear-${foreground}-${continuesInB}`) + let state = getDefaultAppState() + const setAppState = (update: (prev: AppState) => AppState) => { state = update(state) } + const entered = deferred() + const release = deferred() + let holdNextBranch = false + mock.module('../../utils/git.js', () => ({ ...actualGit, getBranch: async () => { + if (holdNextBranch) { holdNextBranch = false; entered.resolve(); await release.promise } + return 'test' + } })) + let hookPath: string | undefined + let verifierPath: string | undefined + let verifierRules: readonly string[] | undefined + mock.module('../../query.js', () => ({ ...actualQuery, query: async function* (params: Parameters[0]) { + if (params.querySource === 'hook_agent') { + const text = JSON.stringify(params.messages) + const inputText = params.messages[0] + if (inputText?.type !== 'user' || typeof inputText.message.content !== 'string') throw new Error(`unexpected hook input ${text}`) + hookPath = JSON.parse(inputText.message.content).agent_transcript_path + verifierPath = params.systemPrompt.join('\n').match(/available at: ([^\n]+)/)?.[1] + verifierRules = params.toolUseContext.getAppState().toolPermissionContext.alwaysAllowRules.session + yield { type: 'attachment', attachment: { type: 'structured_output', data: { ok: true } }, uuid: randomUUID(), timestamp: new Date().toISOString() } as never + return { reason: 'completed' } + } + holdNextBranch = true + yield createAssistantMessage({ content: 'final A answer' }) + if (continuesInB) yield createAssistantMessage({ content: 'continued B answer' }) + addSessionHook(setAppState, agentId, 'SubagentStop', '', { type: 'agent', prompt: '$ARGUMENTS', model: 'test-model' }) + const simple = process.env.CLAUDE_CODE_SIMPLE + delete process.env.CLAUDE_CODE_SIMPLE + try { + for await (const _result of executeStopHooks(undefined, params.toolUseContext.abortController.signal, 1000, false, agentId, params.toolUseContext, params.messages)) { /* consume actual hook dispatch */ } + } finally { process.env.CLAUDE_CODE_SIMPLE = simple } + return { reason: 'completed' } + } })) + const selectedAgent = { agentType: continuesInB ? 'general-purpose' : 'retained-agent', source: continuesInB ? 'built-in' : 'userSettings', baseDir: 'built-in', whenToUse: 'test', getSystemPrompt: () => 'test' } as AgentDefinition + const transcriptSubdir = foreground && !continuesInB ? join('workflows', 'retained') : undefined + if (transcriptSubdir) { + switchSession(getSessionId(), getProjectDir(join(configDir, 'resumed-project'))) + setAgentTranscriptSubdir(agentId, transcriptSubdir) + } + const register = foreground ? registerAgentForeground : registerAsyncAgent + register({ agentId, description: 'clear output', prompt: 'initial prompt', selectedAgent, setAppState }) + const task = state.tasks[agentId] + if (task?.type !== 'local_agent') throw new Error('expected registered local agent') + if (foreground) backgroundAgentTask(agentId, () => state, setAppState) + const pathA = getAgentTranscriptPath(agentId) + const context = { + options: { commands: [], tools: [], mainLoopModel: 'test-model', thinkingConfig: { type: 'disabled' }, mcpClients: [], mcpResources: {}, agentDefinitions: { activeAgents: [], allAgents: [] } }, + abortController: task.abortController, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), + messages: [], getAppState: () => state, setAppState, + setInProgressToolUseIDs: () => {}, setResponseLength: () => {}, updateFileHistoryState: () => {}, updateAttributionState: () => {}, + } as unknown as ToolUseContext + const lifecycle = runAsyncAgentLifecycle({ + taskId: agentId, abortController: task.abortController!, + makeStream: () => runAgent({ + agentDefinition: selectedAgent, promptMessages: [createUserMessage({ content: 'initial prompt' })], + toolUseContext: context, canUseTool: async () => ({ behavior: 'allow' }), isAsync: true, + querySource: 'agent:builtin:general-purpose', availableTools: [], transcriptSubdir, + override: { agentId, abortController: task.abortController, userContext: {}, systemContext: {}, systemPrompt: ['test'] as never }, + }), + metadata: { prompt: 'initial prompt', resolvedAgentModel: 'test-model', isBuiltInAgent: true, startTime: Date.now(), agentType: 'general-purpose', isAsync: true }, + description: 'clear output', toolUseContext: context, rootSetAppState: setAppState, + agentIdForCleanup: agentId, enableSummarization: false, getWorktreeResult: async () => ({}), + }) + try { + await entered.promise + await clearConversation({ setMessages: () => {}, readFileState: context.readFileState, getAppState: () => state, setAppState }) + await recordTranscript([createUserMessage({ content: 'new main B conversation' })]) + const pathB = getAgentTranscriptPath(agentId) + expect(pathB).not.toBe(pathA) + release.resolve() + await lifecycle + expect(state.tasks[agentId]?.status).toBe('completed') + expect(await readFile(pathA, 'utf8')).toContain('final A answer') + const output = await readFile(getTaskOutputPath(agentId), 'utf8') + expect(output).toContain(continuesInB ? 'continued B answer' : 'final A answer') + expect(hookPath).toBe(continuesInB ? pathB : pathA) + expect(verifierPath).toBe(hookPath) + expect(verifierRules).toContain(`Read(/${hookPath})`) + expect(await readFile(verifierPath!, 'utf8')).toContain(continuesInB ? 'continued B answer' : 'final A answer') + expect(getPendingNotificationsSnapshot().map(item => item.value).join('\n')).toContain('completed') + const transcript = await getAgentTranscript(agentId) + expect(JSON.stringify(transcript?.messages)).toContain(continuesInB ? 'continued B answer' : 'final A answer') + if (!continuesInB) { + expect(await readAgentMetadata(agentId)).toMatchObject({ agentType: selectedAgent.agentType, source: selectedAgent.source }) + context.options.agentDefinitions.activeAgents = [] + const resume = () => resumeAgentBackground({ agentId, prompt: 'continue after clear', toolUseContext: context, canUseTool: async () => ({ behavior: 'allow' }) }) + await expect(resume()).rejects.toThrow('unavailable or disabled') + context.options.agentDefinitions.activeAgents = [{ ...selectedAgent, source: 'projectSettings' } as AgentDefinition] + await expect(resume()).rejects.toThrow('identity mismatch') + } + context.options.agentDefinitions.activeAgents = [selectedAgent] + const resumed = deferred() + let resumedMessages: unknown + mock.module('../../query.js', () => ({ ...actualQuery, query: async function* ({ messages }: { messages: unknown }) { + resumedMessages = structuredClone(messages) + yield createAssistantMessage({ content: 'resumed answer' }) + } })) + // Observe actual lifecycle completion, not only resume's admission result. + context.setAppState = update => { + setAppState(update) + if (state.tasks[agentId]?.status !== 'running') resumed.resolve() + } + const result = await resumeAgentBackground({ agentId, prompt: 'continue after clear', toolUseContext: context, canUseTool: async () => ({ behavior: 'allow' }) }) + await resumed.promise + expect(state.tasks[agentId]?.status).toBe('completed') + expect(JSON.stringify(resumedMessages)).toContain(continuesInB ? 'continued B answer' : 'final A answer') + expect(JSON.stringify(resumedMessages)).toContain('continue after clear') + expect(await readFile(result.outputFile, 'utf8')).toContain('resumed answer') + if (!foreground && !continuesInB) { + // Two real SendMessage resumes may both pass stopped-task admission + // before their disk reads finish. The older completion cannot own B. + const { SendMessageTool } = await import('../SendMessageTool/SendMessageTool.js') + const { TaskStopTool } = await import('../TaskStopTool/TaskStopTool.js') + const firstRead = deferred() + const secondRead = deferred() + const oldLink = deferred() + const secondQueryDone = deferred() + let reads = 0 + let links = 0 + let queries = 0 + mock.module('../../utils/sessionStorage.js', () => ({ ...actualStorage, readAgentMetadata: async (id: typeof agentId) => { + const read = ++reads + const meta = await actualStorage.readAgentMetadata(id) + await (read === 1 ? firstRead : secondRead).promise + return meta + } })) + mock.module('../../utils/task/diskOutput.js', () => ({ ...actualDiskOutput, initTaskOutputAsSymlink: (id: string, path: string) => + (++links === 1 ? oldLink.promise : Promise.resolve()).then(() => actualDiskOutput.initTaskOutputAsSymlink(id, path)), + })) + mock.module('../../query.js', () => ({ ...actualQuery, query: async function* (params: Parameters[0]) { + if (++queries === 1) { yield createAssistantMessage({ content: 'older resume answer' }); return { reason: 'completed' } } + try { + await new Promise(resolve => params.toolUseContext.abortController.signal.addEventListener('abort', () => resolve(), { once: true })) + } finally { secondQueryDone.resolve() } + return { reason: 'completed' } + } })) + state.agentNameRegistry.set('overlap-target', agentId) + const send = () => SendMessageTool.call({ to: 'overlap-target', message: 'resume overlap' }, context, async () => ({ behavior: 'allow' }), {} as never) + const firstResume = send() + await waitFor(() => reads === 1) + const secondResume = send() + try { + await waitFor(() => reads === 2) + firstRead.resolve() + expect((await firstResume).data.success).toBe(true) + await waitFor(() => state.tasks[agentId]?.type === 'local_agent' && state.tasks[agentId].producerFinished === true) + const first = state.tasks[agentId] + if (first?.type !== 'local_agent') throw new Error('missing first resume') + secondRead.resolve() + expect((await secondResume).data.success).toBe(true) + await waitFor(() => queries === 2) + const successor = state.tasks[agentId] + if (successor?.type !== 'local_agent') throw new Error('missing successor') + oldLink.resolve() + await first.outputReady + await actualDiskOutput._clearOutputsForTest() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(state.tasks[agentId]?.status).toBe('running') + expect(await TaskStopTool.validateInput({ task_id: agentId }, context)).toEqual({ result: true }) + await TaskStopTool.call({ task_id: agentId }, context) + expect(successor.abortController!.signal.aborted).toBe(true) + await secondQueryDone.promise + await waitFor(() => getPendingNotificationsSnapshot().some(item => typeof item.value === 'string' && item.value.includes('killed'))) + } finally { + firstRead.resolve() + secondRead.resolve() + oldLink.resolve() + await Promise.all([firstResume, secondResume]) + killAsyncAgent(agentId, setAppState) + if (queries === 2) await secondQueryDone.promise + } + } + } finally { + release.resolve() + await lifecycle + } + }) + } +} + +for (const foreground of [false, true]) { + test(`cancelled reconciliation stays before ${foreground ? 'foreground' : 'async'} successor links after eviction`, async () => { + const agentId = asAgentId(`cancelled-link-${foreground}`) + let state = getDefaultAppState() + const setAppState = (update: (prev: AppState) => AppState) => { state = update(state) } + const params = { agentId, description: 'old', prompt: 'old', selectedAgent: { agentType: 'test' } as AgentDefinition, setAppState } + const old = registerAsyncAgent(params) + await old.outputReady + const pathA = getAgentTranscriptPath(agentId) + await recordSidechainTranscript([createUserMessage({ content: 'old A output' })], agentId) + state.tasks[agentId] = { ...old, outputDestination: pathA } + const entered = deferred() + const release = deferred() + const links: Promise[] = [] + mock.module('../../utils/task/diskOutput.js', () => ({ ...actualDiskOutput, initTaskOutputAsSymlink: (id: string, path: string) => { + const wait = path === pathA ? release.promise : Promise.resolve() + if (path === pathA) entered.resolve() + const link = wait.then(() => actualDiskOutput.initTaskOutputAsSymlink(id, path)) + links.push(link) + return link + } })) + const completionA = completeAgentTask({ agentId, content: [] } as never, setAppState).catch(error => error) + let completionB: Promise | undefined + try { + await entered.promise + killAsyncAgent(agentId, setAppState) + expect(await completionA).toBeInstanceOf(AbortError) + // Terminal records can be evicted before abandoned filesystem work settles. + setAppState(prev => { const { [agentId]: removed, ...tasks } = prev.tasks; return { ...prev, tasks } }) + await clearConversation({ setMessages: () => {}, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: () => state, setAppState }) + const register = foreground ? registerAgentForeground : registerAsyncAgent + register({ ...params, description: 'successor' }) + await recordSidechainTranscript([createUserMessage({ content: 'new B output' })], agentId) + completionB = completeAgentTask({ agentId, content: [] } as never, setAppState) + await new Promise(resolve => setTimeout(resolve, 20)) + const statusWhileAWasPending = state.tasks[agentId]?.status + release.resolve() + await completionB + await Promise.all(links) + expect(await readFile(getTaskOutputPath(agentId), 'utf8')).toContain('new B output') + expect(statusWhileAWasPending).toBe('running') + } finally { + release.resolve() + await completionA + await completionB + await Promise.all(links) + killAsyncAgent(agentId, setAppState) + } + }) +} + +for (const captureDestination of [true, false]) { + for (const swappedTarget of ['agent', 'project']) { + test(`retained transcript lookup rejects another ${swappedTarget}'s output link, captured=${captureDestination}`, async () => { + const agentId = asAgentId('retained-producer') + const pathA = getAgentTranscriptPath(agentId) + const metadata = { agentType: 'retained-agent', source: 'userSettings' } + await writeAgentMetadata(agentId, metadata) + await recordSidechainTranscript([createUserMessage({ content: 'recorded producer history' })], agentId, undefined, { captureDestination }) + let target: string + if (swappedTarget === 'agent') { + const otherId = asAgentId('other-producer') + target = getAgentTranscriptPath(otherId) + await writeAgentMetadata(otherId, metadata) + await recordSidechainTranscript([createUserMessage({ content: 'other producer history' })], otherId, undefined, { captureDestination }) + } else { + target = join(getProjectDir(join(configDir, 'other-project')), getSessionId(), 'subagents', `agent-${agentId}.jsonl`) + await mkdir(dirname(target), { recursive: true }) + // Matching agent content and filename cannot establish producer ownership. + await writeFile(target, await readFile(pathA, 'utf8')) + await writeFile(target.replace(/\.jsonl$/, '.meta.json'), JSON.stringify(metadata)) + } + switchSession(asSessionId(randomUUID())) + await resetSessionFilePointer() + await actualDiskOutput.initTaskOutputAsSymlink(agentId, pathA) + expect(JSON.stringify((await getAgentTranscript(agentId))?.messages)).toContain('recorded producer history') + expect(await readAgentMetadata(agentId)).toEqual(metadata) + await actualDiskOutput.initTaskOutputAsSymlink(agentId, target) + expect(await getAgentTranscript(agentId)).toBeNull() + expect(await readAgentMetadata(agentId)).toBeNull() + }) + } +} + +test('retained transcript lookup rejects output links outside generated agent storage', async () => { + const agentId = asAgentId('retained-path-guard') + const pathA = getAgentTranscriptPath(agentId) + await writeAgentMetadata(agentId, { agentType: 'retained-agent', source: 'userSettings' }) + await recordSidechainTranscript([createUserMessage({ content: 'retained history' })], agentId) + const transcript = await readFile(pathA, 'utf8') + switchSession(asSessionId(randomUUID())) + for (const target of [ + join(configDir, `agent-${agentId}.jsonl`), + join(dirname(pathA), 'agent-other.jsonl'), + join(getProjectDir(configDir), 'invalid-session', 'subagents', `agent-${agentId}.jsonl`), + ]) { + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, transcript) + await writeFile(target.replace(/\.jsonl$/, '.meta.json'), JSON.stringify({ agentType: 'retained-agent', source: 'userSettings' })) + await actualDiskOutput.initTaskOutputAsSymlink(agentId, target) + expect(await getAgentTranscript(agentId)).toBeNull() + expect(await readAgentMetadata(agentId)).toBeNull() + } +}) + +for (const continuesInB of [false, true]) { + test(`Ctrl+B final write spanning clear stays advertised, continued=${continuesInB}`, async () => { + let state = getDefaultAppState() + const setAppState = (update: (prev: AppState) => AppState) => { state = update(state) } + const entered = deferred() + const release = deferred() + const finish = deferred() + const settled = deferred() + let holdNextBranch = false + mock.module('../../utils/git.js', () => ({ ...actualGit, getBranch: async () => { + if (holdNextBranch) { holdNextBranch = false; entered.resolve(); await release.promise } + return 'test' + } })) + const handle = startBackgroundSession({ + description: 'main session clear output', setAppState, + prepare: async () => ({ messages: [createUserMessage({ content: 'main session initial' })], queryParams: {} as never }), + queryImpl: async function* () { + await flushSessionStorage() + holdNextBranch = true + yield createAssistantMessage({ content: 'main session final A answer' }) + await finish.promise + if (continuesInB) { + yield createAssistantMessage({ content: 'main session continued B answer' }) + await flushSessionStorage() + } + return { reason: 'completed' } + }, + onSettled: () => settled.resolve(), + }) + try { + await entered.promise + await clearConversation({ setMessages: () => {}, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: () => state, setAppState }) + await recordTranscript([createUserMessage({ content: 'new foreground B' })]) + release.resolve() + await flushSessionStorage() + finish.resolve() + await settled.promise + await actualDiskOutput._clearOutputsForTest() + expect(state.tasks[handle.taskId]?.status).toBe('completed') + expect(getPendingNotificationsSnapshot().map(item => item.value).join('\n')).toContain(getTaskOutputPath(handle.taskId)) + expect(await readFile(getTaskOutputPath(handle.taskId), 'utf8')).toContain(continuesInB ? 'main session continued B answer' : 'main session final A answer') + } finally { + release.resolve() + finish.resolve() + await settled.promise + } + }) +} + +for (const phase of ['initial', 'incremental'] as const) { + test(`fork cancellation escapes a blocked ${phase} transcript and cleans its context`, async () => { + const agentId = asAgentId(`fork-${phase}`) + mock.module('../../utils/uuid.js', () => ({ ...actualUuid, createAgentId: () => agentId })) + const path = getAgentTranscriptPath(agentId) + const release = deferred() + const locked = deferred() + let holder: Promise | undefined + const hold = () => withTranscriptFileLock(path, async () => { locked.resolve(); await release.promise }) + await mkdir(dirname(path), { recursive: true }) + if (phase === 'initial') { holder = hold(); await locked.promise } + mock.module('../../query.js', () => ({ ...actualQuery, query: async function* ({ toolUseContext }: { toolUseContext: ToolUseContext }) { + if (toolUseContext.abortController.signal.aborted) throw new AbortError() + holder = hold() + await locked.promise + yield createAssistantMessage({ content: 'fork answer' }) + } })) + const controller = new AbortController() + const context = { abortController: controller, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: getDefaultAppState, options: {}, messages: [] } as unknown as ToolUseContext + const clear = spyOn(FileStateCache.prototype, 'clear') + const fork = runForkedAgent({ promptMessages: [createUserMessage({ content: 'fork initial' })], cacheSafeParams: { toolUseContext: context, forkContextMessages: [], systemPrompt: [] as never, userContext: {}, systemContext: {} }, canUseTool: async () => ({ behavior: 'allow' }), querySource: 'compact', forkLabel: 'compact' }) + const outcome = fork.then(() => 'finished', error => error) + let deadline: ReturnType | undefined + try { + await locked.promise + await new Promise(resolve => setTimeout(resolve, 150)) + controller.abort() + const result = await Promise.race([outcome, new Promise(resolve => { deadline = setTimeout(() => resolve('still blocked'), 2_000) })]) + expect(result).toBeInstanceOf(AbortError) + expect(clear).toHaveBeenCalled() + } finally { + clearTimeout(deadline) + release.resolve() + await holder + await outcome + clear.mockRestore() + } + await flushSessionStorage() + const output = await readFile(path, 'utf8') + expect(output).toContain('fork initial') + if (phase === 'incremental') expect(output).toContain('fork answer') + }) +} + +} diff --git a/src/tools/AgentTool/runAgent.persistence.test.ts b/src/tools/AgentTool/runAgent.persistence.test.ts index 6f4d260f40..afa1fcae9d 100644 --- a/src/tools/AgentTool/runAgent.persistence.test.ts +++ b/src/tools/AgentTool/runAgent.persistence.test.ts @@ -2,8 +2,13 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import type { AgentDefinition } from './loadAgentsDir.js' import type { ToolUseContext } from '../../Tool.js' -const actualSessionStorage = { ...await import('../../utils/sessionStorage.js') } -const actualQuery = { ...await import('../../query.js') } +const nonce = `${Date.now()}-${Math.random()}` +const actualSessionStorage = { + ...await import(`../../utils/sessionStorage.js?real=${nonce}`), +} +const actualQuery = { + ...await import(`../../query.js?real=${nonce}`), +} // Track how many times each storage function is called let metadataWriteCount = 0 @@ -16,6 +21,8 @@ beforeEach(() => { afterEach(() => { mock.restore() + mock.module('../../utils/sessionStorage.js', () => actualSessionStorage) + mock.module('../../query.js', () => actualQuery) }) /** Load runAgent with a failing metadata writer while retaining unrelated module exports. */ diff --git a/src/tools/AgentTool/runAgent.routing.test.ts b/src/tools/AgentTool/runAgent.routing.test.ts index becea0b18a..499be515dc 100644 --- a/src/tools/AgentTool/runAgent.routing.test.ts +++ b/src/tools/AgentTool/runAgent.routing.test.ts @@ -44,9 +44,11 @@ describe('runAgent provider routing', () => { beforeEach(async () => { await acquireSharedMutationLock('tools/AgentTool/runAgent.routing.test.ts') resetSettingsCache() - actualSettingsModule ??= await import( - `../../utils/settings/settings.ts?runAgentRoutingSettingsActual=${Date.now()}-${Math.random()}` - ) + actualSettingsModule ??= { + ...await import( + `../../utils/settings/settings.ts?runAgentRoutingSettingsActual=${Date.now()}-${Math.random()}` + ), + } settingsForTest = {} mock.module('../../utils/settings/settings.js', () => ({ ...actualSettingsModule!, diff --git a/src/tools/AgentTool/runAgent.ts b/src/tools/AgentTool/runAgent.ts index 72bb87e868..24831ad1cb 100644 --- a/src/tools/AgentTool/runAgent.ts +++ b/src/tools/AgentTool/runAgent.ts @@ -45,6 +45,7 @@ import type { } from '../../types/message.js' import { createAttachmentMessage } from '../../utils/attachments.js' import { AbortError } from '../../utils/errors.js' +import { raceAbort } from '../../utils/boundedAsync.js' import { getDisplayPath } from '../../utils/file.js' import { cloneFileStateCache, @@ -65,6 +66,7 @@ import { resolveAgentRunModelRouting, shouldEnforceModelAllowlist } from '../../ import { getInitialSettings } from '../../utils/settings/settings.js' import { clearAgentTranscriptSubdir, + getAgentTranscriptPath, recordSidechainTranscript, setAgentTranscriptSubdir, writeAgentMetadata, @@ -850,21 +852,44 @@ export async function* runAgent({ logForDebugging(`Failed to write agent metadata: ${_err}`) } - // Record initial messages before the query loop starts. - // Fire-and-forget — persistence failure shouldn't block the agent. - // Only write the transcript if identity metadata was successfully persisted, - // ensuring we never leave a transcript that would resume without its restricted identity. - if (metadataWritten) { - void recordSidechainTranscript(initialMessages, agentId).catch(_err => - logForDebugging(`Failed to record sidechain transcript: ${_err}`), - ) - } else { - logForDebugging('Skipping initial transcript write because identity metadata persistence failed') + let transcriptDestination: string | undefined + agentToolUseContext.getAgentTranscriptPath = id => id === agentId ? transcriptDestination : undefined + + function recordAgentTranscript(messages: Message[], parentUuid?: UUID | null) { + const outputDestination = getAgentTranscriptPath(agentId) + transcriptDestination = outputDestination + // Storage captures this same path synchronously. Publish submission order, + // so an older delayed write cannot replace a newer session's destination. + const write = recordSidechainTranscript(messages, agentId, parentUuid) + rootSetAppState(prev => { + const task = prev.tasks?.[agentId] + if (task?.type !== 'local_agent' || task.outputDestination === outputDestination) return prev + return { ...prev, tasks: { ...prev.tasks, [agentId]: { ...task, outputDestination } } } + }) + return write } - // Track the last recorded message UUID for parent chain continuity + + // Track the last recorded message UUID for parent chain continuity. let lastRecordedUuid: UUID | null = initialMessages.at(-1)?.uuid ?? null try { + // Serialize the initial transcript with subsequent writes. The producer + // resolves after the local append, without flushing unrelated sessions. + // Preserve the identity-metadata gate and best-effort persistence policy. + if (metadataWritten) { + // Storage owns a cleaned message array and tracks this producer even + // when cancellation releases the runner before its append finishes. + await raceAbort( + recordAgentTranscript(initialMessages), + agentAbortController.signal, + ).catch(err => { + if (agentAbortController.signal.aborted) throw new AbortError() + logForDebugging(`Failed to record sidechain transcript: ${err}`) + }) + } else { + logForDebugging('Skipping initial transcript write because identity metadata persistence failed') + } + if (agentAbortController.signal.aborted) throw new AbortError() let queryTerminal: Terminal | undefined const configuredMaxSteps = Number.isSafeInteger(maxSteps) && maxSteps! > 0 @@ -929,13 +954,13 @@ export async function* runAgent({ // Record only the new message with correct parent (O(1) per message) // Only write if identity metadata was successfully persisted. if (metadataWritten) { - await recordSidechainTranscript( - [message], - agentId, - lastRecordedUuid, - ).catch(err => - logForDebugging(`Failed to record sidechain transcript: ${err}`), - ) + await raceAbort( + recordAgentTranscript([message], lastRecordedUuid), + agentAbortController.signal, + ).catch(err => { + if (agentAbortController.signal.aborted) throw new AbortError() + logForDebugging(`Failed to record sidechain transcript: ${err}`) + }) } if (message.type !== 'progress') { lastRecordedUuid = message.uuid diff --git a/src/tools/BashTool/BashTool.sandboxAnalysis.test.ts b/src/tools/BashTool/BashTool.sandboxAnalysis.test.ts index 7a9b4fff0f..ec8f2c44e0 100644 --- a/src/tools/BashTool/BashTool.sandboxAnalysis.test.ts +++ b/src/tools/BashTool/BashTool.sandboxAnalysis.test.ts @@ -1,14 +1,28 @@ -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' -import * as realGrowthbook from '../../services/analytics/growthbook.js' +import { afterAll, afterEach, beforeEach, expect, mock, test } from 'bun:test' import { getEmptyToolPermissionContext } from '../../Tool.js' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' -import * as realShell from '../../utils/Shell.js' import type { ExecResult, ShellCommand } from '../../utils/ShellCommand.js' import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +const nonce = `${Date.now()}-${Math.random()}` +const REAL_SHELL = { + ...await import(`../../utils/Shell.js?real=${nonce}`), +} +const REAL_GROWTHBOOK = { + ...await import(`../../services/analytics/growthbook.js?real=${nonce}`), +} + +afterAll(() => { + mock.restore() + mock.module('../../utils/Shell.js', () => REAL_SHELL) + mock.module('src/utils/Shell.js', () => REAL_SHELL) + mock.module('../../services/analytics/growthbook.js', () => REAL_GROWTHBOOK) + mock.module('src/services/analytics/growthbook.js', () => REAL_GROWTHBOOK) +}) + const originalSandboxMethods = { isSandboxingEnabled: SandboxManager.isSandboxingEnabled, areUnsandboxedCommandsAllowed: SandboxManager.areUnsandboxedCommandsAllowed, @@ -107,19 +121,19 @@ async function importBashToolWithExecutionMocks() { ) mock.module('../../utils/Shell.js', () => ({ - ...realShell, + ...REAL_SHELL, exec: execMock, })) mock.module('src/utils/Shell.js', () => ({ - ...realShell, + ...REAL_SHELL, exec: execMock, })) mock.module('../../services/analytics/growthbook.js', () => ({ - ...realGrowthbook, + ...REAL_GROWTHBOOK, getFeatureValue_CACHED_MAY_BE_STALE, })) mock.module('src/services/analytics/growthbook.js', () => ({ - ...realGrowthbook, + ...REAL_GROWTHBOOK, getFeatureValue_CACHED_MAY_BE_STALE, })) @@ -137,11 +151,11 @@ async function importSandboxPresentationWithMocks() { ) mock.module('../../services/analytics/growthbook.js', () => ({ - ...realGrowthbook, + ...REAL_GROWTHBOOK, getFeatureValue_CACHED_MAY_BE_STALE, })) mock.module('src/services/analytics/growthbook.js', () => ({ - ...realGrowthbook, + ...REAL_GROWTHBOOK, getFeatureValue_CACHED_MAY_BE_STALE, })) diff --git a/src/tools/BashTool/bashCommandAnalysis.test.ts b/src/tools/BashTool/bashCommandAnalysis.test.ts index c1e61efcc2..3e50a9f65a 100644 --- a/src/tools/BashTool/bashCommandAnalysis.test.ts +++ b/src/tools/BashTool/bashCommandAnalysis.test.ts @@ -5,9 +5,13 @@ import { } from '../../test/sharedMutationLock.js' import { parseForSecurityFromAst } from '../../utils/bash/ast.js' import { PARSE_ABORTED } from '../../utils/bash/parser.js' -import * as realDebug from '../../utils/debug.js' import { analyzeBashCommand } from './bashCommandAnalysis.js' +const nonce = `${Date.now()}-${Math.random()}` +const realDebug = { + ...await import(`../../utils/debug.js?real=${nonce}`), +} + let importCounter = 0 beforeEach(async () => { diff --git a/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.test.ts b/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.test.ts index 7db7c16508..c679cab1c3 100644 --- a/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.test.ts +++ b/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.test.ts @@ -35,8 +35,11 @@ beforeAll(async () => { 'tools/ExitPlanModeTool/ExitPlanModeV2Tool.test.ts', ) - actualPlans = await import('../../utils/plans.ts') - actualTeammateMailbox = await import('../../utils/teammateMailbox.ts') + const nonce = `${Date.now()}-${Math.random()}` + actualPlans = { ...await import(`../../utils/plans.js?real=${nonce}`) } + actualTeammateMailbox = { + ...await import(`../../utils/teammateMailbox.js?real=${nonce}`), + } mock.module('../../utils/plans.js', () => ({ ...actualPlans, diff --git a/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts b/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts index f956cb24a6..2f8e401514 100644 --- a/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts +++ b/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts @@ -76,7 +76,7 @@ export type AllowedPrompt = z.infer> const inputSchema = lazySchema(() => z - .strictObject({ + .looseObject({ // Prompt-based permissions requested by the plan allowedPrompts: z .array(allowedPromptSchema()) @@ -84,8 +84,7 @@ const inputSchema = lazySchema(() => .describe( 'Prompt-based permissions needed to implement the plan. These describe categories of actions rather than specific commands.', ), - }) - .passthrough(), + }), ) type InputSchema = ReturnType diff --git a/src/tools/LSPTool/LSPTool.ts b/src/tools/LSPTool/LSPTool.ts index 04c4ca7c64..21b835f12f 100644 --- a/src/tools/LSPTool/LSPTool.ts +++ b/src/tools/LSPTool/LSPTool.ts @@ -1,6 +1,4 @@ -import { open } from 'fs/promises' import * as path from 'path' -import { pathToFileURL } from 'url' import type { CallHierarchyIncomingCall, CallHierarchyItem, @@ -18,6 +16,16 @@ import { isLspConnected, waitForInitialization, } from '../../services/lsp/manager.js' +import { + isLspDocumentRevisionChanged, + isLspServerGenerationChanged, + type LSPServerManager, +} from '../../services/lsp/LSPServerManager.js' +import { + getLspDocumentIdentity, + LspDocumentTooLargeError, + readLspDocumentContents, +} from '../../services/lsp/documentIdentity.js' import type { ValidationResult } from '../../Tool.js' import { buildTool, type ToolDef } from '../../Tool.js' import { uniq } from '../../utils/array.js' @@ -50,8 +58,6 @@ import { userFacingName, } from './UI.js' -const MAX_LSP_FILE_SIZE_BYTES = 10_000_000 - /** * Tool-compatible input schema (regular ZodObject instead of discriminated union) * We validate against the discriminated union in validateInput for better error messages @@ -124,6 +130,74 @@ type OutputSchema = ReturnType export type Output = z.infer export type Input = z.infer +type CallHierarchyFlowResult = + | { kind: 'result'; result: unknown } + | { kind: 'output'; output: Output } + +async function runCallHierarchyFlow( + manager: LSPServerManager, + input: Input, + absolutePath: string, + method: string, + params: unknown, +): Promise { + const callMethod = + input.operation === 'incomingCalls' + ? 'callHierarchy/incomingCalls' + : 'callHierarchy/outgoingCalls' + + for (let generationRetry = 0; generationRetry <= 1; generationRetry++) { + const prepared = await manager.sendRequestWithGeneration< + CallHierarchyItem[] + >(absolutePath, method, params) + if (!prepared) return { kind: 'result', result: undefined } + + const callItems = prepared.result + if (!callItems || callItems.length === 0) { + return { + kind: 'output', + output: { + operation: input.operation, + result: 'No call hierarchy item found at this position', + filePath: input.filePath, + resultCount: 0, + fileCount: 0, + }, + } + } + + try { + const calls = await manager.sendRequestWithGeneration( + absolutePath, + callMethod, + { item: callItems[0] }, + { + expectedGeneration: prepared.serverGeneration, + expectedDocumentRevision: prepared.documentRevision, + expectedDocumentCloseEpoch: prepared.documentCloseEpoch, + }, + ) + if (!calls) { + logForDebugging( + `LSP server returned undefined for ${callMethod} on ${input.filePath}`, + ) + } + return { kind: 'result', result: calls?.result ?? null } + } catch (error) { + if ( + generationRetry === 0 && + (isLspServerGenerationChanged(error) || + isLspDocumentRevisionChanged(error)) + ) { + continue + } + throw error + } + } + + return { kind: 'result', result: undefined } +} + export const LSPTool = buildTool({ name: LSP_TOOL_NAME, searchHint: 'code intelligence (definitions, references, symbols, hover)', @@ -259,26 +333,32 @@ export const LSPTool = buildTool({ // Most LSP servers require textDocument/didOpen before operations // Only read the file if it's not already open to avoid unnecessary I/O if (!manager.isFileOpen(absolutePath)) { - const handle = await open(absolutePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_LSP_FILE_SIZE_BYTES) { - const output: Output = { - operation: input.operation, - result: `File too large for LSP analysis (${Math.ceil(stats.size / 1_000_000)}MB exceeds 10MB limit)`, - filePath: input.filePath, - } - return { data: output } - } - const fileContent = await handle.readFile({ encoding: 'utf-8' }) - await manager.openFile(absolutePath, fileContent) - } finally { - await handle.close() - } + const fileContent = await readLspDocumentContents(absolutePath) + await manager.openFile(absolutePath, fileContent) } - // Send request to LSP server - let result = await manager.sendRequest(absolutePath, method, params) + let result: unknown + + // Call hierarchy items may contain opaque, process-owned data. Keep the + // prepare result and its follow-up request on one server generation. + if ( + input.operation === 'incomingCalls' || + input.operation === 'outgoingCalls' + ) { + const callHierarchy = await runCallHierarchyFlow( + manager, + input, + absolutePath, + method, + params, + ) + if (callHierarchy.kind === 'output') { + return { data: callHierarchy.output } + } + result = callHierarchy.result + } else { + result = await manager.sendRequest(absolutePath, method, params) + } if (result === undefined) { // Log for diagnostic purposes - helps track usage patterns and potential bugs @@ -296,43 +376,6 @@ export const LSPTool = buildTool({ } } - // For incomingCalls and outgoingCalls, we need a two-step process: - // 1. First get CallHierarchyItem(s) from prepareCallHierarchy - // 2. Then request the actual calls using that item - if ( - input.operation === 'incomingCalls' || - input.operation === 'outgoingCalls' - ) { - const callItems = result as CallHierarchyItem[] - if (!callItems || callItems.length === 0) { - const output: Output = { - operation: input.operation, - result: 'No call hierarchy item found at this position', - filePath: input.filePath, - resultCount: 0, - fileCount: 0, - } - return { data: output } - } - - // Use the first call hierarchy item to request calls - const callMethod = - input.operation === 'incomingCalls' - ? 'callHierarchy/incomingCalls' - : 'callHierarchy/outgoingCalls' - - result = await manager.sendRequest(absolutePath, callMethod, { - item: callItems[0], - }) - - if (result === undefined) { - logForDebugging( - `LSP server returned undefined for ${callMethod} on ${input.filePath}`, - ) - // Continue to formatter which will handle empty/null gracefully - } - } - // Filter out gitignored files from location-based results if ( result && @@ -395,6 +438,16 @@ export const LSPTool = buildTool({ const err = toError(error) const errorMessage = err.message + if (err instanceof LspDocumentTooLargeError) { + return { + data: { + operation: input.operation, + result: errorMessage, + filePath: input.filePath, + }, + } + } + // Log error for tracking logError( new Error( @@ -428,7 +481,7 @@ function getMethodAndParams( input: Input, absolutePath: string, ): { method: string; params: unknown } { - const uri = pathToFileURL(absolutePath).href + const uri = getLspDocumentIdentity(absolutePath).fileUri // Convert from 1-based (user-friendly) to 0-based (LSP protocol) const position = { line: input.line - 1, diff --git a/src/tools/MCPTool/MCPTool.ts b/src/tools/MCPTool/MCPTool.ts index 0d26d7fb2a..04a2b790d0 100644 --- a/src/tools/MCPTool/MCPTool.ts +++ b/src/tools/MCPTool/MCPTool.ts @@ -15,7 +15,7 @@ import { } from './UI.js' // Allow any input object since MCP tools define their own schemas -export const inputSchema = lazySchema(() => z.object({}).passthrough()) +export const inputSchema = lazySchema(() => z.looseObject({})) type InputSchema = ReturnType // MCP tools can return either a plain string or an array of content blocks diff --git a/src/tools/SendMessageTool/SendMessageTool.ts b/src/tools/SendMessageTool/SendMessageTool.ts index a7e963511e..b949e9427d 100644 --- a/src/tools/SendMessageTool/SendMessageTool.ts +++ b/src/tools/SendMessageTool/SendMessageTool.ts @@ -812,11 +812,18 @@ export const SendMessageTool: Tool = const task = appState.tasks[agentId] if (isLocalAgentTask(task) && !isMainSessionTask(task)) { if (task.status === 'running') { - queuePendingMessage( + if (!queuePendingMessage( agentId, input.message, context.setAppStateForTasks ?? context.setAppState, - ) + )) { + return { + data: { + success: false, + message: `${input.to} is finishing its output. Please retry after it completes.`, + }, + } + } return { data: { success: true, @@ -831,11 +838,18 @@ export const SendMessageTool: Tool = // the task reference (the first resume changes status to 'running'). const freshTask = context.getAppState().tasks[agentId] if (isLocalAgentTask(freshTask) && freshTask.status === 'running') { - queuePendingMessage( + if (!queuePendingMessage( agentId, input.message, context.setAppStateForTasks ?? context.setAppState, - ) + )) { + return { + data: { + success: false, + message: `${input.to} is finishing its output. Please retry after it completes.`, + }, + } + } return { data: { success: true, diff --git a/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts b/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts index f687946557..78fc6e873d 100644 --- a/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts +++ b/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts @@ -8,7 +8,7 @@ import type { PermissionResult } from '../../utils/permissions/PermissionResult. import { jsonStringify } from '../../utils/slowOperations.js' // Allow any input object since the schema is provided dynamically -const inputSchema = lazySchema(() => z.object({}).passthrough()) +const inputSchema = lazySchema(() => z.looseObject({})) type InputSchema = ReturnType const outputSchema = lazySchema(() => diff --git a/src/tools/WebFetchTool/WebFetchTool.ts b/src/tools/WebFetchTool/WebFetchTool.ts index 3daf05152e..86b1e7956d 100644 --- a/src/tools/WebFetchTool/WebFetchTool.ts +++ b/src/tools/WebFetchTool/WebFetchTool.ts @@ -43,7 +43,7 @@ async function scrapeWithFirecrawl( const inputSchema = lazySchema(() => z.strictObject({ - url: z.string().url().describe('The URL to fetch content from'), + url: z.url().describe('The URL to fetch content from'), prompt: z.string().describe('The prompt to run on the fetched content'), }), ) diff --git a/src/upstreamproxy/relay.ts b/src/upstreamproxy/relay.ts index cd17f73291..837f06a1ae 100644 --- a/src/upstreamproxy/relay.ts +++ b/src/upstreamproxy/relay.ts @@ -63,7 +63,7 @@ const PING_INTERVAL_MS = 30_000 * protobufjs would be the general answer; for a single-field bytes message * the hand encoding is 10 lines and avoids a runtime dep in the hot path. */ -export function encodeChunk(data: Uint8Array): Uint8Array { +export function encodeChunk(data: Uint8Array): Uint8Array { const len = data.length // varint encoding of length — most chunks fit in 1–3 length bytes const varint: number[] = [] diff --git a/src/utils/ShellCommand.test.ts b/src/utils/ShellCommand.test.ts index 03f5e87f87..306327bef0 100644 --- a/src/utils/ShellCommand.test.ts +++ b/src/utils/ShellCommand.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events' +import { spawn } from 'child_process' import { expect, test } from 'bun:test' import { wrapSpawn } from './ShellCommand.js' import { TaskOutput } from './task/TaskOutput.js' @@ -123,3 +124,42 @@ test('interrupt does not kill keep-alive commands used by asyncRewake hooks', as expect(result.code).toBe(2) expect(result.interrupted).toBe(false) }) + +test('already-aborted user-cancel kills a real child promptly', async () => { + const controller = new AbortController() + controller.abort('user-cancel') + + const startedAt = performance.now() + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }) + const childClosed = new Promise(resolve => { + child.once('close', () => resolve()) + }) + const command = wrapSpawn( + child, + controller.signal, + 1_000, + new TaskOutput('shellcommand-test-already-aborted', null), + ) + + try { + expect(child.pid).toBeDefined() + + const result = await command.result + const elapsedMs = performance.now() - startedAt + await childClosed + + expect(elapsedMs).toBeLessThan(2_000) + expect(result.isAbort).toBe(true) + expect(result.abortReason).toBe('user-abort') + expect(result.interrupted).toBe(true) + expect(result.signalAborted).toBe(true) + expect(command.status).toBe('killed') + } finally { + child.kill('SIGKILL') + await command.result + await childClosed + command.cleanup() + } +}) diff --git a/src/utils/ShellCommand.ts b/src/utils/ShellCommand.ts index 208226b396..96158db943 100644 --- a/src/utils/ShellCommand.ts +++ b/src/utils/ShellCommand.ts @@ -303,6 +303,9 @@ class ShellCommandImpl implements ShellCommand { this.#abortSignal.addEventListener('abort', this.#boundAbortHandler, { once: true, }) + if (this.#abortSignal.aborted) { + this.#boundAbortHandler() + } // Use 'exit' not 'close': 'close' waits for stdio to close, which includes // grandchild processes that inherit file descriptors (e.g. `sleep 30 &`). diff --git a/src/utils/__snapshots__/zodToJsonSchema.test.ts.snap b/src/utils/__snapshots__/zodToJsonSchema.test.ts.snap new file mode 100644 index 0000000000..1e461fa536 --- /dev/null +++ b/src/utils/__snapshots__/zodToJsonSchema.test.ts.snap @@ -0,0 +1,320 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`schema compatibility preserves generated and advertised JSON schemas for every exported input and output 1`] = ` +[ + { + "advertised": "77cb6c6df24c59271386c9cdb7b195bf8576341937db2ea30547b91cb2d133be", + "id": "e820cd467e09d6441c73c7f9015c491a791c296011a0346d3e1203ba9128dc1b", + "input": "77cb6c6df24c59271386c9cdb7b195bf8576341937db2ea30547b91cb2d133be", + "output": "429a77dc977db873ada3590226fb0a714635c120cad07272ea5a814de8217a5f", + }, + { + "advertised": "88c3daf70e465d9d0aa661d4c477cf74df4e5a66bde465cd260781008fb8b7bb", + "id": "7d6736a24c16fa68771624a7f0fbfa904444ab163be9f013b0b8925641cd5e2e", + "input": "88c3daf70e465d9d0aa661d4c477cf74df4e5a66bde465cd260781008fb8b7bb", + "output": "34ea57afac8716bcced861601882bc9f8996371108a58d8cb13bb13cd5dbaf4f", + }, + { + "advertised": "8cb4c4c027f884e4d94e7c9756c32d57593634485e192863bf4f3dd78e358b55", + "id": "bfd20582e06fe2d19a8f7050b45f7da4ae2839fcf0bb6eea0a7c81f8f2a11fc5", + "input": "8cb4c4c027f884e4d94e7c9756c32d57593634485e192863bf4f3dd78e358b55", + "output": "c2aa1a4538272e346a8d3626afd15a2d4129ab231d839314625d87b0529ef3df", + }, + { + "advertised": "d2d282ffcf734a07a94677047f55c6ec5aac60a30f9cfccb033dd64b350e75a5", + "id": "bf40d691734596f7f62ee20a339344e0d2e9384335f5681e5195dd7dd6032ede", + "input": "d2d282ffcf734a07a94677047f55c6ec5aac60a30f9cfccb033dd64b350e75a5", + "output": "538df22ac99f76031c2412bab4b2cf634ff2efdc8b91dbf24f99ad0acd7bdef0", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "83d330fdbd8b299dc77fce2e982a2bf2fc6d9df93fed6439926ffc57fa48d5b7", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "b70fee5c304dbdfe136c17c8882d3bb1dc22da1acde039b2964741442a7b42e1", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": "a3d61c43e17761158ad9a4fd03fd0e500e85d55d23b07674853db2ea712c1ea5", + }, + { + "advertised": "f5b65ab7eb376c7a73506ba7a02406f0ca3a0f8ff76356aa9e0355770260ab69", + "id": "face872522c2c9910d38fb9490fc7cc6a41b4dd7ab0af1dae5d4e0b42fea29b8", + "input": "f5b65ab7eb376c7a73506ba7a02406f0ca3a0f8ff76356aa9e0355770260ab69", + "output": "4fa19d4e4123e3987fe1cb9bd61014df2e6a0801dd78430daa66cd2d63194847", + }, + { + "advertised": "1cf52979f184328f1c84682d9dfca4db649f40f8716772de7c988c05b8dcfd1e", + "id": "5320f71c5cdd4a2dec84800720b773ba3d62ab47c9f992299209e89995eb83f5", + "input": "1cf52979f184328f1c84682d9dfca4db649f40f8716772de7c988c05b8dcfd1e", + "output": "08e4aaac64ced96a1c5cf9733faa24c1e1d8151b0ee51933000b5ef7d6d324e2", + }, + { + "advertised": "f4c0cd1a6fc0f75d4dd2ce6f0cbf6836d48f8ab16660816c05515ac08d3c8b42", + "id": "8841d846ea3240c83355501006136736372dfc69ddef27cb238660010cd955a8", + "input": "f4c0cd1a6fc0f75d4dd2ce6f0cbf6836d48f8ab16660816c05515ac08d3c8b42", + "output": "fe4fec8544de6cc2f9676885c146f049dcedbd12b0ad714f3aa89236997b9650", + }, + { + "advertised": "73c20da9a0cd63a1eb6f21ac29e3c9124ecd39c8a10076d94b4eb29c3a5018f0", + "id": "fe427124fd2b513a96bef2c48f1db0547b4fc2be6f1eb37a39c360304bdbadd3", + "input": "73c20da9a0cd63a1eb6f21ac29e3c9124ecd39c8a10076d94b4eb29c3a5018f0", + "output": "f0472e096d47094ac159aba319b8463a2bac616707d6562fefb8c52bc150d719", + }, + { + "advertised": "384be111a755f235f20b1c632d0ed1a665e9f16d191f02108e80232c7fc4b488", + "id": "9a2998a581c2edf8caa1013469b66074fca7177181dd360b706462aee1d22321", + "input": "384be111a755f235f20b1c632d0ed1a665e9f16d191f02108e80232c7fc4b488", + "output": "b38e37bd6dcff1b2affe78817fe5dd356735531ab1c17943aa9618ab239269d4", + }, + { + "advertised": "32dd0537e15f621781fa35bd4db0fce13449e8171eb17b97246a7ed13e6e5038", + "id": "d113dee074c6d58039be9d05735b6ec9df209d24a3d72761221575c66c2e6f7f", + "input": "32dd0537e15f621781fa35bd4db0fce13449e8171eb17b97246a7ed13e6e5038", + "output": "c1288e0d1217c09b910d1622e4afe03906d438f09f469899438c643cc502a004", + }, + { + "advertised": "959396f9008ff9960fe843d9a516c0a5be31c0d6dfb25552c23c5fc203b1b9b6", + "id": "dadf27af7db20323f220b802038a61b2dd371f89d13ed6295fe2e666860df6fe", + "input": "959396f9008ff9960fe843d9a516c0a5be31c0d6dfb25552c23c5fc203b1b9b6", + "output": "0ea0ad25a0a042b1b51bed4f80a651339c8040513ad34ef137b2a1396a359ff4", + }, + { + "advertised": "bda3f7833f5e09fd9f14c0ebb6b466396ecb0a95839343619bbc7daf05416022", + "id": "71172a6b4174685a2c976e1af8e5d53e9a9549803ee0d4ef8d93d9d53df62b52", + "input": "bda3f7833f5e09fd9f14c0ebb6b466396ecb0a95839343619bbc7daf05416022", + "output": "b57a7e2d220fb3f4374acd18f4472a2d8b4b1339982e5c5b3829165248c2588e", + }, + { + "advertised": "4752208900ad2b325b619268e2a2ed6f54b624fb82cedf418312ad5f5c20bb9e", + "id": "335f8386c1541cc80455c046cf696aa8d18be234fa6f4cc8f3ac485af44a90ec", + "input": "4752208900ad2b325b619268e2a2ed6f54b624fb82cedf418312ad5f5c20bb9e", + "output": "0438983e0b3b594b538bcd1fb579777a3e762e5d02558c226b4aeadb42afc2bd", + }, + { + "advertised": "e2b47278f25fd719ba266b7717c1e6bc8750ce85ae25498f2801150770292fc8", + "id": "b3c0caa2accc1faaaf00dbdded50ff5dcc5fdba307c0a9ac53ead64eb3ce3611", + "input": "e2b47278f25fd719ba266b7717c1e6bc8750ce85ae25498f2801150770292fc8", + "output": "b9ceee09d8fe9e41ee3c83d7caae3bd57817cd57878c8198874ca2f8508e183d", + }, + { + "advertised": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "id": "8d2bfcc82de8581913683c3f1be0ddd062897f2a432f2dfc6797c92f1ed77d60", + "input": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "output": "e6c8312ea9d2622a776ccc2f891c8d844dad76839c9be627bb753135cf2ebf6f", + }, + { + "advertised": "df250eb67ec8e567aa29535c5e53da579c592a553355cc0adb63a9782f939b83", + "id": "49d6ee37a6f7656a32975edbf5c1db6e1fd9de40a62e80d45ec7871d02ed80df", + "input": "df250eb67ec8e567aa29535c5e53da579c592a553355cc0adb63a9782f939b83", + "output": "d5cca1f184a8cd748ba8012ac6f44a484ebd2050bcd138956bc912e9cb6c136a", + }, + { + "advertised": "353e011f2b233942c1e4f959ad0dea06510198acfbdcb7d15a6b6067ad4b8094", + "id": "6fca54b7d9c67db2781977d951874fb02e3b49e5e5496b27b4ce2bdb9d1b64a8", + "input": "353e011f2b233942c1e4f959ad0dea06510198acfbdcb7d15a6b6067ad4b8094", + "output": "1e680f4adf6d8f38f9769764328e3cace27113e6259b9290464bc29c8c463c5d", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "6e03df47c8f45fa4cb9e1eed9c589909e9524047d6cccef2f4cf1c7480ee7a2b", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "09206d3aeff46e21148fc6b0472c77b240480d3f0ab19ecbe1bffa3e5a048ed2", + "id": "c6277ab141dc080d2b4f391c3e93ffe709b919fad3d2a6f86817db86dca3a8cd", + "input": "09206d3aeff46e21148fc6b0472c77b240480d3f0ab19ecbe1bffa3e5a048ed2", + "output": "6f5c83fd72140fe13917468022b340f565a5b163ecd1005798935d62aeff3107", + }, + { + "advertised": "1fc3ed2694b6385febea1cfefefbbf81d186cd3d3e6349bddc5704a7ada032b2", + "id": "c2f88f01ee5dcea9c4b9bd6d31af76b59fc9cfb8e04462468624f39b366bfa6d", + "input": "1fc3ed2694b6385febea1cfefefbbf81d186cd3d3e6349bddc5704a7ada032b2", + "output": "2b3370a0cc9e186a394903f49790a92303d4212ec1bc70d73095d6bfc35e0b67", + }, + { + "advertised": "9e8261cfa302abc3bccac5d24e222abc59a82111addfeae97a3395003af671df", + "id": "a9ca4741547cfce1552acd0dc7b89ea8902e7b755d56b415c36ac99382c00096", + "input": "9e8261cfa302abc3bccac5d24e222abc59a82111addfeae97a3395003af671df", + "output": "ae33ad38219c68eb5fb6c657b1379df28f68f53f7d643c46dc745b404c246ca7", + }, + { + "advertised": "995fa712490de724671fcf782e72486c21c3b569cf68b6441e85fd79dda304e3", + "id": "ad68fb8682719715dfd11fe84e425283304037ef022295cd4aa5a459a49e1f55", + "input": "995fa712490de724671fcf782e72486c21c3b569cf68b6441e85fd79dda304e3", + "output": "3728014d56349be8bb3366dadd2803f893d9a9018e7af4378ac32e6443c565e0", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "4b55f14dd751df72e841281ec6533f233b0e45f9ff602bb6c8ce76095056d749", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "60b9691b11b33bf093753c77eb35564f59c81712a6ae37803f2e09d6fe35bc10", + "id": "be341dfb1dc93e01ec0ccb3199fbb6b840ff8487aa7c8db01775120b5004bd30", + "input": "60b9691b11b33bf093753c77eb35564f59c81712a6ae37803f2e09d6fe35bc10", + "output": "3f9a882802dbd0821b08d3a9f52d7b8aa98e76edb5ef4d9649d7cbbf81815666", + }, + { + "advertised": "865230a52e30149c5ebe16edda290f13f0ce72842bd49a8bef5afafd0e1199e3", + "id": "63599064a971606946e438380d225817818c540afc28b50d14320ea4a04f925e", + "input": "865230a52e30149c5ebe16edda290f13f0ce72842bd49a8bef5afafd0e1199e3", + "output": "85d708fc6f8d200041c3fae9d193483067fc559850e1977bd6ff3f690f8fe2cd", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "7ab63dc4573b61911ae3b4932f2813d88121bea34a9ddacbed3b09eff162b9be", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": "bdc48d794483ba79b5a9202367a09c22a96628eabd48eb6261d999c51ee8d652", + }, + { + "advertised": "62d9e8e53e4c49b332448b47da537b84b942550e3b345c6e2078f7612fb8b28d", + "id": "7295becc13cd3cf96335c842f889b7d4b65acebaae2816637c6a5c0693e9d527", + "input": "62d9e8e53e4c49b332448b47da537b84b942550e3b345c6e2078f7612fb8b28d", + "output": null, + }, + { + "advertised": "c0754a94ca562253bc700026f2f23a9577a483e59139a94c036dfcc925998a59", + "id": "8f745fae6deccf4c5323fd7c8556d7d1ba4d846ae0da6bc2ed231df7ddebef44", + "input": "c0754a94ca562253bc700026f2f23a9577a483e59139a94c036dfcc925998a59", + "output": "9392a64293a631e738702c9facb92c636056bb50ee72f27d06046bd8019a11ea", + }, + { + "advertised": "1ea46b7b91a096f0ebd76111c3337238bcb0b2d57d3dd23d8e4e2ea09f77a4bc", + "id": "6820d861ad3d1dd451aebf66a4e7a35b32205e6787fe7a1aa95e40381d41e25c", + "input": "1ea46b7b91a096f0ebd76111c3337238bcb0b2d57d3dd23d8e4e2ea09f77a4bc", + "output": null, + }, + { + "advertised": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "id": "246794557465dc048346b3baa603db7001956f67b0160c9838b3135d25675a28", + "input": "8d7d5e23b454edcf308cf2d220347c39de4360c4d4a55c58f355d0009ad01da3", + "output": "189ef3f99d87aeb26b60950fcb672c021af8a6c204781259eb28e15537d9994e", + }, + { + "advertised": "0ee69f567a9240a3aed35f267eb0219cf974839480944af4db84fdea0d3e11b1", + "id": "dde972b9cf8648eadf82e00ed1f0a380146436042e72d9192b8fbfa09c6356ea", + "input": "0ee69f567a9240a3aed35f267eb0219cf974839480944af4db84fdea0d3e11b1", + "output": "2ea86994447c0b6ba1e17ef83fd630a26469e000af367d98d6ffcb25ae39ae3c", + }, + { + "advertised": "83868e55a4a803551f011494c8bc021113742db57989503d4391fa0dd9ec057a", + "id": "301bcb286d6a83bc86895c5f86ead159f682f332666ce5e2db977b31ca1f7eed", + "input": "83868e55a4a803551f011494c8bc021113742db57989503d4391fa0dd9ec057a", + "output": "0ac7d77d806635ebd074675db1edf0b2a43cf0844861b306a48515126219994d", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "9b3d29a242f9840335a5b9ddc6eca0f2a202e67a85d80ca5da060c2f6869ca28", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": "91d8f23df7f0991623f783fcf90af1d1120092547eefecdb4f3b604a49be655e", + }, + { + "advertised": "891cde9c7057d6e54554116564655318c862ae8b7019466aa00aab28e56fe4da", + "id": "87950de3ec70fd966719053c66dcf4f74f25609f6a320ce8adbdb93b76d176f9", + "input": "891cde9c7057d6e54554116564655318c862ae8b7019466aa00aab28e56fe4da", + "output": null, + }, + { + "advertised": "9f76ff6acc16e7f316c68606df009097b011cec429eb170fb41adbc0f812b926", + "id": "2bfaeee71433859cfd6e4516ed4e064f33cfc558dd831c1ca170baa2e789d8f1", + "input": "9f76ff6acc16e7f316c68606df009097b011cec429eb170fb41adbc0f812b926", + "output": "7b98afccc859185afc6c77e78da460ea0da1356a66eeb9a307ecccd5f05bd61d", + }, + { + "advertised": "518f1409411eb7ac540c47626e72521092c0d61874d590d3e85e39c2145207ac", + "id": "4fdcb1d2bac5cde4c017ced61657ca6b9fb7b91c9ff59a27eb66f580d3848fc1", + "input": "518f1409411eb7ac540c47626e72521092c0d61874d590d3e85e39c2145207ac", + "output": "cf865014979f3277899dc6cb835737242698c9cb2c537185cd41d352f649675b", + }, + { + "advertised": "09f93a3aa0d8f4e1b81d74044a8aa554b2e4e47aeec12247e12e5e7426848e14", + "id": "d03e96478c61a5b67db9148f0bd5cb475d4047cf2732df3c0f7e6c602b1d6006", + "input": "09f93a3aa0d8f4e1b81d74044a8aa554b2e4e47aeec12247e12e5e7426848e14", + "output": null, + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "e961d9d2e8822ff47ea84e570a839f0797358177e51312afcb4ef380f12d6a91", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "f29f42721d4d46a4ce50572a8fef3f8a3142fc0fcf544459d1804966424fed99", + "id": "79a0a14e693704b32c14d60fde371850ca489c5d29fa887e041645effcc0d507", + "input": "f29f42721d4d46a4ce50572a8fef3f8a3142fc0fcf544459d1804966424fed99", + "output": "ec82e6db1f5b340724db18cf269de5ea1325d044d68f054c732c96fa3e532c50", + }, + { + "advertised": "5280abc39104a88d80ece779109a0c1b717c377c291b0df51f72f8406e6a687f", + "id": "8fbb0e7c76d52de420eca0be72143727622d7575bfd404832f5bb86e82583579", + "input": "5280abc39104a88d80ece779109a0c1b717c377c291b0df51f72f8406e6a687f", + "output": "c8a9da99d15b311fa999cdbbbeca21f39693b3bd22d242e126914e9d21ed1660", + }, + { + "advertised": "29188b18ff3e3cc1a8326091768850706c9b5aa545717facb774eae4f6d7ed85", + "id": "28ac27c6b2b47389ee7879655bea10a97cd950b8bb23de458497cdc989c54df5", + "input": "29188b18ff3e3cc1a8326091768850706c9b5aa545717facb774eae4f6d7ed85", + "output": "dfab3d9383b6902c737345efee32272cff801c7f259245285b461cc5ad67ec0b", + }, + { + "advertised": "ce035ada7312377e4219f1f4d74d325e1db5b9e3b9c4bf9b8e46e7e119b7cbbb", + "id": "b11dc8a983e218c22cb4c1a3d26e1771b027ef2da5ded3273dcc3ba481e8efb6", + "input": "ce035ada7312377e4219f1f4d74d325e1db5b9e3b9c4bf9b8e46e7e119b7cbbb", + "output": "c2384b75e26d17cdd6625de8ec2d3b507a77786f7fd59e0b5fd7ebfad8e9635d", + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "23063c391ff1101398b617433ce3225220200e2144b2dc988c7269ab51bd6379", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, + { + "advertised": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "id": "839ffbedaf5ba514f5176b8e7d169047e3280c43ea829306724a3461c47b99be", + "input": "71fc596a20f27da3dec80050e5d1f553eba34aea24c2853b853488a811e6b892", + "output": null, + }, +] +`; + +exports[`schema compatibility preserves defaults, optional and nullable fields, records and unknown-key policy 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "count": { + "default": 3, + "type": "number", + }, + "enabled": { + "type": "boolean", + }, + "entries": { + "additionalProperties": {}, + "propertyNames": { + "type": "string", + }, + "type": "object", + }, + "note": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "null", + }, + ], + }, + }, + "required": [ + "count", + "entries", + ], + "type": "object", +} +`; diff --git a/src/utils/apiPreconnect.test.ts b/src/utils/apiPreconnect.test.ts index 0666f5a860..021a1aa25c 100644 --- a/src/utils/apiPreconnect.test.ts +++ b/src/utils/apiPreconnect.test.ts @@ -1,10 +1,22 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' import { asMockFetch } from '../test/typedMocks.js' +const providerSpecifier = './model/providers.js' +const realProvidersForCleanup = { + ...await import( + `${providerSpecifier}?real=${Date.now()}-${Math.random()}` + ), +} + +afterAll(() => { + mock.restore() + mock.module(providerSpecifier, () => realProvidersForCleanup) +}) + const originalEnv = { ...process.env } const originalFetch = globalThis.fetch diff --git a/src/utils/apiPreconnect.ts b/src/utils/apiPreconnect.ts index abe80dbaa0..d829c1002b 100644 --- a/src/utils/apiPreconnect.ts +++ b/src/utils/apiPreconnect.ts @@ -49,6 +49,7 @@ export function preconnectAnthropicApi( if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ) { return diff --git a/src/utils/attachments.lspDiagnostics.test.ts b/src/utils/attachments.lspDiagnostics.test.ts index e7985e1e16..255a0aec03 100644 --- a/src/utils/attachments.lspDiagnostics.test.ts +++ b/src/utils/attachments.lspDiagnostics.test.ts @@ -1,16 +1,22 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { snapshotEnv } from '../test/envSnapshot.js' import type { ToolUseContext, Tools } from '../Tool.js' import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' import type { DiagnosticFile } from '../services/diagnosticTracking.js' import type { Attachment } from './attachments.js' const debugMessages: string[] = [] -const realDebugModule = await import( - `./debug.js?real=${Date.now()}-${Math.random()}`, -) -const realLSPRegistry = await import( - `../services/lsp/LSPDiagnosticRegistry.js?real=${Date.now()}-${Math.random()}`, -) +const realDebugModule = { + ...await import(`./debug.js?real=${Date.now()}-${Math.random()}`), +} +const realLSPRegistry = { + ...await import(`../services/lsp/LSPDiagnosticRegistry.js?real=${Date.now()}-${Math.random()}`), +} + +afterAll(() => { + mock.module('./debug.js', () => realDebugModule) + mock.module('../services/lsp/LSPDiagnosticRegistry.js', () => realLSPRegistry) +}) let diagnosticSets: Array<{ serverName: string; files: DiagnosticFile[] }> = [] let nextDeliveryDelay: number | null = null @@ -48,8 +54,7 @@ const { getAttachmentMessages, __test } = await import( `./attachments.ts?test=${Date.now()}-${Math.random()}` ) -const SAVED_SIMPLE = process.env.CLAUDE_CODE_SIMPLE -const SAVED_DISABLE_ATTACHMENTS = process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS +let restoreEnv: () => void type DiagnosticsAttachment = Extract @@ -137,8 +142,10 @@ async function collectLSPDiagnosticAttachments(): Promise< describe('LSP diagnostic attachment filtering', () => { beforeEach(() => { - delete process.env.CLAUDE_CODE_SIMPLE - delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + restoreEnv = snapshotEnv( + ['CLAUDE_CODE_SIMPLE', 'CLAUDE_CODE_DISABLE_ATTACHMENTS'], + { clear: true }, + ) diagnosticSets = [] nextDeliveryDelay = null checkForLSPDiagnosticsOptions.length = 0 @@ -150,16 +157,7 @@ describe('LSP diagnostic attachment filtering', () => { }) afterEach(() => { - if (SAVED_SIMPLE === undefined) { - delete process.env.CLAUDE_CODE_SIMPLE - } else { - process.env.CLAUDE_CODE_SIMPLE = SAVED_SIMPLE - } - if (SAVED_DISABLE_ATTACHMENTS === undefined) { - delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS - } else { - process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = SAVED_DISABLE_ATTACHMENTS - } + restoreEnv() }) test('does not return a diagnostics attachment for an empty final LSP payload', async () => { diff --git a/src/utils/attachments.todoReminder.test.ts b/src/utils/attachments.todoReminder.test.ts new file mode 100644 index 0000000000..acf2c8b5b8 --- /dev/null +++ b/src/utils/attachments.todoReminder.test.ts @@ -0,0 +1,319 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { getIsInteractive, setIsInteractive } from '../bootstrap/state.js' +import { getDefaultAppState } from '../state/AppStateStore.js' +import { snapshotEnv } from '../test/envSnapshot.js' +import { acquireSharedMutationLock, releaseSharedMutationLock } from '../test/sharedMutationLock.js' +import type { ToolUseContext, Tools } from '../Tool.js' +import { asAgentId } from '../types/ids.js' +import { createFileStateCacheWithSizeLimit } from './fileStateCache.js' +import { createAssistantMessage } from './messages.js' +import { SettingsSchema } from './settings/types.js' + +const ENV_KEYS = [ + 'OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE', + 'OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS', +] as const + +const SAVED_ENV = Object.fromEntries( + ENV_KEYS.map(key => [key, process.env[key]]), +) as Record<(typeof ENV_KEYS)[number], string | undefined> + +function restoreEnv(): void { + for (const key of ENV_KEYS) { + const value = SAVED_ENV[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} + +function clearTestEnv(): void { + for (const key of ENV_KEYS) { + delete process.env[key] + } +} + +// Snapshot the real settings module with a cache-busted import before any +// mock.module runs, so the cleanup below can restore the genuine +// implementation even when a previous serialized Bun test mocked the same +// module (pattern from attribution.test.ts / modelOptions.crossProfile.test.ts). +const realSettings = { ...await import(`./settings/settings.js?real=${Date.now()}`) } + +// Settings stubs passed to mockMergedSettings must already be schema-valid: +// SettingsSchema degrades invalid todoReminder values to undefined upstream +// (see the schema tests below), so the consumer never observes them. +function mockMergedSettings(settings: Record): void { + mock.module('./settings/settings.js', () => ({ + ...realSettings, + getInitialSettings: () => settings, + getSettings_DEPRECATED: () => settings, + getSettingsForSource: () => settings, + })) +} + +let settingsAwareAttachments: Promise | undefined +let getTodoReminderConfig: typeof import('./attachments.js')['getTodoReminderConfig'] +async function getSettingsAwareAttachments(): Promise { + // Fresh nonced import so the consumer observes this file's settings mock + // even when earlier serialized tests mocked the same modules. + settingsAwareAttachments ??= import( + `./attachments.ts?todoreminderSettings=${Date.now()}-${Math.random()}` + ) + return settingsAwareAttachments +} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/attachments.todoReminder.test.ts') + clearTestEnv() + mockMergedSettings({}) + ;({ getTodoReminderConfig } = await getSettingsAwareAttachments()) +}) + +afterEach(() => { + try { + mock.restore() + mock.module('./settings/settings.js', () => realSettings) + restoreEnv() + } finally { + releaseSharedMutationLock() + } +}) + +describe('getTodoReminderConfig', () => { + test('returns defaults when nothing is configured', () => { + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(10) + expect(config.turnsBetweenReminders).toBe(10) + }) + + test('reads from environment variables', () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '15' + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS = '20' + + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(15) + expect(config.turnsBetweenReminders).toBe(20) + }) + + test('handles invalid environment variable values', () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = 'invalid' + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS = '0' + + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(10) + expect(config.turnsBetweenReminders).toBe(10) + }) + + test('handles negative environment variable values', () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '-5' + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS = '-10' + + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(10) + expect(config.turnsBetweenReminders).toBe(10) + }) + + test('handles partial environment variable configuration', () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '15' + + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(15) + expect(config.turnsBetweenReminders).toBe(10) + }) + + test('rejects suffixed, fractional, and scientific-notation values', () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '15turns' + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS = '2.5' + + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(10) + expect(config.turnsBetweenReminders).toBe(10) + + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '1e2' + expect(getTodoReminderConfig().turnsSinceWrite).toBe(10) + }) + + test('rejects oversized values instead of truncating them', () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '9'.repeat(400) + + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(10) + }) + + test('accepts whitespace-padded valid values', () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = ' 12 ' + + const config = getTodoReminderConfig() + expect(config.turnsSinceWrite).toBe(12) + }) +}) + +describe('getTodoReminderConfig settings precedence', () => { + test('both settings fields override environment variables', async () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '99' + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS = '99' + mockMergedSettings({ todoReminder: { turnsSinceWrite: 15, turnsBetweenReminders: 20 } }) + + const { getTodoReminderConfig: resolve } = await getSettingsAwareAttachments() + const config = resolve() + expect(config.turnsSinceWrite).toBe(15) + expect(config.turnsBetweenReminders).toBe(20) + }) + + test('partial settings keep the configured field and fall back per field', async () => { + mockMergedSettings({ todoReminder: { turnsSinceWrite: 15 } }) + + const { getTodoReminderConfig: resolve } = await getSettingsAwareAttachments() + const config = resolve() + expect(config.turnsSinceWrite).toBe(15) + expect(config.turnsBetweenReminders).toBe(10) + }) + + test('partial settings combine with environment fallbacks per field', async () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '7' + mockMergedSettings({ todoReminder: { turnsBetweenReminders: 20 } }) + + const { getTodoReminderConfig: resolve } = await getSettingsAwareAttachments() + const config = resolve() + expect(config.turnsSinceWrite).toBe(7) + expect(config.turnsBetweenReminders).toBe(20) + }) + + test('settings without todoReminder fall back to environment variables', async () => { + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE = '15' + process.env.OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS = '20' + mockMergedSettings({}) + + const { getTodoReminderConfig: resolve } = await getSettingsAwareAttachments() + const config = resolve() + expect(config.turnsSinceWrite).toBe(15) + expect(config.turnsBetweenReminders).toBe(20) + }) +}) + +describe('reminder attachment thresholds', () => { + let configDir: string + let wasInteractive: boolean + let restoreConsumerEnv: () => void + + beforeEach(() => { + wasInteractive = getIsInteractive() + restoreConsumerEnv = snapshotEnv([ + 'OPENCLAUDE_CONFIG_DIR', + 'CLAUDE_CODE_TASK_LIST_ID', + 'CLAUDE_CODE_ENABLE_TASKS', + 'CLAUDE_CODE_DISABLE_ATTACHMENTS', + 'CLAUDE_CODE_SIMPLE', + 'USER_TYPE', + ], { clear: true }) + configDir = mkdtempSync(join(tmpdir(), 'todo-reminder-test-')) + process.env.OPENCLAUDE_CONFIG_DIR = configDir + process.env.CLAUDE_CODE_TASK_LIST_ID = 'todo-reminder-test' + }) + + afterEach(() => { + setIsInteractive(wasInteractive) + restoreConsumerEnv() + rmSync(configDir, { recursive: true, force: true }) + }) + + for (const [type, toolName] of [ + ['todo_reminder', 'TodoWrite'], + ['task_reminder', 'TaskUpdate'], + ] as const) { + test.each([ + { field: 'turnsSinceWrite', turnsSinceWrite: 3, turnsBetweenReminders: 1, threshold: 3 }, + { field: 'turnsBetweenReminders', turnsSinceWrite: 1, turnsBetweenReminders: 4, threshold: 4 }, + ])(`${type} applies $field at its configured boundary`, async ({ + turnsSinceWrite, turnsBetweenReminders, threshold, + }) => { + setIsInteractive(type === 'task_reminder') + mockMergedSettings({ todoReminder: { turnsSinceWrite, turnsBetweenReminders } }) + const { getAttachments } = await getSettingsAwareAttachments() + let appState = getDefaultAppState() + const messages = Array.from({ length: threshold - 1 }, () => + createAssistantMessage({ content: 'Working on the task' }), + ) + const context: ToolUseContext = { + agentId: asAgentId('todo-reminder-test'), + abortController: new AbortController(), + readFileState: createFileStateCacheWithSizeLimit(1), + getAppState: () => appState, + setAppState: updater => { appState = updater(appState) }, + setInProgressToolUseIDs: () => {}, + setResponseLength: () => {}, + updateFileHistoryState: () => {}, + updateAttributionState: () => {}, + messages, + options: { + commands: [], + debug: false, + thinkingConfig: { type: 'disabled' }, + tools: [{ name: toolName } as Tools[number]], + verbose: false, + mcpClients: [], + mcpResources: {}, + isNonInteractiveSession: type === 'todo_reminder', + agentDefinitions: { activeAgents: [], allAgents: [], allowedAgentTypes: undefined }, + mainLoopModel: 'gpt-4o', + }, + nestedMemoryAttachmentTriggers: new Set(), + dynamicSkillDirTriggers: new Set(), + } + const collect = async () => (await getAttachments( + null, context, null, [], messages, 'compact', { skipSkillDiscovery: true }, + )).filter(attachment => attachment.type === type) + + expect(await collect()).toEqual([]) + messages.push(createAssistantMessage({ content: 'Continuing the task' })) + expect(await collect()).toEqual([{ type, content: [], itemCount: 0 }]) + }) + } +}) + +describe('todoReminder settings schema', () => { + const parseSettings = (settings: unknown) => SettingsSchema().safeParse(settings) + + test('degrades an invalid threshold to undefined while keeping the source valid', () => { + const result = parseSettings({ + includeCoAuthoredBy: true, + todoReminder: { turnsSinceWrite: 0, turnsBetweenReminders: 5 }, + }) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.data.includeCoAuthoredBy).toBe(true) + expect(result.data.todoReminder?.turnsSinceWrite).toBeUndefined() + expect(result.data.todoReminder?.turnsBetweenReminders).toBe(5) + }) + + test('degrades negative and non-integer thresholds to undefined', () => { + for (const turnsSinceWrite of [-3, 2.5, '15']) { + const result = parseSettings({ todoReminder: { turnsSinceWrite } }) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.data.todoReminder?.turnsSinceWrite).toBeUndefined() + } + }) + + test('degrades a malformed todoReminder object to undefined instead of invalidating the source', () => { + const result = parseSettings({ includeCoAuthoredBy: true, todoReminder: 'yes' }) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.data.includeCoAuthoredBy).toBe(true) + expect(result.data.todoReminder).toBeUndefined() + }) + + test('preserves valid thresholds', () => { + const result = parseSettings({ + todoReminder: { turnsSinceWrite: 15, turnsBetweenReminders: 20 }, + }) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.data.todoReminder).toEqual({ turnsSinceWrite: 15, turnsBetweenReminders: 20 }) + }) +}) diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index 3cd6cd451f..93ae931fd4 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -66,6 +66,7 @@ import { import { randomUUID, type UUID } from 'crypto' import { getSettings_DEPRECATED } from './settings/settings.js' import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js' +import { getEnvNumber } from './envUtils.js' import { getEffortEnvOverride, modelSupportsXHighEffort } from './effort.js' import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js' import type { @@ -266,10 +267,25 @@ import { unassignTeammateTasks } from './tasks.js' import { getCompanionIntroAttachment } from '../buddy/prompt.js' import { isBuddyEnabled } from '../buddy/feature.js' -export const TODO_REMINDER_CONFIG = { - TURNS_SINCE_WRITE: 10, - TURNS_BETWEEN_REMINDERS: 10, -} as const +export function getTodoReminderConfig(): { + turnsSinceWrite: number + turnsBetweenReminders: number +} { + // Per-field precedence: merged-settings value when present, otherwise the + // matching environment variable, otherwise the default. Each threshold is + // resolved independently so a partial settings object still honors the + // field it configures. + const config = getSettings_DEPRECATED()?.todoReminder + + return { + turnsSinceWrite: + config?.turnsSinceWrite ?? + getEnvNumber('OPENCLAUDE_TODO_REMINDER_TURNS_SINCE_WRITE', 10), + turnsBetweenReminders: + config?.turnsBetweenReminders ?? + getEnvNumber('OPENCLAUDE_TODO_REMINDER_TURNS_BETWEEN_REMINDERS', 10), + } +} export const PLAN_MODE_ATTACHMENT_CONFIG = { TURNS_BETWEEN_ATTACHMENTS: 5, @@ -3711,10 +3727,12 @@ async function getTodoReminderAttachments( const { turnsSinceLastTodoWrite, turnsSinceLastReminder } = getTodoReminderTurnCounts(messages) + const reminderConfig = getTodoReminderConfig() + // Check if we should show a reminder if ( - turnsSinceLastTodoWrite >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE && - turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS + turnsSinceLastTodoWrite >= reminderConfig.turnsSinceWrite && + turnsSinceLastReminder >= reminderConfig.turnsBetweenReminders ) { const todoKey = toolUseContext.agentId ?? getSessionId() const appState = toolUseContext.getAppState() @@ -3828,10 +3846,12 @@ async function getTaskReminderAttachments( const { turnsSinceLastTaskManagement, turnsSinceLastReminder } = getTaskReminderTurnCounts(messages) + const reminderConfig = getTodoReminderConfig() + // Check if we should show a reminder if ( - turnsSinceLastTaskManagement >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE && - turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS + turnsSinceLastTaskManagement >= reminderConfig.turnsSinceWrite && + turnsSinceLastReminder >= reminderConfig.turnsBetweenReminders ) { const tasks = await listTasks(getTaskListId()) return [ diff --git a/src/utils/attachments.ultrathink.test.ts b/src/utils/attachments.ultrathink.test.ts index f597074897..3ce0168376 100644 --- a/src/utils/attachments.ultrathink.test.ts +++ b/src/utils/attachments.ultrathink.test.ts @@ -1,24 +1,24 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { snapshotEnv } from '../test/envSnapshot.js' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' -import * as realThinking from './thinking.js' -let getUltrathinkEffortAttachment: typeof import('./attachments.js').getUltrathinkEffortAttachment -let savedEnv: { - disableAttachments: string | undefined - simple: string | undefined +// Keep a copy: mock.module also repoints an imported module namespace. +const realThinking = { + ...await import(`./thinking.js?real=${Date.now()}-${Math.random()}`), } +let getUltrathinkEffortAttachment: typeof import('./attachments.js').getUltrathinkEffortAttachment +let restoreEnv: () => void + beforeEach(async () => { await acquireSharedMutationLock('utils/attachments.ultrathink.test.ts') - savedEnv = { - disableAttachments: process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS, - simple: process.env.CLAUDE_CODE_SIMPLE, - } - delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS - delete process.env.CLAUDE_CODE_SIMPLE + restoreEnv = snapshotEnv([ + 'CLAUDE_CODE_DISABLE_ATTACHMENTS', + 'CLAUDE_CODE_SIMPLE', + ], { clear: true }) mock.module('./thinking.js', () => ({ ...realThinking, isUltrathinkEnabled: () => true, @@ -30,16 +30,7 @@ beforeEach(async () => { afterEach(() => { try { - if (savedEnv.disableAttachments === undefined) { - delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS - } else { - process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = savedEnv.disableAttachments - } - if (savedEnv.simple === undefined) { - delete process.env.CLAUDE_CODE_SIMPLE - } else { - process.env.CLAUDE_CODE_SIMPLE = savedEnv.simple - } + restoreEnv() mock.restore() // Bun's mock.restore() does not unregister module mocks. mock.module('./thinking.js', () => realThinking) diff --git a/src/utils/attribution.test.ts b/src/utils/attribution.test.ts index edfcb8dc7e..64bc569e72 100644 --- a/src/utils/attribution.test.ts +++ b/src/utils/attribution.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' import { getClientType, getMainLoopModelOverride, @@ -6,16 +6,29 @@ import { setClientType, setMainLoopModelOverride, } from '../bootstrap/state.js' -import * as actualModel from './model/model.js' -import * as actualProviders from './model/providers.js' import { resetSettingsCache, setSessionSettingsCache, } from './settings/settingsCache.js' -import * as realSettings from './settings/settings.js' import type { SettingsJson } from './settings/types.js' -const actualSettings = { ...realSettings } +const nonce = `${Date.now()}-${Math.random()}` +const actualProviders = { + ...await import(`./model/providers.js?real=${nonce}`), +} +const actualModel = { + ...await import(`./model/model.js?real=${nonce}`), +} +const actualSettings = { + ...await import(`./settings/settings.js?real=${nonce}`), +} + +afterAll(() => { + mock.restore() + mock.module('./model/providers.js', () => actualProviders) + mock.module('./model/model.js', () => actualModel) + mock.module('./settings/settings.js', () => actualSettings) +}) let getAttributionTexts: (typeof import('./attribution.js'))['getAttributionTexts'] let getDefaultCommitCoAuthorEmail: (typeof import('./attribution.js'))[ diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index 02cc24478d..63caaf18ac 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -1,5 +1,6 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, expect, spyOn, test } from 'bun:test' import * as originalSettings from './settings/settings.js' +import type { SettingsJson } from './settings/types.js' type MockSource = | 'userSettings' @@ -14,11 +15,10 @@ async function importAuthFresh() { return await import(`./auth.js?ts=${Date.now()}-${Math.random()}`) } -// Restore Bun's module mocks after each test so leaked settings behavior -// cannot influence later auth/settings tests in the same process. -// Addresses jatmn's P3 on #1731: test isolation for mock.module(). +const settingsSpies: Array<{ mockRestore(): void }> = [] + afterEach(() => { - mock.restore() + for (const spy of settingsSpies.splice(0)) spy.mockRestore() }) // Helper: mock settings to return the given subscriptionType from a specific @@ -26,17 +26,18 @@ afterEach(() => { // from user settings only — project, local, flag, and policy settings must // NOT propagate subscriptionType. function mockSettings( - subscriptionType: string | undefined, + subscriptionType: SettingsJson['subscriptionType'], source: MockSource = 'userSettings', ) { - mock.module('./settings/settings.js', () => ({ - ...originalSettings, - getSettings_DEPRECATED: () => (source === 'none' ? {} : { subscriptionType }), - getSettingsForSource: (s: string) => { + settingsSpies.push( + spyOn(originalSettings, 'getSettings_DEPRECATED').mockImplementation( + () => (source === 'none' ? {} : { subscriptionType }), + ), + spyOn(originalSettings, 'getSettingsForSource').mockImplementation(s => { if (source === 'none') return null return s === source ? { subscriptionType } : null - }, - })) + }), + ) } async function withControlledAuthEnv( diff --git a/src/utils/auth.ts b/src/utils/auth.ts index c8443edbba..706512dc7c 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -58,6 +58,7 @@ import { isEnvTruthy, isRunningOnHomespace, } from './envUtils.js' +import { hasAnyTruthyProviderSelectionFlag } from './providerSelectionFlags.js' import { errorMessage } from './errors.js' import { execSyncWithDefaults_DEPRECATED } from './execFileNoThrow.js' import * as lockfile from './lockfile.js' @@ -114,14 +115,7 @@ export function isAnthropicAuthEnabled(): boolean { return !!process.env.CLAUDE_CODE_OAUTH_TOKEN } - const is3P = - isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) + const is3P = hasAnyTruthyProviderSelectionFlag() // Check if user has configured an external API key source // This allows externally-provided API keys to work (without requiring proxy configuration) @@ -1641,6 +1635,7 @@ export function is1PApiCustomer(): boolean { if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ) { return false @@ -1780,17 +1775,9 @@ export function getSubscriptionName(): string { } } -/** Check if using third-party services (Bedrock or Vertex or Foundry or OpenAI-compatible or Gemini or GitHub Models) */ +/** Check if any third-party provider-selection flag is active (derives from PROVIDER_SELECTION_FLAGS). */ export function isUsing3PServices(): boolean { - return !!( - isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) - ) + return hasAnyTruthyProviderSelectionFlag() } /** diff --git a/src/utils/bash/commands.ts b/src/utils/bash/commands.ts index 8c2d0ef16b..f4467ac017 100644 --- a/src/utils/bash/commands.ts +++ b/src/utils/bash/commands.ts @@ -252,7 +252,7 @@ export function filterControlOperators( commandsAndOperators: string[], ): string[] { return commandsAndOperators.filter( - part => !(ALL_SUPPORTED_CONTROL_OPERATORS as Set).has(part), + part => !ALL_SUPPORTED_CONTROL_OPERATORS.has(part), ) } @@ -520,7 +520,7 @@ export function clearCommandPrefixCaches(): void { getCommandSubcommandPrefix.cache.clear() } -const COMMAND_LIST_SEPARATORS = new Set([ +const COMMAND_LIST_SEPARATORS: ReadonlySet = new Set([ '&&', '||', ';', @@ -528,7 +528,7 @@ const COMMAND_LIST_SEPARATORS = new Set([ '|', ]) -const ALL_SUPPORTED_CONTROL_OPERATORS = new Set([ +const ALL_SUPPORTED_CONTROL_OPERATORS: ReadonlySet = new Set([ ...COMMAND_LIST_SEPARATORS, '>&', '>', diff --git a/src/utils/claudeInChrome/chromeNativeHost.ts b/src/utils/claudeInChrome/chromeNativeHost.ts index 7176d050db..37fa89401f 100644 --- a/src/utils/claudeInChrome/chromeNativeHost.ts +++ b/src/utils/claudeInChrome/chromeNativeHost.ts @@ -18,7 +18,7 @@ import { import { createServer, type Server, type Socket } from 'net' import { homedir, platform } from 'os' import { join } from 'path' -import { z } from 'zod' +import { z } from 'zod/v4' import { lazySchema } from '../lazySchema.js' import { jsonParse, jsonStringify } from '../slowOperations.js' import { getSecureSocketPath, getSocketDir } from './common.js' @@ -83,10 +83,9 @@ export async function runChromeNativeHost(): Promise { const messageSchema = lazySchema(() => z - .object({ + .looseObject({ type: z.string(), - }) - .passthrough(), + }), ) type ToolRequest = { diff --git a/src/utils/claudemd.ts b/src/utils/claudemd.ts index a332a3cf07..9cb1990fdb 100644 --- a/src/utils/claudemd.ts +++ b/src/utils/claudemd.ts @@ -312,14 +312,20 @@ function stripHtmlCommentsFromTokens(tokens: ReturnType): { let result = '' let stripped = false - for (const token of tokens) { + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]! if (token.type === 'html') { const trimmed = token.raw.trimStart() if (trimmed.startsWith('')) { // Per CommonMark, a type-2 HTML block ends at the *line* containing // `-->`, so text after `-->` on that line is part of this token. // Strip only the comment spans and keep any residual content. - const residue = stripHtmlCommentSpans(token.raw) + // marked emits trailing blank lines as a separate token. Remove them + // with a comment-only block, or retain them with its visible residue. + const next = tokens[index + 1] + const trailingSpace = next?.type === 'space' ? next.raw : '' + if (trailingSpace) index++ + const residue = stripHtmlCommentSpans(token.raw + trailingSpace) stripped = true if (residue.trim().length > 0) { // Residual content exists (e.g. ` Use bun`): keep it. diff --git a/src/utils/cleanup.test.ts b/src/utils/cleanup.test.ts index 3306f63453..b89b1d0667 100644 --- a/src/utils/cleanup.test.ts +++ b/src/utils/cleanup.test.ts @@ -67,6 +67,7 @@ async function runCleanupFixture( mode: | 'once' | 'pass-outcome' + | 'policy-outcome' | 'marker-failure' | 'periodic-policy-recheck' | 'periodic-recovery' @@ -400,6 +401,11 @@ describe('cleanupOldMessageFilesInBackground', () => { expect(await Bun.file(targetPath).exists()).toBe(true) await rm(join(root, '.recovery-pass'), { force: true }) + const freshPath = await createCompletedBackgroundSession( + configDir, + 'bg-progress-fresh', + new Date(Date.now() - 60_000), + ) expect( await runCleanupFixture( configDir, @@ -407,6 +413,7 @@ describe('cleanupOldMessageFilesInBackground', () => { { OPENCLAUDE_CLEANUP_RECOVERY_LIMIT: '2' }, ), ).toEqual({ result: 'ran' }) + expect(await Bun.file(freshPath).exists()).toBe(false) expect(await Bun.file(targetPath).exists()).toBe(false) }, 30_000, @@ -447,41 +454,37 @@ describe('cleanupOldMessageFilesInBackground', () => { ) const journal = join(root, '.recovery-journal') const before = await readFile(journal, 'utf8') - expect( - await runCleanupFixture(configDir, 'pass-outcome', { - OPENCLAUDE_CLEANUP_TRIGGER: trigger, - OPENCLAUDE_CLEANUP_REJECT_AFTER_LOCK: - timing === 'after' ? '1' : '0', - }), - ).toEqual({ result: 'invalid-policy' }) - expect(await Bun.file(metadataPath).exists()).toBe(true) - expect(await Bun.file(marker).exists()).toBe(false) - expect( - await Bun.file(join(root, '.recovery-cursor.json')).exists(), - ).toBe(false) - expect(await readFile(journal, 'utf8')).toBe(before) - - await writeFile(settingsPath, validPolicy) - expect( - await runCleanupFixture(configDir, 'pass-outcome', { - OPENCLAUDE_CLEANUP_TRIGGER: trigger, - }), - ).toEqual({ result: 'ran' }) + const outcomes = await runCleanupFixture(configDir, 'policy-outcome', { + OPENCLAUDE_CLEANUP_TRIGGER: trigger, + OPENCLAUDE_CLEANUP_REJECT_AFTER_LOCK: + timing === 'after' ? '1' : '0', + }) + expect(outcomes).toEqual({ + phases: timing === 'after' ? ['policy-hook', 'threshold'] : ['threshold'], + policyHookLockHeld: timing === 'after' ? true : null, + rejected: { + result: 'invalid-policy', + metadataPresent: true, + markerPresent: false, + cursorPresent: false, + journal: before, + }, + retried: { + result: 'ran', + metadataPresent: false, + journal: '', + markerPresent: true, + }, + throttled: 'skipped', + rerun: { + result: 'ran', + metadataPresent: false, + markerPresent: true, + }, + }) expect(await Bun.file(metadataPath).exists()).toBe(false) expect(await readFile(journal, 'utf8')).toBe('') expect(await Bun.file(marker).exists()).toBe(true) - expect( - await runCleanupFixture(configDir, 'pass-outcome', { - OPENCLAUDE_CLEANUP_TRIGGER: trigger, - }), - ).toEqual({ result: 'skipped' }) - await rm(marker) - expect( - await runCleanupFixture(configDir, 'pass-outcome', { - OPENCLAUDE_CLEANUP_TRIGGER: trigger, - }), - ).toEqual({ result: 'ran' }) - expect(await Bun.file(marker).exists()).toBe(true) }, 30_000) } diff --git a/src/utils/cleanupBackgroundSessions.fixture.ts b/src/utils/cleanupBackgroundSessions.fixture.ts index a9b0a838cc..f6b3efe2e3 100644 --- a/src/utils/cleanupBackgroundSessions.fixture.ts +++ b/src/utils/cleanupBackgroundSessions.fixture.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { mkdir, utimes } from 'node:fs/promises' +import { mkdir, readFile, rm, stat, utimes } from 'node:fs/promises' import { dirname, join } from 'node:path' import { createBackgroundSession, @@ -8,6 +8,7 @@ import { import { killHandler } from '../cli/bg.js' import { enableConfigs } from './config.js' import { eagerLoadSettingsFromArgs } from './settings/flagSettings.js' +import { resetSettingsCache } from './settings/settingsCache.js' import { cleanupBackgroundSessionsAfterFinalization, cleanupBackgroundSessionsInBackground, @@ -17,7 +18,7 @@ import { try { const mode = process.env.OPENCLAUDE_CLEANUP_FIXTURE_MODE - if (mode === 'pass-outcome') { + if (mode === 'pass-outcome' || mode === 'policy-outcome') { enableConfigs() const settingsPath = join( process.env.OPENCLAUDE_CONFIG_DIR!, @@ -32,15 +33,27 @@ try { process.env.OPENCLAUDE_CLEANUP_TRIGGER === 'periodic-retention' ? 'periodic-retention' : 'periodic-recovery' + let rejectAfterLock = + process.env.OPENCLAUDE_CLEANUP_REJECT_AFTER_LOCK === '1' + const phases: string[] = [] + let policyHookLockHeld: boolean | null = null const afterLock = async () => { - if (process.env.OPENCLAUDE_CLEANUP_REJECT_AFTER_LOCK === '1') { + if (rejectAfterLock) { + phases.push('policy-hook') + policyHookLockHeld = await stat(join( + process.env.OPENCLAUDE_CONFIG_DIR!, + 'bg-sessions', + trigger === 'periodic-recovery' + ? '.recovery-pass.lock' + : '.retention-pass.lock', + )).then(lock => lock.isDirectory(), () => false) await Bun.write( settingsPath, JSON.stringify({ cleanupPeriodDays: 'invalid' }), ) } } - const result = await runBackgroundSessionRetention( + const runPass = () => runBackgroundSessionRetention( trigger === 'periodic-recovery' ? { trigger, _afterThrottleLockForTesting: afterLock } : { @@ -74,7 +87,83 @@ try { }, }, ) - process.stdout.write(`${JSON.stringify({ result })}\n`) + if (mode === 'policy-outcome') { + const root = join(process.env.OPENCLAUDE_CONFIG_DIR!, 'bg-sessions') + const marker = join( + root, + trigger === 'periodic-recovery' ? '.recovery-pass' : '.retention-pass', + ) + const snapshot = async (result: Awaited>) => ({ + result, + metadataPresent: await Bun.file( + join(root, 'sessions', 'bg-rejected-policy.json'), + ).exists(), + markerPresent: await Bun.file(marker).exists(), + cursorPresent: await Bun.file( + join(root, '.recovery-cursor.json'), + ).exists(), + journal: await readFile(join(root, '.recovery-journal'), 'utf8'), + }) + const freshPass = () => { + // Match a fresh process's settings read at each phase boundary. + resetSettingsCache() + return runPass() + } + const originalDateNow = Date.now + const now = Date.now() + try { + // Scheduling must not age the recovery marker between these phases. + Date.now = () => now + const rejected = await snapshot(await freshPass()) + rejectAfterLock = false + await Bun.write(settingsPath, JSON.stringify({ cleanupPeriodDays: 0 })) + const retriedState = await snapshot(await freshPass()) + const retried = { + result: retriedState.result, + metadataPresent: retriedState.metadataPresent, + journal: retriedState.journal, + markerPresent: retriedState.markerPresent, + } + // Anchor the throttle check to the published marker's time. + const markerNow = Math.max(now, Math.ceil((await stat(marker)).mtimeMs)) + Date.now = () => markerNow + phases.push('threshold') + const throttled = await freshPass() + await rm(marker) + const rerunSession = await createBackgroundSession({ + id: 'bg-policy-rerun', + pid: process.pid, + cwd: process.cwd(), + command: ['openclaude', '--print', 'fixture'], + sessionId: 'bg-policy-rerun-conversation', + }) + recordBackgroundSessionNaturalTerminationSync( + rerunSession.id, + { exitCode: 0 }, + { + ownerPid: process.pid, + expectedSession: rerunSession, + now: new Date(markerNow - 1_000), + }, + ) + const rerunState = await snapshot(await freshPass()) + const rerun = { + result: rerunState.result, + metadataPresent: await Bun.file( + join(root, 'sessions', `${rerunSession.id}.json`), + ).exists(), + markerPresent: rerunState.markerPresent, + } + process.stdout.write( + `${JSON.stringify({ rejected, retried, throttled, rerun, phases, policyHookLockHeld })}\n`, + ) + } finally { + Date.now = originalDateNow + } + } else { + const result = await runPass() + process.stdout.write(`${JSON.stringify({ result })}\n`) + } } else if ( mode === 'periodic-retention' || mode === 'periodic-retention-policy-recheck' diff --git a/src/utils/cliHighlight.test.ts b/src/utils/cliHighlight.test.ts new file mode 100644 index 0000000000..a49b50e322 --- /dev/null +++ b/src/utils/cliHighlight.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import chalk from 'chalk' +import { stripVTControlCharacters } from 'node:util' +import baseline from '../test/fixtures/cliHighlight.baseline.json' +import { getCliHighlightPromise, getLanguageName, type CliHighlight } from './cliHighlight.js' +import { applyMarkdown } from './markdown.js' + +let previousLevel: typeof chalk.level +let highlighter: CliHighlight + +beforeEach(async () => { + previousLevel = chalk.level + chalk.level = 1 + const loaded = await getCliHighlightPromise() + expect(loaded).not.toBeNull() + highlighter = loaded! +}) + +afterEach(() => { chalk.level = previousLevel }) + +// Captured from the previous renderer at color level 1. Compare ANSI bytes, +// including nested color restoration, rather than only the visible text. +describe('terminal syntax output compatibility', () => { + for (const fixture of baseline) { + test(fixture.name, () => { + expect(highlighter.highlight(fixture.code, { language: fixture.language })) + .toBe(fixture.output) + }) + } + + test('disabled colors preserve visible output', () => { + chalk.level = 0 + for (const fixture of baseline) { + expect(highlighter.highlight(fixture.code, { language: fixture.language })) + .toBe(stripVTControlCharacters(fixture.output)) + } + }) + + test('aliases and telemetry use the shared language registry', async () => { + expect(getCliHighlightPromise()).toBe(getCliHighlightPromise()) + expect(highlighter.supportsLanguage('ts')).toBe(true) + expect(highlighter.supportsLanguage('unsupported-language')).toBe(false) + expect(await getLanguageName('example.ts')).toBe('TypeScript') + expect(await getLanguageName('example.unsupported-language')).toBe('unknown') + expect(await getLanguageName('README')).toBe('unknown') + }) + + test('Markdown keeps the syntax palette in dark and light themes', () => { + const fixture = baseline.find(item => item.name === 'json')! + for (const theme of ['dark', 'light'] as const) { + expect(applyMarkdown('```json\n' + fixture.code + '\n```', theme, highlighter)) + .toBe(fixture.output) + } + }) + + test('unknown and missing fenced languages fall back to plaintext', () => { + const code = 'const value = "<&>"; // keep plain' + for (const language of ['unsupported-language', '']) { + expect(applyMarkdown('```' + language + '\n' + code + '\n```', 'dark', highlighter)) + .toBe(code) + } + }) +}) diff --git a/src/utils/cliHighlight.ts b/src/utils/cliHighlight.ts index 70504fa04a..086782aa80 100644 --- a/src/utils/cliHighlight.ts +++ b/src/utils/cliHighlight.ts @@ -1,34 +1,64 @@ -// highlight.js's type defs carry `/// `. SSETransport, -// mcp/client, ssh, dumpPrompts use DOM types (TextDecodeOptions, RequestInfo) -// that only typecheck because this file's `typeof import('highlight.js')` pulls -// lib.dom in. tsconfig has lib: ["ESNext"] only — fixing the actual DOM-type -// deps is a separate sweep; this ref preserves the status quo. -/// - +import chalk from 'chalk' import { extname } from 'path' export type CliHighlight = { - highlight: typeof import('cli-highlight').highlight - supportsLanguage: typeof import('cli-highlight').supportsLanguage + highlight: (code: string, options: { language: string }) => string + supportsLanguage: (name: string) => boolean +} + +// Preserve the terminal palette used by the code and Markdown renderers. +// Unlisted tokens, including titles, parameters and substitutions, are plain. +const tokenStyles = new Map string>([ + ['keyword', chalk.blue], ['literal', chalk.blue], ['class', chalk.blue], + ['built_in', chalk.cyan], ['type', chalk.cyan.dim], ['attr', chalk.cyan], + ['number', chalk.green], ['comment', chalk.green], ['doctag', chalk.green], + ['addition', chalk.green], ['regexp', chalk.red], ['string', chalk.red], + ['deletion', chalk.red], ['function', chalk.yellow], ['meta', chalk.gray], + ['tag', chalk.gray], ['name', chalk.blue], ['emphasis', chalk.italic], + ['strong', chalk.bold], ['link', chalk.underline], +]) + +const entities: Record = { + '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", +} + +// Only consume highlight.js's generated spans and its five escaped entities. +// Source markup is escaped by highlight.js, so it cannot become a span here. +// A stack preserves nested styles and unprefixed sublanguage spans without a +// general HTML parser. Keep the previous renderer's CR/LF and NUL handling. +function terminalHighlight(html: string): string { + const stack: { token?: string; text: string }[] = [{ text: '' }] + for (const part of html.replace(/\r\n?/g, '\n').replace(/\0/g, '') + .split(/(|<\/span>)/g)) { + if (part.startsWith('