From 8f23da2b4208ac16baeb99bd80e7bea4b89ed48e Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Fri, 16 Oct 2026 02:20:49 +0900 Subject: [PATCH] Move the action from Deno to Node.js and Vite+ --- .gitea/workflows/check.yml | 17 +- .gitignore | 2 + AGENTS.md | 21 +- action.yml | 10 +- package-lock.json | 3083 ++++++++++++++++++++ package.json | 16 + profiles.json | 19 + profiles.yml | 10 - scripts/project-lint.ts | 232 ++ scripts/protect-source.sh | 3 +- src/agent-claude.ts | 119 + src/agent-codex.ts | 174 ++ src/agent-opencode.ts | 46 + src/agents.ts | 30 + src/{auth_test.ts => auth.test.ts} | 134 +- src/auth.ts | 98 +- src/command.ts | 160 + src/credentials.test.ts | 68 + src/credentials.ts | 116 +- src/credentials_test.ts | 70 - src/errors.ts | 8 + src/isolation.test.ts | 112 + src/isolation.ts | 100 +- src/isolation_test.ts | 129 - src/json.ts | 12 + src/{opencode_test.ts => opencode.test.ts} | 56 +- src/opencode.ts | 25 +- src/post-artifacts.ts | 114 + src/post.test.ts | 296 ++ src/post.ts | 320 +- src/post_test.ts | 303 -- src/prepare.ts | 15 +- src/{profiles_test.ts => profiles.test.ts} | 142 +- src/profiles.ts | 63 +- src/run-context.ts | 79 + src/run.ts | 448 +-- src/runtime.test.ts | 105 + src/runtime.ts | 92 +- src/runtime_test.ts | 102 - src/testing.ts | 23 + src/workflow_test.ts | 174 -- tests/entrypoints.test.ts | 27 + tests/workflow.test.ts | 175 ++ tsconfig.json | 22 + vite.config.ts | 25 + 45 files changed, 5664 insertions(+), 1731 deletions(-) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 profiles.json delete mode 100644 profiles.yml create mode 100644 scripts/project-lint.ts create mode 100644 src/agent-claude.ts create mode 100644 src/agent-codex.ts create mode 100644 src/agent-opencode.ts create mode 100644 src/agents.ts rename src/{auth_test.ts => auth.test.ts} (55%) create mode 100644 src/command.ts create mode 100644 src/credentials.test.ts delete mode 100644 src/credentials_test.ts create mode 100644 src/errors.ts create mode 100644 src/isolation.test.ts delete mode 100644 src/isolation_test.ts create mode 100644 src/json.ts rename src/{opencode_test.ts => opencode.test.ts} (55%) create mode 100644 src/post-artifacts.ts create mode 100644 src/post.test.ts delete mode 100644 src/post_test.ts rename src/{profiles_test.ts => profiles.test.ts} (58%) create mode 100644 src/run-context.ts create mode 100644 src/runtime.test.ts delete mode 100644 src/runtime_test.ts create mode 100644 src/testing.ts delete mode 100644 src/workflow_test.ts create mode 100644 tests/entrypoints.test.ts create mode 100644 tests/workflow.test.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 730639b..eef9b0f 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -5,17 +5,18 @@ on: branches: [main] jobs: - deno: + check: runs-on: self-hosted steps: - uses: actions/checkout@v4 - - run: apt-get update && apt-get install -y unzip + # The action runs on this same image; src/post.ts needs Node 24.2+. + - run: node --version - - uses: denoland/setup-deno@v2 + - run: npm ci - - run: >- - deno fmt --check . && - deno lint . && - deno check src/prepare.ts src/run.ts src/post.ts && - deno test --allow-all + # Formatting, lint, and type checks, then every test. The job runs as + # root, so the isolation tests run rather than skip. + - run: npx vp check + + - run: npx vp test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25fbf5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +coverage/ diff --git a/AGENTS.md b/AGENTS.md index a301718..b00511e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,21 @@ comment `@bot` to request an AI-bot review. Wait for green CI and the bot's approval on the current head before merging. Never merge without the user's explicit permission. -Repository TypeScript and checks use Deno. The composite action uses npm only to -install the externally distributed Claude Code and Codex command-line tools; do -not treat those vendor executables as repository TypeScript dependencies. +# Runtime and checks + +The action's TypeScript runs directly on the job image's Node.js (24.2 or +newer), which strips the types itself: there is no build step and no runtime +dependency. Keep the sources to erasable syntax and `.ts` imports, which +`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and +type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as +`src/*.test.ts`; repository-level tests live in `tests/`. + +The only config files are `package.json`, `tsconfig.json`, and +`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule +list live in `scripts/project-lint.ts`. Lint disable comments are not allowed; +fix the source instead. + +The composite action uses npm only to install the externally distributed +Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that +check Deno-based repositories; do not treat those vendor executables as +repository TypeScript dependencies. diff --git a/action.yml b/action.yml index 2757ede..92fc054 100644 --- a/action.yml +++ b/action.yml @@ -15,7 +15,9 @@ runs: with: fetch-depth: 0 token: ${{ gitea.token }} - # This step assumes this is `node:24-bookworm` container. + # This step assumes this is `node:24-bookworm` container. The action runs + # its TypeScript on that Node directly; Deno stays for the agents, which + # still check Deno-based repositories with it. - name: Install dependencies shell: bash run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno @@ -34,7 +36,7 @@ runs: - name: Prepare credentials id: prepare shell: bash - run: deno run -A "${{ steps.source.outputs.path }}/src/prepare.ts" + run: node "${{ steps.source.outputs.path }}/src/prepare.ts" env: ACTION_PATH: ${{ steps.source.outputs.path }} BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }} @@ -42,7 +44,7 @@ runs: - name: Run bot id: agent shell: bash - run: deno run -A "${{ steps.source.outputs.path }}/src/run.ts" + run: node "${{ steps.source.outputs.path }}/src/run.ts" env: BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} GITEA_TOKEN: ${{ gitea.token }} @@ -55,7 +57,7 @@ runs: id: post if: always() && steps.prepare.outputs.manifest != '' shell: bash - run: deno run -A "${{ steps.source.outputs.path }}/src/post.ts" + run: node "${{ steps.source.outputs.path }}/src/post.ts" env: BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} GITEA_API_URL: ${{ gitea.api_url }} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6479904 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3083 @@ +{ + "name": "bot-agents", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bot-agents", + "devDependencies": { + "@eslint/core": "^0.17.0", + "@types/node": "^24.19.2", + "@typescript-eslint/types": "^8.71.2", + "vite-plus": "^1.2.0", + "yaml": "^2.9.1" + }, + "engines": { + "node": ">=24.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@blazediff/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.10.0.tgz", + "integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.154.0.tgz", + "integrity": "sha512-cBQNGoh659BNgvA8OC/e4BNDtFkv03dJaYdkp2adgOQNtISNAPQ+nB4S8XvWtNk7Xl+0g+7aa+iRGR25Xycd1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.73.0.tgz", + "integrity": "sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.73.0.tgz", + "integrity": "sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.73.0.tgz", + "integrity": "sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.73.0.tgz", + "integrity": "sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.73.0.tgz", + "integrity": "sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.73.0.tgz", + "integrity": "sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.73.0.tgz", + "integrity": "sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.73.0.tgz", + "integrity": "sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.73.0.tgz", + "integrity": "sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.73.0.tgz", + "integrity": "sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.73.0.tgz", + "integrity": "sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.73.0.tgz", + "integrity": "sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.73.0.tgz", + "integrity": "sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.73.0.tgz", + "integrity": "sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.73.0.tgz", + "integrity": "sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.73.0.tgz", + "integrity": "sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.73.0.tgz", + "integrity": "sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.73.0.tgz", + "integrity": "sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.73.0.tgz", + "integrity": "sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2004.tgz", + "integrity": "sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2004.tgz", + "integrity": "sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2004.tgz", + "integrity": "sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2004.tgz", + "integrity": "sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2004.tgz", + "integrity": "sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2004.tgz", + "integrity": "sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.88.0.tgz", + "integrity": "sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.88.0.tgz", + "integrity": "sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.88.0.tgz", + "integrity": "sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.88.0.tgz", + "integrity": "sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.88.0.tgz", + "integrity": "sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.88.0.tgz", + "integrity": "sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.88.0.tgz", + "integrity": "sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.88.0.tgz", + "integrity": "sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.88.0.tgz", + "integrity": "sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.88.0.tgz", + "integrity": "sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.88.0.tgz", + "integrity": "sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.88.0.tgz", + "integrity": "sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.88.0.tgz", + "integrity": "sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.88.0.tgz", + "integrity": "sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.88.0.tgz", + "integrity": "sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.88.0.tgz", + "integrity": "sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.88.0.tgz", + "integrity": "sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.88.0.tgz", + "integrity": "sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.88.0.tgz", + "integrity": "sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/plugins": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.88.0.tgz", + "integrity": "sha512-G2xbfWTrbqYRhR/vfeKNMQf/sS3/ti0iKJAAkRxHuXWZH/hne/dyIb9QKk5LuR6cJLARxsL6FMoAYmfspd74MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/browser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-5.0.4.tgz", + "integrity": "sha512-pRRkI2am/lmhGiaeHkVVLQb7SZyCOe5ytjVNQ0zLNZjA1xb+7aNTRqaaPeMJzWM958/5IrxJ50yTUpKSsigHMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.10.0", + "@vitest/mocker": "5.0.4", + "@vitest/ui": "5.0.4", + "@vitest/utils": "5.0.4", + "magic-string": "^1.4.3", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.2.0", + "ws": "^8.22.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.4" + } + }, + "node_modules/@vitest/browser-preview": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/browser-preview/-/browser-preview-5.0.4.tgz", + "integrity": "sha512-ltb2DIPoNS14XuyHD/bW2tt+T764eoy5IeQTWH2/2/6f6WgsyazPBmvoa6BstMiDm4VhE5CBy71wr6EhGhoymw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.2", + "@testing-library/user-event": "^14.6.7", + "@vitest/browser": "5.0.4" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.4" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-5.0.4.tgz", + "integrity": "sha512-kh/bp0oLr6w18kt4MFn5/zS+Hpc5k3bsf6S2m2Qksj7sKHrnc7Tu90UVYyiXnRvkYruN6kSCHQ3RR+SBE6FinA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-5.0.4.tgz", + "integrity": "sha512-d5bPylmU0je1vWdcXTj9NnYghZvFE/7jhYZZcW4TCa+f3aazYDN4szksKfuHqKjBm7NwtSEPpeY52Zn7lYuqTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "5.0.4", + "@vitest/utils": "5.0.4", + "magic-string": "^1.4.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-5.0.4.tgz", + "integrity": "sha512-L40ToOGwFgeZ+JVPMKHb7T0b671mqcmCpMUh9P68xq9YZ+TXM72rkDf9ljuv02e5Y1zj8ww0CnWIUnqs3FEWKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "5.0.4", + "fflate": "^0.8.3", + "flatted": "^3.4.4", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.4" + } + }, + "node_modules/@vitest/utils": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-5.0.4.tgz", + "integrity": "sha512-hgpnyMuMqCz2PARmk3v62FxUr8jHIkY13ygvUeh8s+dmQxfnvkB/sgtoZFzG4lMitnEdnrg/lCvDbtVzuAsGWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "5.0.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@voidzero-dev/vite-plus-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-hH6ZQC/Hpa3eCeV7ro7xUCEcFOOhiUjLXpcOnhA6r079wAY6aHkdKvaMNzHSXdT6PG54XHuOE6zxgu3OxS+7jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-1.2.0.tgz", + "integrity": "sha512-WCGWyp5NHU1a4WCL7YRkfE2LdPmFlA8jR8xlI1ryMC/K7TWs8XiS+P91H/3MrY41pl8tYm67zjR2h2rdft/gRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-KKs7LPCqKgvwcTg4BNybbZLbgOq75zTst4HxZEGmRZaaLE4uhjTFrQ1ERqNz4Nbpkl6AaEE/Iz3CY6EFk9bqJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-QHz70GOhlCsf1tf2Qi2CDjJAlxbmitj0AvJqmLl5fgaKvj3P4Thd9z3nsf4MTe5aGP26ehNpU+O9Sm19cxlplQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-PVW6srd6GW5ROMDQmxtNIR3vRcu44w3MYXBo2d15VhvHeskFKdTSIBHqXlnYxcE0adMtarT8JOmZXkD58+4zUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-rbNt1wudy3i+eVbVTSGPDvT3H8e7f2kouzzFAduV5m9boSTOgJjFzSqgUIN0oCtp7PpCUvC3h1Xewbor75uS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-fb0vSBUAhnHFxZCHqbZz2sOGQwAxt+eZ+DnSBeOndyE1HPja56mobruSgaH2WTw8d41KmGMUirFKIMaHwg0agQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-0aBgalqUdVV2yqZTImuDgu5kGcTCSDRPk9qkO+EOiWECo31SQRkKruD6nwdtr07hWbQ34v4VC1pG9G0fU1dwmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@yuku-core/binding-android-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-android-arm64/-/binding-android-arm64-0.19.0.tgz", + "integrity": "sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@yuku-core/binding-darwin-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-darwin-arm64/-/binding-darwin-arm64-0.19.0.tgz", + "integrity": "sha512-7sNJa6XJhyetnTy64jw3NEi4YgUcezHk1G5GH9FSvXAuC8wIlbaOwT9lgq5qo5sspjrZJQlBR1TWflP49uRpMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-core/binding-darwin-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-darwin-x64/-/binding-darwin-x64-0.19.0.tgz", + "integrity": "sha512-pTC7dYEc59isdyizc3nsuyFNBu2LU5wMYZ1be6UMr59PY/AJNtO1aBLWV0MgL/z7C1nv/2sX4TNjGgKLoeevJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-core/binding-freebsd-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-freebsd-x64/-/binding-freebsd-x64-0.19.0.tgz", + "integrity": "sha512-xrZjF6XOEwyLOiDIfrwDdRCS6ghxcJeirNC1KivQhVcUwlRonaU79S3/Uiyx3lBylPnT8qKJ3/aMT+H52nGTJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-core/binding-linux-arm-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.19.0.tgz", + "integrity": "sha512-0OyIH53C0I1CbADHn9FSdCfX5qbpeLwJhmOVhonFJNYheybOnkSJ50qGMfTJIb8y5zrwqvLs7FVBvz6Fk8maZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-arm-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm-musl/-/binding-linux-arm-musl-0.19.0.tgz", + "integrity": "sha512-z/iPfkCOCSXE34VXNt0uP1ORSW+4mEa9b5CiJcWZeqZH08g49bFU/jRM2T04f+Wpw5mJ6HIzNBiojxeAbrHjeg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-arm64-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.19.0.tgz", + "integrity": "sha512-jLNTi+gzaRwBJ+gwhCJ+rDb5dmx/8hEBx93UWd7hjMSqPTL1D1wvVSvXVAldV5kALvju7b0/m51EHEhs4+I1fA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-arm64-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.19.0.tgz", + "integrity": "sha512-YwyPIdW/rN2Fmv0MTFWi47FaB3F9g+2b+sJISyAw83Yd60RWH4T3Nx6XkAHHVN+uKJNBAd6So7/BVihSiFYvuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-x64-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.19.0.tgz", + "integrity": "sha512-/vskaAXVX3ESR43FiTdfiYS7z1b9uoQJwVQ/0RgUHgN2sWoAyCZs+aX4CgU7eKGIHTVfp9VFwv+c+2XchYiEtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-x64-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-x64-musl/-/binding-linux-x64-musl-0.19.0.tgz", + "integrity": "sha512-FuXL5igeSKib9Rye02TdH+D6SQezTyGxIATMu1Qsn/QywrzwpGl35OLpS2C1x5vpGLeVE2XgiLd5mVPFHmIglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-win32-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-win32-arm64/-/binding-win32-arm64-0.19.0.tgz", + "integrity": "sha512-YuJAe7zEKT1Xa7LuHlhGJpPnYQeVtvo0rZvNjJRqkH0hQGOw4C8QeP30Tg5VKGzZfo2x8CjhjEhKt4TmLwfmdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-core/binding-win32-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-win32-x64/-/binding-win32-x64-0.19.0.tgz", + "integrity": "sha512-o8iY/mXwlRkedp0LNTr4+TEfXTPQwoNZmv6k+1WIAd1ZXEn8xJJTBCtB0osaVvdZo/x/cNmV16UrWrrGpM5Mvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-toolchain/types": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.19.0.tgz", + "integrity": "sha512-tFkNbsP4WXX9UMUC5syJGdK9dPMLK0PPaIuXCcCYH75sZ8MrebfBZ2XvCILRTw8AgLivSSCfDa4R67iYBDa4Kg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxfmt": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.73.0.tgz", + "integrity": "sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.73.0", + "@oxfmt/binding-android-arm64": "0.73.0", + "@oxfmt/binding-darwin-arm64": "0.73.0", + "@oxfmt/binding-darwin-x64": "0.73.0", + "@oxfmt/binding-freebsd-x64": "0.73.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.73.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.73.0", + "@oxfmt/binding-linux-arm64-gnu": "0.73.0", + "@oxfmt/binding-linux-arm64-musl": "0.73.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.73.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.73.0", + "@oxfmt/binding-linux-riscv64-musl": "0.73.0", + "@oxfmt/binding-linux-s390x-gnu": "0.73.0", + "@oxfmt/binding-linux-x64-gnu": "0.73.0", + "@oxfmt/binding-linux-x64-musl": "0.73.0", + "@oxfmt/binding-openharmony-arm64": "0.73.0", + "@oxfmt/binding-win32-arm64-msvc": "0.73.0", + "@oxfmt/binding-win32-ia32-msvc": "0.73.0", + "@oxfmt/binding-win32-x64-msvc": "0.73.0" + }, + "peerDependencies": { + "@astrojs/compiler-rs": "^0.4.0 || ^0.5.0", + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "@astrojs/compiler-rs": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.88.0.tgz", + "integrity": "sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.88.0", + "@oxlint/binding-android-arm64": "1.88.0", + "@oxlint/binding-darwin-arm64": "1.88.0", + "@oxlint/binding-darwin-x64": "1.88.0", + "@oxlint/binding-freebsd-x64": "1.88.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.88.0", + "@oxlint/binding-linux-arm-musleabihf": "1.88.0", + "@oxlint/binding-linux-arm64-gnu": "1.88.0", + "@oxlint/binding-linux-arm64-musl": "1.88.0", + "@oxlint/binding-linux-ppc64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-musl": "1.88.0", + "@oxlint/binding-linux-s390x-gnu": "1.88.0", + "@oxlint/binding-linux-x64-gnu": "1.88.0", + "@oxlint/binding-linux-x64-musl": "1.88.0", + "@oxlint/binding-openharmony-arm64": "1.88.0", + "@oxlint/binding-win32-arm64-msvc": "1.88.0", + "@oxlint/binding-win32-ia32-msvc": "1.88.0", + "@oxlint/binding-win32-x64-msvc": "1.88.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint-tsgolint": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2004.tgz", + "integrity": "sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==", + "dev": true, + "license": "MIT", + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2004", + "@oxlint-tsgolint/darwin-x64": "7.0.2004", + "@oxlint-tsgolint/linux-arm64": "7.0.2004", + "@oxlint-tsgolint/linux-x64": "7.0.2004", + "@oxlint-tsgolint/win32-arm64": "7.0.2004", + "@oxlint-tsgolint/win32-x64": "7.0.2004" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plus": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-1.2.0.tgz", + "integrity": "sha512-u4INxnrq9LxwyA3nr33Qg6VltJiKz+ILSlZuZnQJecozUvj1/rVctkw6Igh/Xkd0En2rst7m0xE9sG7uzzZS7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@oxlint/plugins": "=1.88.0", + "@vitest/browser": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/mocker": "5.0.4", + "@vitest/pretty-format": "5.0.4", + "@vitest/snapshot": "5.0.4", + "@vitest/spy": "5.0.4", + "@vitest/utils": "5.0.4", + "oxfmt": "=0.73.0", + "oxlint": "=1.88.0", + "oxlint-tsgolint": "=7.0.2004", + "vite": "npm:@voidzero-dev/vite-plus-core@1.2.0", + "vitest": "5.0.4" + }, + "bin": { + "vp": "bin/vp", + "vpr": "bin/vpr" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "optionalDependencies": { + "@voidzero-dev/vite-plus-darwin-arm64": "1.2.0", + "@voidzero-dev/vite-plus-darwin-x64": "1.2.0", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "1.2.0", + "@voidzero-dev/vite-plus-linux-arm64-musl": "1.2.0", + "@voidzero-dev/vite-plus-linux-x64-gnu": "1.2.0", + "@voidzero-dev/vite-plus-linux-x64-musl": "1.2.0", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "1.2.0", + "@voidzero-dev/vite-plus-win32-x64-msvc": "1.2.0" + }, + "peerDependencies": { + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "playwright": "*" + }, + "peerDependenciesMeta": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "playwright": { + "optional": true + } + } + }, + "node_modules/vite-plus/node_modules/vite": { + "name": "@voidzero-dev/vite-plus-core", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-1.2.0.tgz", + "integrity": "sha512-SWTD9Q73hNpm07LLgYFcTLlPiOoOWtmpWxarclTHNVf6eKfdt0Saz2keDNpHPsJXKOjXREcGXozbdKc4J31sZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "=0.154.0", + "@oxc-project/types": "=0.154.0", + "lightningcss": "^1.33.0", + "postcss": "^8.5.29", + "yuku-parser": "0.19.0" + }, + "engines": { + "node": "^20.19.0 || ^22.18.0 || >=24.11.0" + }, + "optionalDependencies": { + "@voidzero-dev/vite-plus-darwin-arm64": "1.2.0", + "@voidzero-dev/vite-plus-darwin-x64": "1.2.0", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "1.2.0", + "@voidzero-dev/vite-plus-linux-arm64-musl": "1.2.0", + "@voidzero-dev/vite-plus-linux-x64-gnu": "1.2.0", + "@voidzero-dev/vite-plus-linux-x64-musl": "1.2.0", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "1.2.0", + "@voidzero-dev/vite-plus-win32-x64-msvc": "1.2.0", + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@types/node": "^20.19.0 || >=22.12.0", + "@typescript/native-preview": "*", + "@vitejs/devtools": "^0.7.1", + "@volar/typescript": "~2.4.0", + "@vue/language-core": "~3.2.0 || ~3.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "publint": "^0.3.8", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": ">=0.5.0", + "unrun": "*", + "vue-tsc": "~3.2.0 || ~3.3.0", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@typescript/native-preview": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "@volar/typescript": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "publint": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + }, + "vue-tsc": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yuku-core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-core/-/yuku-core-0.19.0.tgz", + "integrity": "sha512-kYK9e1XdfirNhkipzW0wmMcSWcVF/CWnuugodfVYRCUwvoBefweL9HzB96OiKEhsx78V5WbUxbJqlizMX869Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.19.0" + }, + "engines": { + "node": ">=14.18.0" + }, + "optionalDependencies": { + "@yuku-core/binding-android-arm64": "0.19.0", + "@yuku-core/binding-darwin-arm64": "0.19.0", + "@yuku-core/binding-darwin-x64": "0.19.0", + "@yuku-core/binding-freebsd-x64": "0.19.0", + "@yuku-core/binding-linux-arm-gnu": "0.19.0", + "@yuku-core/binding-linux-arm-musl": "0.19.0", + "@yuku-core/binding-linux-arm64-gnu": "0.19.0", + "@yuku-core/binding-linux-arm64-musl": "0.19.0", + "@yuku-core/binding-linux-x64-gnu": "0.19.0", + "@yuku-core/binding-linux-x64-musl": "0.19.0", + "@yuku-core/binding-win32-arm64": "0.19.0", + "@yuku-core/binding-win32-x64": "0.19.0" + } + }, + "node_modules/yuku-parser": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.19.0.tgz", + "integrity": "sha512-XrBh9ZiQK5mJVRxZBAvD6LwRyAYrK3MFtH3+onyuxFL5hFD44EC3QOmIC1FjTj6H2MMwq46/NAI+NZxLZPZgxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.19.0", + "yuku-core": "0.19.0" + }, + "engines": { + "node": ">=14.18.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4d78189 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "bot-agents", + "description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.", + "private": true, + "type": "module", + "engines": { + "node": ">=24.2" + }, + "devDependencies": { + "@eslint/core": "^0.17.0", + "@types/node": "^24.19.2", + "@typescript-eslint/types": "^8.71.2", + "vite-plus": "^1.2.0", + "yaml": "^2.9.1" + } +} diff --git a/profiles.json b/profiles.json new file mode 100644 index 0000000..f7ef3a0 --- /dev/null +++ b/profiles.json @@ -0,0 +1,19 @@ +{ + "reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY", + "profiles": [ + { + "type": "codex", + "model": "gpt-5.6-terra" + }, + { + "type": "claude", + "model": "claude-sonnet-5", + "token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg" + }, + { + "type": "opencode", + "model": "openrouter/qwen/qwen3.8-27b", + "token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0" + } + ] +} diff --git a/profiles.yml b/profiles.yml deleted file mode 100644 index 57d8e46..0000000 --- a/profiles.yml +++ /dev/null @@ -1,10 +0,0 @@ -reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY -profiles: - - type: codex - model: gpt-5.6-terra - - type: claude - model: claude-sonnet-5 - token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg - - type: opencode - model: openrouter/qwen/qwen3.8-27b - token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0 diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts new file mode 100644 index 0000000..a62efc2 --- /dev/null +++ b/scripts/project-lint.ts @@ -0,0 +1,232 @@ +/** + * `project` — lint rules this repository holds its own source to. Deno lint + * ran here with its `recommended` rules and no plugins; the Oxlint categories + * in `vite.config.ts` plus the rule list below keep at least that coverage. + * Its rules of its own are `no-disable-comments` and `no-non-ts-imports`. + * + * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. + * + * @module + */ + +import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; +import type { TSESTree } from "@typescript-eslint/types"; +import type { DummyRuleMap } from "vite-plus/lint"; + +/** The source code both hosts hand a rule, with the comment list. */ +interface ProjectSourceCode extends TextSourceCode { + getAllComments(): TSESTree.Comment[]; +} + +/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */ +interface ProjectRuleTypes { + LangOptions: LanguageOptions; + Code: ProjectSourceCode; + RuleOptions: []; + Visitor: RuleVisitor; + Node: TSESTree.Node; + MessageIds: string; + ExtRuleDocs: unknown; +} + +/** A repo-local rule, in ESLint's standard `create` form. */ +type ProjectRule = RuleDefinition; + +/** A comment that turns lint rules off or back on, in ESLint or Oxlint form. */ +const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u; + +/** This rule's own name, which a directive may not use to silence it. */ +const RULE_NAME: string = "no-disable-comments"; + +/** + * `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`, + * `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has + * no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them + * itself: when a rule fires, fix the source. + * + * A directive can silence the report on itself, so two built-in checks close + * the gaps: `unicorn/no-abusive-eslint-disable` fails one that names no rule, + * and this rule skips one that names it, which then silences nothing and fails + * `reportUnusedDisableDirectives` in `vite.config.ts`. + */ +const noDisableCommentsRule: ProjectRule = { + meta: { + type: "problem", + docs: { description: "Disallow lint disable and enable comments" }, + schema: [], + messages: { directive: "Lint disable comments are not allowed; fix the source." }, + }, + create(context) { + return { + Program(_node: TSESTree.Program): void { + for (const comment of context.sourceCode.getAllComments()) { + if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) { + context.report({ loc: comment.loc, messageId: "directive" }); + } + } + }, + }; + }, +}; + +/** A module specifier read as a path: relative, absolute, or a `#` alias. */ +const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u; + +/** + * Whether `source` names something other than a `.ts` module: a query string + * such as `?raw`, or a final path segment with any other extension. Bare + * package names are left alone, dots and all, as are extensionless paths. + */ +function isNonTsSource(source: string): boolean { + if (source.includes("?")) { + return true; + } + const segments: string[] = source.split("/"); + if (!PATH_SPECIFIER_PATTERN.test(source)) { + const nameLength: number = source.startsWith("@") ? 2 : 1; + if (segments.length <= nameLength) { + return false; + } + } + const lastSegment: string = segments.at(-1) ?? ""; + if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) { + return false; + } + return !lastSegment.endsWith(".ts"); +} + +/** + * `no-non-ts-imports` — rejects importing anything but a `.ts` module: a + * source path, static or a dynamic `import()` with a string literal, ending in + * another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a + * query string (`?raw`, `?url`), and any import or export with import + * attributes. Bare package names, `#` aliases, and `node:` builtins without an + * extension pass; package subpaths are checked like paths. + */ +const noNonTsImportsRule: ProjectRule = { + meta: { + type: "problem", + docs: { description: "Disallow importing modules other than .ts files" }, + schema: [], + messages: { + extension: "Only .ts modules may be imported.", + attributes: "Import attributes are not allowed.", + }, + }, + create(context) { + function checkSource(source: TSESTree.StringLiteral): void { + if (isNonTsSource(source.value)) { + context.report({ node: source, messageId: "extension" }); + } + } + function checkDeclaration( + node: + | TSESTree.ImportDeclaration + | TSESTree.ExportNamedDeclaration + | TSESTree.ExportAllDeclaration, + ): void { + if (node.source === null) { + return; + } + checkSource(node.source); + if (node.attributes.length > 0) { + context.report({ node, messageId: "attributes" }); + } + } + return { + ImportDeclaration: checkDeclaration, + ExportNamedDeclaration: checkDeclaration, + ExportAllDeclaration: checkDeclaration, + ImportExpression(node: TSESTree.ImportExpression): void { + if (node.source.type === "Literal" && typeof node.source.value === "string") { + checkSource(node.source); + } + if (node.options !== null) { + context.report({ node, messageId: "attributes" }); + } + }, + }; + }, +}; + +/** + * This repository's own rule list, spread by `vite.config.ts`: Deno's + * `recommended` rules that sit outside the Oxlint categories, plus the + * template's extras. Built-in Oxlint rule ids sit here too, so + * `vite.config.ts` lists no rule by name. + */ +const rules: DummyRuleMap = { + // The explicit-types template's `camelcase` stand-in: no `_` inside a name + // once leading and trailing ones are trimmed, unless it has no lowercase + // ASCII letter (UPPER_SNAKE). + "id-match": [ + "error", + "^_*[^_]*_*$|^[^a-z]*$", + { properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, + ], + // Deno's `recommended` rules outside the categories. + "no-array-constructor": "error", + "no-case-declarations": "error", + "no-empty": "error", + "no-fallthrough": "error", + "no-inner-declarations": "error", + "no-prototype-builtins": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-var": "error", + "prefer-const": "error", + "require-await": "error", + "require-yield": "error", + "typescript/adjacent-overload-signatures": "error", + "typescript/ban-ts-comment": "error", + "typescript/no-empty-interface": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-explicit-any": "error", + "typescript/no-namespace": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/triple-slash-reference": "error", + // Deno's `no-window`, `no-process-global`, and `no-node-globals`: on Node, + // these come from an explicit `node:` import instead of the global scope. + "no-restricted-globals": [ + "error", + "window", + "process", + "Buffer", + "global", + "setImmediate", + "clearImmediate", + ], + // The explicit-types template's extras. + "import/no-cycle": "error", + "typescript/consistent-type-imports": "error", + "typescript/no-import-type-side-effects": "error", + "typescript/no-non-null-assertion": "error", + // Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no + // disable comments at all. + "project/no-disable-comments": "error", + "project/no-non-ts-imports": "error", + "unicorn/no-abusive-eslint-disable": "error", + // Pedantic, but Node's and the web's own types (`Response`, `Stats`, + // `RequestInit`) are mutable, so no parameter of theirs can pass; the + // explicit-types template and the app repos turn it off too. + "typescript/prefer-readonly-parameter-types": "off", +}; + +/** + * The repo-local plugin, namespace `project`: its own rules, enabled as + * `project/`, and `configs.project`, the rule list above. + */ +const plugin = { + meta: { name: "project" }, + rules: { + "no-disable-comments": noDisableCommentsRule, + "no-non-ts-imports": noNonTsImportsRule, + }, + configs: { project: { rules } }, +}; + +export default plugin; diff --git a/scripts/protect-source.sh b/scripts/protect-source.sh index f187f69..5208c2d 100755 --- a/scripts/protect-source.sh +++ b/scripts/protect-source.sh @@ -4,6 +4,7 @@ set -euo pipefail path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX") mkdir "${path}/src" cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/" -cp -a "${ACTION_PATH}/profiles.yml" "${ACTION_PATH}/prompt.md" "${path}/" +# package.json makes Node load the TypeScript sources as ES modules. +cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/" chmod -R go-w "${path}" printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}" diff --git a/src/agent-claude.ts b/src/agent-claude.ts new file mode 100644 index 0000000..b56acb1 --- /dev/null +++ b/src/agent-claude.ts @@ -0,0 +1,119 @@ +// Runs Claude Code for one profile and returns its final result text. +import { createInterface } from "node:readline"; +import type { Readable } from "node:stream"; + +import { isClaudeAuthFailure } from "./auth.ts"; +import type { ClaudeFailureFields } from "./auth.ts"; +import { chunks } from "./command.ts"; +import { agentCommand } from "./isolation.ts"; +import { isObject } from "./json.ts"; +import type { JsonObject } from "./json.ts"; +import { AgentAuthError, agentEnvironment } from "./profiles.ts"; +import { PROCESS_ENV, WORKSPACE } from "./run-context.ts"; +import type { RuntimeProfile } from "./runtime.ts"; + +// Claude Code's stream-json `result` event, in its own field names. +type ClaudeResult = ClaudeFailureFields & + Partial> & { + subtype: string; + }; + +// Echoes stderr as it arrives and resolves with all of it. +async function copyError(stream: Readable): Promise { + const decoder = new TextDecoder(); + let error = ""; + for await (const chunk of chunks(stream)) { + const text = decoder.decode(chunk, { stream: true }); + error += text; + console.error(text); + } + return error + decoder.decode(); +} + +// Prints the visible text of each streamed message part. +function printParts(event: JsonObject): void { + const content = isObject(event.message) ? event.message.content : undefined; + for (const part of Array.isArray(content) ? content : []) { + if (isObject(part)) { + const text = part.thinking ?? part.text ?? part.name; + if (typeof text === "string" && text !== "") { + console.log(text); + } + } + } +} + +function toResult(event: JsonObject): ClaudeResult { + const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event; + return { + ...(typeof isError === "boolean" && { is_error: isError }), + ...(typeof result === "string" && { result }), + subtype: String(subtype), + ...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && { + api_error_status: apiErrorStatus, + }), + }; +} + +// Prints each streamed message part and returns the final `result` event. +async function readEvents(stream: Readable): Promise { + let result: ClaudeResult | undefined; + for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) { + if (line !== "") { + const event: unknown = JSON.parse(line); + if (isObject(event)) { + printParts(event); + if (event.type === "result") { + result = toResult(event); + } + } + } + } + return result; +} + +function checkResult(result: ClaudeResult | undefined, error: string): string { + if (result?.is_error === true) { + const failure = new Error(result.result ?? `claude ended with ${result.subtype}`); + if (isClaudeAuthFailure(result)) { + throw new AgentAuthError("claude sign-in failed", { cause: failure }); + } + throw failure; + } + if (result?.result === undefined) { + throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`); + } + return result.result; +} + +export async function runClaude( + profile: RuntimeProfile, + token: string, + prompt: string, +): Promise { + const args = [ + "--print", + "--dangerously-skip-permissions", + "--model", + profile.model, + "--output-format", + "stream-json", + "--verbose", + prompt, + ]; + const claude = agentCommand(profile, "claude", args, { + cwd: WORKSPACE, + env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" }, + clearEnv: true, + stdout: "piped", + stderr: "piped", + }).spawn(); + const errorText = copyError(claude.stderr); + const result = await readEvents(claude.stdout); + const [status, error] = await Promise.all([claude.status, errorText]); + const body = checkResult(result, error); + if (!status.success) { + throw new Error(error.trim() || `claude exited with ${status.code}`); + } + return body; +} diff --git a/src/agent-codex.ts b/src/agent-codex.ts new file mode 100644 index 0000000..495d741 --- /dev/null +++ b/src/agent-codex.ts @@ -0,0 +1,174 @@ +// Signs Codex in for one profile, runs it, and returns its result file path. +import process from "node:process"; +import type { Readable } from "node:stream"; +import { text } from "node:stream/consumers"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts"; +import type { CodexAuthentication } from "./auth.ts"; +import { chunks, endWith, writeChunk } from "./command.ts"; +import type { CommandStatus } from "./command.ts"; +import { agentCommand } from "./isolation.ts"; +import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts"; +import { + makeProfileFile, + postAuthorComment, + PROCESS_ENV, + runAgentCommand, + WORKSPACE, +} from "./run-context.ts"; +import type { RuntimeProfile } from "./runtime.ts"; + +interface CodexAttempt { + status: CommandStatus; + error: string; +} + +async function collectText(stream: Readable, append: (chunk: string) => void): Promise { + const decoder = new TextDecoder(); + for await (const chunk of chunks(stream)) { + append(decoder.decode(chunk, { stream: true })); + } + append(decoder.decode()); +} + +// Polls every 100 ms until the login prints something, failing if it exits first. +async function pollForInstructions( + shownText: () => string, + status: Promise, +): Promise { + if (shownText().trim() !== "") { + return; + } + const exited = await Promise.race([status.then(() => true), sleep(100, false)]); + if (exited) { + throw new AgentAuthError("AI bot login produced no instructions"); + } + await pollForInstructions(shownText, status); +} + +async function codexDeviceLogin( + profile: RuntimeProfile, + environment: Record, +): Promise { + const login = agentCommand(profile, "codex", ["login", "--device-auth"], { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + stdout: "piped", + stderr: "piped", + }).spawn(); + let shown = ""; + const append = (chunk: string): void => { + shown += chunk; + }; + const drained = Promise.all([ + collectText(login.stdout, append), + collectText(login.stderr, append), + ]); + await sleep(3000); + await pollForInstructions(() => shown, login.status); + await postAuthorComment(withProfile(shown, profile)); + const [result] = await Promise.all([login.status, drained]); + if (!result.success) { + throw new AgentAuthError(`codex login exited with ${result.code}`); + } +} + +async function codexApiLogin( + profile: RuntimeProfile, + environment: Record, + token: string, +): Promise { + const login = agentCommand(profile, "codex", ["login", "--with-api-key"], { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + stdin: "piped", + stderr: "piped", + }).spawn(); + await endWith(login.stdin, `${token}\n`); + const [status, error] = await Promise.all([login.status, text(login.stderr)]); + if (!status.success) { + throw new Error(error.trim() || `codex login exited with ${status.code}`); + } +} + +async function signIn( + profile: RuntimeProfile, + environment: Record, + authentication: CodexAuthentication, +): Promise { + if (authentication.kind === "api-key") { + await codexApiLogin(profile, environment, authentication.token); + return; + } + const loggedIn = await agentCommand(profile, "codex", ["login", "status"], { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + }).output(); + if (!loggedIn.success) { + await codexDeviceLogin(profile, environment); + } +} + +async function execCodex( + profile: RuntimeProfile, + environment: Record, + prompt: string, + outputPath: string, +): Promise { + const args = [ + "exec", + "--model", + profile.model, + "--dangerously-bypass-approvals-and-sandbox", + "--output-last-message", + outputPath, + prompt, + ]; + const codex = agentCommand(profile, "codex", args, { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + stdout: "inherit", + stderr: "piped", + }).spawn(); + const decoder = new TextDecoder(); + let error = ""; + for await (const chunk of chunks(codex.stderr)) { + await writeChunk(process.stderr, chunk); + error += decoder.decode(chunk, { stream: true }); + } + error += decoder.decode(); + // Codex echoes the prompt, whose comments may quote auth errors. + return { status: await codex.status, error: error.replaceAll(prompt, "") }; +} + +export async function runCodex( + profile: RuntimeProfile, + prompt: string, + authentication: CodexAuthentication, +): Promise { + const token = authentication.kind === "api-key" ? authentication.token : undefined; + const environment = agentEnvironment(PROCESS_ENV, profile, token); + await signIn(profile, environment, authentication); + const outputPath = await makeProfileFile(profile, "result.md"); + const attempt = (): Promise => execCodex(profile, environment, prompt, outputPath); + const result = + authentication.kind === "api-key" + ? await attempt() + : await retryInvalidToken(attempt, async () => { + await runAgentCommand(profile, "codex", ["logout"]); + await codexDeviceLogin(profile, environment); + }); + if (!result.status.success) { + const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`); + if (isCodexAuthFailure(result.error)) { + throw new AgentAuthError("codex sign-in failed", { cause: failure }); + } + throw failure; + } + return outputPath; +} diff --git a/src/agent-opencode.ts b/src/agent-opencode.ts new file mode 100644 index 0000000..c21ff90 --- /dev/null +++ b/src/agent-opencode.ts @@ -0,0 +1,46 @@ +// Runs OpenCode for one profile and returns its final visible text. +import process from "node:process"; + +import { chunks, writeChunk } from "./command.ts"; +import { agentCommand } from "./isolation.ts"; +import { + collectOpenCodeOutput, + openCodeArgs, + openCodeError, + parseOpenCodeEvents, +} from "./opencode.ts"; +import { agentEnvironment } from "./profiles.ts"; +import { PROCESS_ENV, WORKSPACE } from "./run-context.ts"; +import type { RuntimeProfile } from "./runtime.ts"; + +export async function runOpenCode( + profile: RuntimeProfile, + token: string, + prompt: string, +): Promise { + const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), { + cwd: WORKSPACE, + env: agentEnvironment(PROCESS_ENV, profile, token), + clearEnv: true, + stdout: "piped", + stderr: "piped", + }).spawn(); + const collected = collectOpenCodeOutput( + chunks(child.stdout), + chunks(child.stderr), + async (chunk) => { + await writeChunk(process.stdout, chunk); + }, + async (chunk) => { + await writeChunk(process.stderr, chunk); + }, + ); + const [status, streams] = await Promise.all([child.status, collected]); + const error = streams.stderr.trim(); + if (!status.success) { + throw ( + openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`) + ); + } + return parseOpenCodeEvents(streams.stdout); +} diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..44f1101 --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,30 @@ +// Dispatches one attempt to the agent CLI its profile names. +import { runClaude } from "./agent-claude.ts"; +import { runCodex } from "./agent-codex.ts"; +import { runOpenCode } from "./agent-opencode.ts"; +import { codexAuthentication } from "./auth.ts"; +import type { CodexAuthentication } from "./auth.ts"; +import type { RuntimeProfile } from "./runtime.ts"; + +export interface AgentAnswer { + body?: string; + resultPath?: string; +} + +// Runs the selected agent; Codex leaves its answer in a file, the others return it. +export async function runAgent( + profile: RuntimeProfile, + token: string | undefined, + prompt: string, + authentication: CodexAuthentication | null, +): Promise { + if (profile.type === "claude") { + return { body: await runClaude(profile, token ?? "", prompt) }; + } + if (profile.type === "codex") { + return { + resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)), + }; + } + return { body: await runOpenCode(profile, token ?? "", prompt) }; +} diff --git a/src/auth_test.ts b/src/auth.test.ts similarity index 55% rename from src/auth_test.ts rename to src/auth.test.ts index 8d19322..67432ac 100644 --- a/src/auth_test.ts +++ b/src/auth.test.ts @@ -1,4 +1,6 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@1"; +import { expect, test } from "vite-plus/test"; +import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; + import { codexAuthentication, isClaudeAuthFailure, @@ -7,8 +9,9 @@ import { retryInvalidToken, stageCodexAuth, } from "./auth.ts"; +import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts"; -Deno.test("retries once after an invalid refresh token", async () => { +test("retries once after an invalid refresh token", async () => { let attempts = 0; let relogins = 0; const result = await retryInvalidToken( @@ -23,17 +26,14 @@ Deno.test("retries once after an invalid refresh token", async () => { }, ); - assertEquals( - { attempts, relogins, error: result.error }, - { - attempts: 2, - relogins: 1, - error: "still failed", - }, - ); + expect({ attempts, relogins, error: result.error }).toStrictEqual({ + attempts: 2, + relogins: 1, + error: "still failed", + }); }); -Deno.test("does not retry an unrelated failure", async () => { +test("does not retry an unrelated failure", async () => { let attempts = 0; let relogins = 0; await retryInvalidToken( @@ -50,7 +50,7 @@ Deno.test("does not retry an unrelated failure", async () => { }, ); - assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 }); + expect({ attempts, relogins }).toStrictEqual({ attempts: 1, relogins: 0 }); }); // Verbatim stderr lines from the revoked-login job on lead/memona-home#36. @@ -61,27 +61,26 @@ const REVOKED_CODEX = [ "ERROR: workspace routing discovery unauthorized (401)", ]; -Deno.test("recognizes each Codex sign-in failure line", () => { +test("recognizes each Codex sign-in failure line", () => { for (const line of [ ...REVOKED_CODEX, `2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`, `ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`, "ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key", ]) { - assertEquals(isCodexAuthFailure(`exec\n${line}\n`), true, line); + expect(isCodexAuthFailure(`exec\n${line}\n`), line).toBe(true); } }); -Deno.test("ignores auth words outside Codex error lines", () => { - assertEquals( +test("ignores auth words outside Codex error lines", () => { + expect( isCodexAuthFailure( - "exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" + "ERROR: tests failed\n", + "exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\nERROR: tests failed\n", ), - false, - ); + ).toBe(false); }); -Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => { +test("re-logs in after the job 9837 revoked-session stderr", async () => { let relogins = 0; let attempts = 0; const result = await retryInvalidToken( @@ -96,91 +95,84 @@ Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => { }, ); - assertEquals( - { relogins, success: result.status.success }, - { - relogins: 1, - success: true, - }, - ); + expect({ relogins, success: result.status.success }).toStrictEqual({ + relogins: 1, + success: true, + }); }); -Deno.test("recognizes a rejected Claude OAuth token", () => { - assertEquals( +test("recognizes a rejected Claude OAuth token", () => { + expect( isClaudeAuthFailure({ api_error_status: 401, result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.", }), - true, - ); - assertEquals( + ).toBe(true); + expect( isClaudeAuthFailure({ result: "OAuth token has expired. Please obtain a new token or refresh your existing token.", }), - true, - ); - assertEquals(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }), false); + ).toBe(true); + expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false); }); -Deno.test("selects API-key login only for a configured Codex token", () => { - assertEquals(codexAuthentication("selected-key"), { +test("selects API-key login only for a configured Codex token", () => { + expect(codexAuthentication("selected-key")).toStrictEqual({ kind: "api-key", token: "selected-key", }); - assertEquals(codexAuthentication(undefined), { kind: "persistent" }); + expect(codexAuthentication()).toStrictEqual({ kind: "persistent" }); }); -const AUTH = JSON.stringify({ - auth_mode: "chatgpt", - tokens: { - access_token: "access", - refresh_token: "refresh", - id_token: "id", - account_id: "account", - }, -}); +const AUTH_TOKENS = { + access_token: "access", + refresh_token: "refresh", + id_token: "id", + account_id: "account", +}; +const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS }); -Deno.test("stages and persists only a regular private Codex login", async () => { - if (Deno.build.os === "windows") return; - const root = await Deno.makeTempDir(); +test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => { + const root = await makeTempDir(); try { const canonical = `${root}/persistent/auth.json`; - await Deno.mkdir(`${root}/persistent`); - await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 }); + await mkdir(`${root}/persistent`); + await writeFile(canonical, AUTH, { mode: 0o600 }); const home = `${root}/profile`; - await Deno.mkdir(home); + await mkdir(home); - const staged = await stageCodexAuth(canonical, home, Deno.uid()!, Deno.gid()!); - assertEquals(await Deno.readTextFile(staged), AUTH); - assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600); + const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid()); + expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); + expect((await stat(staged)).mode & 0o777).toBe(0o600); const refreshed = JSON.stringify({ - ...JSON.parse(AUTH), - tokens: { ...JSON.parse(AUTH).tokens, access_token: "refreshed" }, + auth_mode: "chatgpt", + tokens: { ...AUTH_TOKENS, access_token: "refreshed" }, }); - await Deno.writeTextFile(staged, refreshed); + await writeFile(staged, refreshed); await persistCodexAuth(staged, canonical); - assertEquals(await Deno.readTextFile(canonical), refreshed); - assertEquals((await Deno.stat(canonical)).mode! & 0o777, 0o600); + expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); + expect((await stat(canonical)).mode & 0o777).toBe(0o600); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); -Deno.test("rejects a symlink or malformed Codex login", async () => { - if (Deno.build.os === "windows") return; - const root = await Deno.makeTempDir(); +test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => { + const root = await makeTempDir(); try { const canonical = `${root}/auth.json`; const malformed = `${root}/malformed.json`; const linked = `${root}/linked.json`; - await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 }); - await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 }); - await Deno.symlink(canonical, linked); + await writeFile(canonical, AUTH, { mode: 0o600 }); + await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); + await symlink(canonical, linked); - await assertRejects(() => persistCodexAuth(linked, canonical), Error, "regular file"); - await assertRejects(() => persistCodexAuth(malformed, canonical), Error, "invalid Codex login"); + await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); + await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow( + "invalid Codex login", + ); } finally { - await Deno.remove(root, { recursive: true }); + await rm(root, { recursive: true }); } }); diff --git a/src/auth.ts b/src/auth.ts index f9e8648..b57c645 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,19 @@ -import { dirname, join } from "jsr:@std/path@1"; +import { randomUUID } from "node:crypto"; +import { + chmod, + chown, + lstat, + mkdir, + open, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { isNotFound } from "./errors.ts"; +import { isObject } from "./json.ts"; export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" }; @@ -9,7 +24,7 @@ export function codexAuthentication(token?: string): CodexAuthentication { // Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys // on its own ERROR lines; tool output and the echoed prompt must not count. const CODEX_AUTH_FAILURE = - /^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/im; + /^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/imu; export function isCodexAuthFailure(error: string): boolean { return CODEX_AUTH_FAILURE.test(error); @@ -18,13 +33,16 @@ export function isCodexAuthFailure(error: string): boolean { // Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error // stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth // access token is invalid." with api_error_status 401. -export function isClaudeAuthFailure(result: { - api_error_status?: number | null; +// The fields of Claude Code's stream-json `result` event that tell a sign-in +// failure apart, under their own names. +export type ClaudeFailureFields = Partial> & { result?: string; -}): boolean { +}; + +export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean { return ( result.api_error_status === 401 || - /failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/i.test( + /failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test( result.result ?? "", ) ); @@ -42,33 +60,35 @@ export async function retryInvalidToken typeof tokens[name] === "string" && tokens[name] !== "", + ) + ); +} + async function readCodexAuth(path: string): Promise { - const stat = await Deno.lstat(path); - if (!stat.isFile || stat.isSymlink) { + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink()) { throw new Error("Codex login must be a regular file"); } - if ((stat.mode! & 0o077) !== 0) { + if ((stat.mode & 0o077) !== 0) { throw new Error("Codex login must be private"); } - const text = await Deno.readTextFile(path); + const text = await readFile(path, "utf8"); let value: unknown; try { value = JSON.parse(text); } catch { throw new Error("invalid Codex login"); } - const record = value as Record | null; - const tokens = record?.tokens as Record | null; if ( - record === null || - typeof record !== "object" || - typeof record.auth_mode !== "string" || - record.auth_mode === "" || - tokens === null || - typeof tokens !== "object" || - !["access_token", "refresh_token", "id_token", "account_id"].every( - (name) => typeof tokens[name] === "string" && tokens[name] !== "", - ) + !isObject(value) || + typeof value.auth_mode !== "string" || + value.auth_mode === "" || + !hasCodexTokens(value.tokens) ) { throw new Error("invalid Codex login"); } @@ -82,33 +102,39 @@ export async function stageCodexAuth( gid: number, ): Promise { const codexHome = join(profileHome, ".codex"); - await Deno.mkdir(codexHome, { recursive: true, mode: 0o700 }); - await Deno.chmod(codexHome, 0o700); - await Deno.chown(codexHome, uid, gid); + await mkdir(codexHome, { recursive: true, mode: 0o700 }); + await chmod(codexHome, 0o700); + await chown(codexHome, uid, gid); const stagedPath = join(codexHome, "auth.json"); try { - await Deno.writeTextFile(stagedPath, await readCodexAuth(canonicalPath), { - mode: 0o600, - }); - await Deno.chmod(stagedPath, 0o600); - await Deno.chown(stagedPath, uid, gid); + await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 }); + await chmod(stagedPath, 0o600); + await chown(stagedPath, uid, gid); } catch (error) { - if (!(error instanceof Deno.errors.NotFound)) throw error; + if (!isNotFound(error)) throw error; } return stagedPath; } +// Creates an empty private file under a fresh name, as Deno.makeTempFile did. +async function makeTempFile(directory: string): Promise { + const path = join(directory, `.tmp-${randomUUID()}`); + const handle = await open(path, "wx", 0o600); + await handle.close(); + return path; +} + export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise { const text = await readCodexAuth(stagedPath); const directory = dirname(canonicalPath); - await Deno.mkdir(directory, { recursive: true, mode: 0o700 }); - const temporary = await Deno.makeTempFile({ dir: directory }); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = await makeTempFile(directory); try { - await Deno.writeTextFile(temporary, text, { mode: 0o600 }); - await Deno.chmod(temporary, 0o600); - await Deno.rename(temporary, canonicalPath); + await writeFile(temporary, text, { mode: 0o600 }); + await chmod(temporary, 0o600); + await rename(temporary, canonicalPath); } catch (error) { - await Deno.remove(temporary).catch(() => undefined); + await rm(temporary).catch(() => {}); throw error; } } diff --git a/src/command.ts b/src/command.ts new file mode 100644 index 0000000..e45fe2d --- /dev/null +++ b/src/command.ts @@ -0,0 +1,160 @@ +// A child process with the call shapes the runner was reviewed with: `output()` +// collects both streams, `spawn()` hands them out, and `clearEnv` passes only +// the given environment. Streams default as Deno's did: stdin is inherited, +// stdout and stderr are piped for `output()` and inherited for `spawn()`. +import { spawn as spawnProcess } from "node:child_process"; +import type { ChildProcess, StdioOptions } from "node:child_process"; +import { Buffer } from "node:buffer"; +import { constants } from "node:os"; +import process from "node:process"; +import type { Readable, Writable } from "node:stream"; +import { buffer } from "node:stream/consumers"; +import { finished } from "node:stream/promises"; + +export type StdioMode = "inherit" | "piped" | "null"; + +export interface CommandOptions { + args?: string[]; + cwd?: string; + env?: Record; + clearEnv?: boolean; + uid?: number; + gid?: number; + stdin?: StdioMode; + stdout?: StdioMode; + stderr?: StdioMode; +} + +export interface CommandStatus { + success: boolean; + code: number; +} + +export interface CommandOutput extends CommandStatus { + stdout: Uint8Array; + stderr: Uint8Array; +} + +export interface CommandChild { + status: Promise; + stdin: Writable; + stdout: Readable; + stderr: Readable; +} + +const STDIO: Record = { + inherit: "inherit", + piped: "pipe", + null: "ignore", +}; + +function signalNumber(signal: NodeJS.Signals): number { + return constants.signals[signal]; +} + +function startProcess( + command: string, + options: CommandOptions, + outputDefault: StdioMode, +): ChildProcess { + const stdio: StdioOptions = [ + STDIO[options.stdin ?? "inherit"], + STDIO[options.stdout ?? outputDefault], + STDIO[options.stderr ?? outputDefault], + ]; + const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env }; + return spawnProcess(command, options.args ?? [], { + stdio, + ...(options.cwd !== undefined && { cwd: options.cwd }), + ...(environment !== undefined && { env: environment }), + ...(options.uid !== undefined && { uid: options.uid }), + ...(options.gid !== undefined && { gid: options.gid }), + }); +} + +// Settles when the process exits, before its streams close, as Deno's did. A +// signal-killed process reports 128 plus the signal number, as a shell does. +function waitForStatus(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal)); + resolve({ success: exitCode === 0, code: exitCode }); + }); + }); +} + +function piped(stream: T | null, name: string): T { + if (stream === null) { + throw new TypeError(`${name} is not piped`); + } + return stream; +} + +function collect(stream: Readable | null): Promise { + return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream); +} + +// A stream's chunks as bytes, whether or not it has an encoding set. +export async function* chunks(stream: Readable): AsyncGenerator { + for await (const chunk of stream) { + const value: unknown = chunk; + if (value instanceof Uint8Array) { + yield value; + } else if (typeof value === "string") { + yield Buffer.from(value); + } + } +} + +export function spawn(command: string, options: CommandOptions = {}): CommandChild { + const child = startProcess(command, options, "inherit"); + const status = waitForStatus(child); + // A failed start rejects `status`; the caller sees it once it awaits that. + status.catch(() => {}); + return { + status, + get stdin(): Writable { + return piped(child.stdin, "stdin"); + }, + get stdout(): Readable { + return piped(child.stdout, "stdout"); + }, + get stderr(): Readable { + return piped(child.stderr, "stderr"); + }, + }; +} + +export async function output( + command: string, + options: CommandOptions = {}, +): Promise { + const child = startProcess(command, options, "piped"); + const [status, stdout, stderr] = await Promise.all([ + waitForStatus(child), + collect(child.stdout), + collect(child.stderr), + ]); + return { ...status, stdout, stderr }; +} + +// Writes one chunk and settles once the stream has taken it, as Deno's +// `Deno.stdout.write` did. +export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise { + await new Promise((resolve, reject) => { + stream.write(chunk, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +// Writes the last chunk and settles once the stream has flushed and closed. +export async function endWith(stream: Writable, chunk: string): Promise { + stream.end(chunk); + await finished(stream); +} diff --git a/src/credentials.test.ts b/src/credentials.test.ts new file mode 100644 index 0000000..2b2a8be --- /dev/null +++ b/src/credentials.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vite-plus/test"; +import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts"; + +const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; +const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; +const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32); +const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; + +test("decrypts a known AES-256-GCM credential", async () => { + expect(await decryptCredential(ENCRYPTED, MASTER_KEY)).toBe("secret-token"); +}); + +test("encrypts a credential with a fresh supplied nonce", async () => { + expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED); +}); + +test("rejects a credential encrypted by another master key", async () => { + await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow(); +}); + +test("rejects a credential with a nonstandard nonce", async () => { + await expect(() => + decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), + ).rejects.toThrow("nonce must be 12 bytes"); +}); + +test("decrypts the central reviewer token and ordered profiles", async () => { + expect( + await parseCredentials( + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [ + { type: "codex", model: "gpt-custom" }, + { type: "claude", token: ENCRYPTED }, + { type: "opencode", token: ENCRYPTED }, + ], + }), + MASTER_KEY, + ), + ).toStrictEqual({ + reviewerToken: "secret-token", + profiles: [ + { type: "codex", model: "gpt-custom" }, + { + type: "claude", + model: "claude-sonnet-5", + token: "secret-token", + }, + { + type: "opencode", + model: "openrouter/qwen/qwen3.8-27b", + token: "secret-token", + }, + ], + }); +}); + +test("rejects malformed central credentials", async () => { + await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow( + "reviewer-token", + ); + await expect(() => + parseCredentials( + JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), + MASTER_KEY, + ), + ).rejects.toThrow("profiles[0].token"); +}); diff --git a/src/credentials.ts b/src/credentials.ts index 4bcf49a..980ec19 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,4 +1,6 @@ -import { parse } from "jsr:@std/yaml@1"; +import { webcrypto } from "node:crypto"; + +import { isMapping } from "./json.ts"; import type { BotProfile } from "./profiles.ts"; const DEFAULT_MODELS: Record = { @@ -7,40 +9,40 @@ const DEFAULT_MODELS: Record = { opencode: "openrouter/qwen/qwen3.8-27b", }; -export type BotCredentials = { +export interface BotCredentials { reviewerToken: string; profiles: BotProfile[]; -}; +} function decodeBase64Url(value: string): Uint8Array { const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0); } function encodeBase64Url(value: Uint8Array): string { - const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join(""); + const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join(""); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; -async function importMasterKey(encoded: string, usage: KeyUsage): Promise { +function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise { const bytes = decodeBase64Url(encoded); if (bytes.length !== 32) { throw new Error("master-key must be 32 bytes encoded as base64url"); } - return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); + return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); } export async function decryptCredential(encrypted: string, masterKey: string): Promise { - const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(":"); - if (version !== "v1" || !encodedNonce || !encodedCiphertext || extra) { + const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":"); + if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") { throw new Error("credential must use v1::"); } const nonce = decodeBase64Url(encodedNonce); if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes"); - const plaintext = await crypto.subtle.decrypt( + const plaintext = await webcrypto.subtle.decrypt( { name: "AES-GCM", iv: buffer(nonce) }, await importMasterKey(masterKey, "decrypt"), buffer(decodeBase64Url(encodedCiphertext)), @@ -51,10 +53,10 @@ export async function decryptCredential(encrypted: string, masterKey: string): P export async function encryptCredential( plaintext: string, masterKey: string, - nonce: Uint8Array = crypto.getRandomValues(new Uint8Array(12)), + nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)), ): Promise { if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes"); - const ciphertext = await crypto.subtle.encrypt( + const ciphertext = await webcrypto.subtle.encrypt( { name: "AES-GCM", iv: buffer(nonce) }, await importMasterKey(masterKey, "encrypt"), new TextEncoder().encode(plaintext), @@ -62,59 +64,57 @@ export async function encryptCredential( return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`; } -export async function parseCredentials(text: string, masterKey: string): Promise { - const value: unknown = parse(text); - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new Error("credentials must be a mapping"); +async function parseProfile(entry: unknown, index: number, masterKey: string): Promise { + const at = `profiles[${index}]`; + if (!isMapping(entry)) { + throw new TypeError(`${at}: expected a mapping`); } - const record = value as Record; - for (const key of Object.keys(record)) { + for (const key of Object.keys(entry)) { + if (key !== "type" && key !== "model" && key !== "token") { + throw new Error(`${at}.${key}: unknown field`); + } + } + const { type, model, token } = entry; + if (type !== "claude" && type !== "codex" && type !== "opencode") { + throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); + } + if (model !== undefined && (typeof model !== "string" || model === "")) { + throw new Error(`${at}.model: expected a model name`); + } + if ((type === "claude" || type === "opencode") && typeof token !== "string") { + throw new Error(`${at}.token: expected an encrypted credential`); + } + if (token !== undefined && typeof token !== "string") { + throw new Error(`${at}.token: expected an encrypted credential`); + } + return { + type, + model: model ?? DEFAULT_MODELS[type], + ...(typeof token === "string" && { + token: await decryptCredential(token, masterKey), + }), + }; +} + +export async function parseCredentials(text: string, masterKey: string): Promise { + const value: unknown = JSON.parse(text); + if (!isMapping(value)) { + throw new TypeError("credentials must be a mapping"); + } + for (const key of Object.keys(value)) { if (key !== "reviewer-token" && key !== "profiles") { throw new Error(`${key}: unknown field`); } } - if (typeof record["reviewer-token"] !== "string") { - throw new Error("reviewer-token: expected an encrypted credential"); + const { "reviewer-token": reviewerToken, profiles } = value; + if (typeof reviewerToken !== "string") { + throw new TypeError("reviewer-token: expected an encrypted credential"); } - if (!Array.isArray(record.profiles) || record.profiles.length === 0) { + if (!Array.isArray(profiles) || profiles.length === 0) { throw new Error("profiles must contain at least one profile"); } - const profiles = await Promise.all( - record.profiles.map(async (entry: unknown, index): Promise => { - const at = `profiles[${index}]`; - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { - throw new Error(`${at}: expected a mapping`); - } - const profile = entry as Record; - for (const key of Object.keys(profile)) { - if (key !== "type" && key !== "model" && key !== "token") { - throw new Error(`${at}.${key}: unknown field`); - } - } - const { type, model, token } = profile; - if (type !== "claude" && type !== "codex" && type !== "opencode") { - throw new Error(`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`); - } - if (model !== undefined && (typeof model !== "string" || model === "")) { - throw new Error(`${at}.model: expected a model name`); - } - if ((type === "claude" || type === "opencode") && typeof token !== "string") { - throw new Error(`${at}.token: expected an encrypted credential`); - } - if (token !== undefined && typeof token !== "string") { - throw new Error(`${at}.token: expected an encrypted credential`); - } - return { - type, - model: model ?? DEFAULT_MODELS[type], - ...(typeof token === "string" && { - token: await decryptCredential(token, masterKey), - }), - }; - }), + const parsed = await Promise.all( + profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)), ); - return { - reviewerToken: await decryptCredential(record["reviewer-token"], masterKey), - profiles, - }; + return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; } diff --git a/src/credentials_test.ts b/src/credentials_test.ts deleted file mode 100644 index 8d4cd44..0000000 --- a/src/credentials_test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@1"; -import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts"; - -const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; -const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; -const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32); -const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; - -Deno.test("decrypts a known AES-256-GCM credential", async () => { - assertEquals(await decryptCredential(ENCRYPTED, MASTER_KEY), "secret-token"); -}); - -Deno.test("encrypts a credential with a fresh supplied nonce", async () => { - assertEquals(await encryptCredential("secret-token", MASTER_KEY, NONCE), ENCRYPTED); -}); - -Deno.test("rejects a credential encrypted by another master key", async () => { - await assertRejects(() => decryptCredential(ENCRYPTED, OTHER_KEY)); -}); - -Deno.test("rejects a credential with a nonstandard nonce", async () => { - await assertRejects( - () => decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), - Error, - "nonce must be 12 bytes", - ); -}); - -Deno.test("decrypts the central reviewer token and ordered profiles", async () => { - assertEquals( - await parseCredentials( - ` -reviewer-token: ${ENCRYPTED} -profiles: - - type: codex - model: gpt-custom - - type: claude - token: ${ENCRYPTED} - - type: opencode - token: ${ENCRYPTED} -`, - MASTER_KEY, - ), - { - reviewerToken: "secret-token", - profiles: [ - { type: "codex", model: "gpt-custom" }, - { - type: "claude", - model: "claude-sonnet-5", - token: "secret-token", - }, - { - type: "opencode", - model: "openrouter/qwen/qwen3.8-27b", - token: "secret-token", - }, - ], - }, - ); -}); - -Deno.test("rejects malformed central credentials", async () => { - await assertRejects(() => parseCredentials("profiles: []", MASTER_KEY), Error, "reviewer-token"); - await assertRejects( - () => parseCredentials(`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`, MASTER_KEY), - Error, - "profiles[0].token", - ); -}); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..d00da7b --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,8 @@ +// Node reports a missing file as ENOENT and a missing process as ESRCH, where +// Deno threw `Deno.errors.NotFound` for both. +export function isNotFound(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) { + return false; + } + return error.code === "ENOENT" || error.code === "ESRCH"; +} diff --git a/src/isolation.test.ts b/src/isolation.test.ts new file mode 100644 index 0000000..3b8cd78 --- /dev/null +++ b/src/isolation.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "vite-plus/test"; +import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import process from "node:process"; + +import { isNotFound } from "./errors.ts"; +import { + agentCommand, + isRunnableProfileProcess, + killProfileProcesses, + readProfileFile, +} from "./isolation.ts"; +import type { RuntimeProfile } from "./runtime.ts"; +import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts"; + +const profile = (uid: number, home: string): RuntimeProfile => ({ + index: uid - 20_000, + type: "claude", + model: "test", + uid, + gid: uid, + home, + tokenFile: null, +}); + +test("zombie process entries are already quiescent", () => { + const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n"; + expect(isRunnableProfileProcess(status, 20_010)).toBe(false); +}); + +test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => { + const root = await makeTempDir(); + try { + const secret = `${root}/reviewer-token`; + const result = `${root}/result.md`; + await writeFile(secret, "reviewer-secret", { mode: 0o600 }); + await symlink(secret, result); + + await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow( + "regular private file", + ); + } finally { + await rm(root, { recursive: true }); + } +}); + +test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => { + const root = await makeTempDir(); + await chmod(root, 0o711); + try { + const homeA = `${root}/a`; + const homeB = `${root}/b`; + await mkdir(homeA, { mode: 0o700 }); + await mkdir(homeB, { mode: 0o700 }); + await chown(homeA, 20_000, 20_000); + await chown(homeB, 20_001, 20_001); + await writeFile(`${homeA}/token`, "own", { mode: 0o600 }); + await chown(`${homeA}/token`, 20_000, 20_000); + await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 }); + await chown(`${homeB}/token`, 20_001, 20_001); + await writeFile(`${root}/reviewer`, "root", { mode: 0o600 }); + + const selected = profile(20_000, homeA); + const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], { + cwd: "/", + }).output(); + const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], { + cwd: "/", + }).output(); + const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], { + cwd: "/", + }).output(); + + expect(new TextDecoder().decode(own.stdout)).toBe("own"); + expect(own.success).toBe(true); + expect(sibling.success).toBe(false); + expect(reviewer.success).toBe(false); + } finally { + await rm(root, { recursive: true }); + } +}); + +test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => { + const selected = profile(20_010, "/tmp"); + const launched = await agentCommand( + selected, + "/bin/sh", + ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"], + { cwd: "/" }, + ).output(); + const pid = Number(new TextDecoder().decode(launched.stdout).trim()); + expect(Number.isInteger(pid)).toBe(true); + + await killProfileProcesses(selected.uid); + + const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => { + if (isNotFound(error)) return null; + throw error; + }); + expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); +}); + +test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => { + const selected = profile(20_020, "/tmp"); + const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { + cwd: "/", + env: { PARENT_SECRET: "must-not-cross-boundary" }, + clearEnv: true, + stderr: "piped", + }).output(); + + expect(result.success).toBe(false); +}); diff --git a/src/isolation.ts b/src/isolation.ts index d8e357d..4df8701 100644 --- a/src/isolation.ts +++ b/src/isolation.ts @@ -1,57 +1,89 @@ +import { lstat, readdir, readFile } from "node:fs/promises"; +import process from "node:process"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { output, spawn } from "./command.ts"; +import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts"; +import { isNotFound } from "./errors.ts"; import type { RuntimeProfile } from "./runtime.ts"; +export type AgentCommandOptions = Omit; + +export interface AgentCommand { + output(): Promise; + spawn(): CommandChild; +} + export function agentCommand( profile: RuntimeProfile, command: string, args: string[], - options: Omit = {}, -): Deno.Command { - return new Deno.Command(command, { - ...options, - args, - uid: profile.uid, - gid: profile.gid, - }); + options: AgentCommandOptions = {}, +): AgentCommand { + const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid }; + return { + output: () => output(command, full), + spawn: () => spawn(command, full), + }; } export function isRunnableProfileProcess(status: string, uid: number): boolean { - const owner = status.match(/^Uid:\s+(\d+)/m)?.[1]; - const state = status.match(/^State:\s+(\w)/m)?.[1]; + const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; + const state = /^State:\s+(\w)/mu.exec(status)?.[1]; return owner !== undefined && Number(owner) === uid && state !== "Z"; } async function profileProcessIds(uid: number): Promise { - const ids: number[] = []; - for await (const entry of Deno.readDir("/proc")) { - if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue; - const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(() => null); - if (status !== null && isRunnableProfileProcess(status, uid)) { - ids.push(Number(entry.name)); - } - } - return ids; + const entries = await readdir("/proc", { withFileTypes: true }); + const pids = entries + .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name)) + .map((entry) => entry.name); + const statuses = await Promise.all( + pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)), + ); + return pids + .filter((_pid, index) => { + const status = statuses[index]; + return status !== null && status !== undefined && isRunnableProfileProcess(status, uid); + }) + .map(Number); } -export async function killProfileProcesses(uid: number): Promise { - for (let attempt = 0; attempt < 100; attempt++) { - const ids = await profileProcessIds(uid); - if (ids.length === 0) return; - for (const pid of ids) { - try { - Deno.kill(pid, "SIGKILL"); - } catch (error) { - if (!(error instanceof Deno.errors.NotFound)) throw error; - } +function killProcess(pid: number): void { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if (!isNotFound(error)) { + throw error; } - await new Promise((resolve) => setTimeout(resolve, 10)); } - throw new Error(`profile UID ${uid} still owns processes after cleanup`); +} + +// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds. +export async function killProfileProcesses(uid: number, attempt = 0): Promise { + if (attempt === 100) { + throw new Error(`profile UID ${uid} still owns processes after cleanup`); + } + const ids = await profileProcessIds(uid); + if (ids.length === 0) { + return; + } + for (const pid of ids) { + killProcess(pid); + } + await sleep(10); + await killProfileProcesses(uid, attempt + 1); } export async function readProfileFile(profile: RuntimeProfile, path: string): Promise { - const stat = await Deno.lstat(path); - if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) { + const stat = await lstat(path); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.uid !== profile.uid || + (stat.mode & 0o077) !== 0 + ) { throw new Error(`${path}: expected a profile-owned regular private file`); } - return await Deno.readTextFile(path); + return readFile(path, "utf8"); } diff --git a/src/isolation_test.ts b/src/isolation_test.ts deleted file mode 100644 index f39d95a..0000000 --- a/src/isolation_test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@1"; -import { - agentCommand, - isRunnableProfileProcess, - killProfileProcesses, - readProfileFile, -} from "./isolation.ts"; -import type { RuntimeProfile } from "./runtime.ts"; - -const profile = (uid: number, home: string): RuntimeProfile => ({ - index: uid - 20_000, - type: "claude", - model: "test", - uid, - gid: uid, - home, - tokenFile: null, -}); - -Deno.test("zombie process entries are already quiescent", () => { - const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n"; - assertEquals(isRunnableProfileProcess(status, 20_010), false); -}); - -Deno.test({ - name: "refuses to read a profile result symlink as root", - permissions: { read: true, write: true, sys: ["uid"] }, - async fn() { - if (Deno.build.os === "windows") return; - const root = await Deno.makeTempDir(); - try { - const secret = `${root}/reviewer-token`; - const result = `${root}/result.md`; - await Deno.writeTextFile(secret, "reviewer-secret", { mode: 0o600 }); - await Deno.symlink(secret, result); - - await assertRejects( - () => readProfileFile(profile(Deno.uid()!, root), result), - Error, - "regular private file", - ); - } finally { - await Deno.remove(root, { recursive: true }); - } - }, -}); - -Deno.test({ - name: "a profile process can read only its own private files", - permissions: { read: true, write: true, run: true, sys: ["uid"] }, - async fn() { - if (Deno.uid() !== 0) return; - const root = await Deno.makeTempDir(); - await Deno.chmod(root, 0o711); - try { - const homeA = `${root}/a`; - const homeB = `${root}/b`; - await Deno.mkdir(homeA, { mode: 0o700 }); - await Deno.mkdir(homeB, { mode: 0o700 }); - await Deno.chown(homeA, 20_000, 20_000); - await Deno.chown(homeB, 20_001, 20_001); - await Deno.writeTextFile(`${homeA}/token`, "own", { mode: 0o600 }); - await Deno.chown(`${homeA}/token`, 20_000, 20_000); - await Deno.writeTextFile(`${homeB}/token`, "sibling", { mode: 0o600 }); - await Deno.chown(`${homeB}/token`, 20_001, 20_001); - await Deno.writeTextFile(`${root}/reviewer`, "root", { mode: 0o600 }); - - const selected = profile(20_000, homeA); - const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], { - cwd: "/", - }).output(); - const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], { - cwd: "/", - }).output(); - const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], { - cwd: "/", - }).output(); - - assertEquals(new TextDecoder().decode(own.stdout), "own"); - assertEquals(own.success, true); - assertEquals(sibling.success, false); - assertEquals(reviewer.success, false); - } finally { - await Deno.remove(root, { recursive: true }); - } - }, -}); - -Deno.test({ - name: "cleanup leaves no runnable daemonized process", - permissions: "inherit", - async fn() { - if (Deno.uid() !== 0) return; - const selected = profile(20_010, "/tmp"); - const launched = await agentCommand( - selected, - "/bin/sh", - ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"], - { cwd: "/" }, - ).output(); - const pid = Number(new TextDecoder().decode(launched.stdout).trim()); - assertEquals(Number.isInteger(pid), true); - - await killProfileProcesses(selected.uid); - - const status = await Deno.readTextFile(`/proc/${pid}/status`).catch((error) => { - if (error instanceof Deno.errors.NotFound) return null; - throw error; - }); - assertEquals(status === null || !isRunnableProfileProcess(status, selected.uid), true); - }, -}); - -Deno.test({ - name: "the agent process cannot read its coordinator environment", - permissions: { run: true, sys: ["uid"] }, - async fn() { - if (Deno.uid() !== 0) return; - const selected = profile(20_020, "/tmp"); - const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${Deno.pid}/environ`], { - cwd: "/", - env: { PARENT_SECRET: "must-not-cross-boundary" }, - clearEnv: true, - stderr: "piped", - }).output(); - - assertEquals(result.success, false); - }, -}); diff --git a/src/json.ts b/src/json.ts new file mode 100644 index 0000000..20bea5c --- /dev/null +++ b/src/json.ts @@ -0,0 +1,12 @@ +// Type guards for values read from JSON, which arrive as `unknown`. +export type JsonObject = Record; + +// Any non-null object, arrays included: reading a missing key yields undefined. +export function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null; +} + +// A plain mapping, which an array is not. +export function isMapping(value: unknown): value is JsonObject { + return isObject(value) && !Array.isArray(value); +} diff --git a/src/opencode_test.ts b/src/opencode.test.ts similarity index 55% rename from src/opencode_test.ts rename to src/opencode.test.ts index 2bfc5c7..1b2a0cc 100644 --- a/src/opencode_test.ts +++ b/src/opencode.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertInstanceOf, assertRejects, assertThrows } from "jsr:@std/assert@1"; +import { assert, expect, test } from "vite-plus/test"; import { collectOpenCodeOutput, openCodeArgs, @@ -7,8 +7,8 @@ import { } from "./opencode.ts"; import { AgentAuthError } from "./profiles.ts"; -Deno.test("builds a non-interactive auto-approved OpenCode command", () => { - assertEquals(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), [ +test("builds a non-interactive auto-approved OpenCode command", () => { + expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([ "run", "--format", "json", @@ -19,8 +19,8 @@ Deno.test("builds a non-interactive auto-approved OpenCode command", () => { ]); }); -Deno.test("returns the final visible OpenCode text event", () => { - assertEquals( +test("returns the final visible OpenCode text event", () => { + expect( parseOpenCodeEvents( [ JSON.stringify({ type: "step_start", part: { type: "step-start" } }), @@ -29,23 +29,19 @@ Deno.test("returns the final visible OpenCode text event", () => { JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }), ].join("\n"), ), - "done", - ); + ).toBe("done"); }); -Deno.test("rejects a successful OpenCode run without visible text", async () => { - await assertRejects( - async () => { - parseOpenCodeEvents('{"type":"step_start"}'); - await Promise.resolve(); - }, - Error, - "no text output", - ); +test("rejects a successful OpenCode run without visible text", async () => { + await expect(async () => { + parseOpenCodeEvents('{"type":"step_start"}'); + await Promise.resolve(); + }).rejects.toThrow("no text output"); }); -Deno.test("streams OpenCode output while collecting it", async () => { - const encode = (value: string) => new TextEncoder().encode(value); +const encode = (value: string): Uint8Array => new TextEncoder().encode(value); + +test("streams OpenCode output while collecting it", async () => { const stdout = new ReadableStream({ start(controller) { controller.enqueue(encode("first\n")); @@ -72,8 +68,8 @@ Deno.test("streams OpenCode output while collecting it", async () => { }, ); - assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" }); - assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]); + expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" }); + expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]); }); const openRouterError = (statusCode: number, message: string): string => @@ -91,21 +87,17 @@ const openRouterError = (statusCode: number, message: string): string => }, }); -Deno.test("reports a rejected OpenRouter key as a sign-in failure", () => { +test("reports a rejected OpenRouter key as a sign-in failure", () => { const error = openCodeError(openRouterError(401, "User not found.")); - assertInstanceOf(error, AgentAuthError); - assertEquals(error.message, "opencode sign-in failed"); - assertEquals( - (error.cause as Error).message, - "opencode APIError (API error 401): User not found.", - ); + assert.instanceOf(error, AgentAuthError); + expect(error.message).toBe("opencode sign-in failed"); + assert.instanceOf(error.cause, Error); + expect(error.cause.message).toBe("opencode APIError (API error 401): User not found."); }); -Deno.test("surfaces an OpenCode error event instead of empty output", () => { - assertThrows( - () => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")), - Error, +test("surfaces an OpenCode error event instead of empty output", () => { + expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( "API error 429", ); - assertEquals(openCodeError('{"type":"text","part":{"text":"ok"}}'), undefined); + expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined); }); diff --git a/src/opencode.ts b/src/opencode.ts index 2f9ae24..cb911ae 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -1,3 +1,4 @@ +import { isObject } from "./json.ts"; import { AgentAuthError } from "./profiles.ts"; export function openCodeArgs(model: string, prompt: string): string[] { @@ -7,12 +8,12 @@ export function openCodeArgs(model: string, prompt: string): string[] { type ChunkWriter = (chunk: Uint8Array) => void | Promise; export async function collectOpenCodeOutput( - stdout: ReadableStream, - stderr: ReadableStream, + stdout: AsyncIterable, + stderr: AsyncIterable, writeStdout: ChunkWriter, writeStderr: ChunkWriter, ): Promise<{ stdout: string; stderr: string }> { - const collect = async (stream: ReadableStream, write: ChunkWriter) => { + const collect = async (stream: AsyncIterable, write: ChunkWriter) => { const chunks: Uint8Array[] = []; for await (const chunk of stream) { chunks.push(chunk); @@ -47,13 +48,13 @@ export function openCodeError(output: string): Error | undefined { } catch { continue; } - if (typeof event !== "object" || event === null) continue; - const { type, error } = event as Record; - if (type !== "error" || typeof error !== "object" || error === null) { + if (!isObject(event)) continue; + const { type, error } = event; + if (type !== "error" || !isObject(error)) { continue; } - const { name, data } = error as Record; - const { message, statusCode } = (data ?? {}) as Record; + const { name, data } = error; + const { message, statusCode } = isObject(data) ? data : {}; const detail = `opencode ${typeof name === "string" ? name : "error"}${ typeof statusCode === "number" ? ` (API error ${statusCode})` : "" }: ${typeof message === "string" ? message : "unknown error"}`; @@ -72,10 +73,10 @@ export function parseOpenCodeEvents(output: string): string { for (const line of output.split("\n")) { if (line.trim() === "") continue; const event: unknown = JSON.parse(line); - if (typeof event !== "object" || event === null) continue; - const { type, part } = event as Record; - if (type !== "text" || typeof part !== "object" || part === null) continue; - const text = (part as Record).text; + if (!isObject(event)) continue; + const { type, part } = event; + if (type !== "text" || !isObject(part)) continue; + const { text } = part; if (typeof text === "string" && text.trim() !== "") result = text; } if (result === undefined) { diff --git a/src/post-artifacts.ts b/src/post-artifacts.ts new file mode 100644 index 0000000..683dfb3 --- /dev/null +++ b/src/post-artifacts.ts @@ -0,0 +1,114 @@ +// Reads what the agent phase left behind: its outcome, and the review and +// anchors files it wrote into a profile home. +import type { Stats } from "node:fs"; +import { lstat, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { isNotFound } from "./errors.ts"; +import { isObject } from "./json.ts"; +import { isFailureReason, isProfileType } from "./profiles.ts"; +import type { ProfileFailure } from "./profiles.ts"; +import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; + +export interface Anchor { + path: string; + line: number; + side: "new" | "old"; + body: string; +} + +function parseAnchor(entry: unknown, at: string): Anchor { + if (!isObject(entry)) { + throw new TypeError(`${at}: not an object`); + } + const { path, line, side = "new", body } = entry; + if (typeof path !== "string" || path === "") { + throw new TypeError(`${at}.path: expected a repository path`); + } + if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { + throw new TypeError(`${at}.line: expected a line number`); + } + if (side !== "new" && side !== "old") { + throw new Error(`${at}.side: expected "new" or "old"`); + } + if (typeof body !== "string" || body.trim() === "") { + throw new TypeError(`${at}.body: expected the finding`); + } + return { path, line, side, body }; +} + +export function parseAnchors(text: string, sourcePath: string): Anchor[] { + const entries: unknown = JSON.parse(text); + if (!Array.isArray(entries)) { + throw new TypeError(`${sourcePath}: not an array`); + } + return entries.map((entry: unknown, index) => parseAnchor(entry, `${sourcePath}[${index}]`)); +} + +// Only known labels reach the comment, never raw agent output. +function knownFailures(failures: unknown): ProfileFailure[] { + return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => { + if (!isObject(entry)) { + return []; + } + const { type, model, reason } = entry; + return isProfileType(type) && typeof model === "string" && isFailureReason(reason) + ? [{ type, model, reason }] + : []; + }); +} + +export async function readOutcome(path: string): Promise { + const value: unknown = JSON.parse(await readFile(path, "utf8")); + if (!isObject(value) || !("ok" in value)) { + throw new Error("invalid agent outcome"); + } + if (value.ok === false) { + return { ok: false, failures: knownFailures(value.failures) }; + } + const { ok, body, reviewPath, anchorsPath, profile } = value; + if ( + ok !== true || + typeof body !== "string" || + typeof reviewPath !== "string" || + typeof anchorsPath !== "string" || + !isObject(profile) || + !isProfileType(profile.type) || + typeof profile.model !== "string" + ) { + throw new Error("invalid agent outcome"); + } + return { + ok: true, + body, + reviewPath, + anchorsPath, + profile: { type: profile.type, model: profile.model }, + }; +} + +export async function readAgentArtifact( + manifest: RuntimeManifest, + path: string, + name: string, +): Promise { + const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); + if (profile === undefined) throw new Error(`${name}: outside profile home`); + let stat: Stats; + try { + stat = await lstat(path); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.uid !== profile.uid || + (stat.mode & 0o077) !== 0 + ) { + throw new Error(`${name}: expected a profile-owned regular private file`); + } + const text = await readFile(path, "utf8"); + return text === "" ? null : text; +} diff --git a/src/post.test.ts b/src/post.test.ts new file mode 100644 index 0000000..737dcab --- /dev/null +++ b/src/post.test.ts @@ -0,0 +1,296 @@ +import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; + +import { assert, expect, test } from "vite-plus/test"; + +import { isNotFound } from "./errors.ts"; +import { postRuntime } from "./post.ts"; +import type { PostContext } from "./post.ts"; +import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; +import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts"; + +const context: PostContext = { + apiUrl: "https://gitea.example/api/v1", + repository: "owner/repo", + issueIndex: "7", + eventName: "issue_comment", +}; + +interface Fixture { + manifestPath: string; + runtimeDir: string; + reviewerFile: string; + reviewPath: string; + outcomeFile: string; +} + +type FixtureSource = Extract | { ok: true; body: string }; + +async function writePrivate(path: string, text: string): Promise { + await writeFile(path, text, { mode: 0o600 }); + await chmod(path, 0o600); +} + +async function writeArtifacts( + outcome: RuntimeOutcome, + review: string | undefined, + anchors: unknown, +): Promise { + if (!outcome.ok) return; + if (review !== undefined) await writePrivate(outcome.reviewPath, review); + if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors)); +} + +function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest { + return { + runtimeDir, + manifestPath: `${runtimeDir}/manifest.json`, + reviewerFile: `${runtimeDir}/reviewer-token`, + outcomeFile: `${runtimeDir}/outcome.json`, + profiles: [ + { + index: 0, + type: "claude", + model: "test", + uid: currentUid(), + gid: currentGid(), + home: profileHome, + tokenFile: null, + }, + ], + }; +} + +async function fixture( + source: FixtureSource, + review?: string, + anchors?: unknown, +): Promise { + const runtimeDir = await makeTempDir(); + const profileHome = `${runtimeDir}/profile-0`; + const manifest = manifestFor(runtimeDir, profileHome); + await mkdir(profileHome); + await writeFile(manifest.reviewerFile, "reviewer-secret"); + const outcome: RuntimeOutcome = source.ok + ? { + ...source, + profile: { type: "codex", model: "gpt-test" }, + reviewPath: `${profileHome}/review.md`, + anchorsPath: `${profileHome}/anchors.json`, + } + : source; + await writeFile(manifest.outcomeFile, JSON.stringify(outcome)); + await writeArtifacts(outcome, review, anchors); + await writeFile(manifest.manifestPath, JSON.stringify(manifest)); + const { manifestPath, reviewerFile, outcomeFile } = manifest; + return { + manifestPath, + runtimeDir, + reviewerFile, + reviewPath: `${profileHome}/review.md`, + outcomeFile, + }; +} + +const missing = async (path: string): Promise => { + try { + await stat(path); + return false; + } catch (error) { + if (isNotFound(error)) return true; + throw error; + } +}; + +// The JSON body of one request the fake Gitea received. +function requestBody(init?: RequestInit): unknown { + return typeof init?.body === "string" ? JSON.parse(init.body) : undefined; +} + +// A fake Gitea that records each request body and answers with `status`. +function recordingFetch(bodies: unknown[], status = 201): typeof fetch { + return (_url, init) => { + bodies.push(requestBody(init)); + return Promise.resolve(new Response(null, { status })); + }; +} + +test("posts a successful issue result and removes runtime credentials", async () => { + const result = await fixture({ ok: true, body: "done" }); + const requests: { url: unknown; init: RequestInit | undefined }[] = []; + + await postRuntime(result.manifestPath, context, (url, init) => { + requests.push({ url, init }); + return Promise.resolve(new Response(null, { status: 201 })); + }); + + expect(requests.length).toBe(1); + const [request] = requests; + expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); + expect(requestBody(request?.init)).toStrictEqual({ + body: "done\n\n> Profile: `codex` · `gpt-test`", + }); + expect(request?.init?.headers).toStrictEqual({ + Authorization: "token reviewer-secret", + "Content-Type": "application/json", + }); + assert(await missing(result.runtimeDir)); +}); + +test("retries a rejected inline review without anchors", async () => { + const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ + { path: "run.ts", line: 1, side: "new", body: "Finding" }, + ]); + const bodies: unknown[] = []; + + await postRuntime( + result.manifestPath, + { + ...context, + eventName: "pull_request", + }, + (_url, init) => { + bodies.push(requestBody(init)); + return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); + }, + ); + + expect(bodies[0]).toMatchObject({ + comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }], + }); + expect(bodies[1]).toMatchObject({ + comments: [], + body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`", + }); + assert(await missing(result.runtimeDir)); +}); + +test("reports each tried profile and keeps the action failed", async () => { + const result = await fixture({ + ok: false, + failures: [ + { type: "codex", model: "gpt-test", reason: "sign-in failed" }, + { type: "claude", model: "claude-test", reason: "usage exhausted" }, + { type: "opencode", model: "openrouter/test", reason: "failed" }, + ], + }); + const bodies: unknown[] = []; + + await expect(() => + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); + + expect(bodies).toStrictEqual([ + { + body: + "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " + + "`claude` · `claude-test` (usage exhausted), " + + "`opencode` · `openrouter/test` (failed)", + }, + ]); + assert(!JSON.stringify(bodies).includes("reviewer-secret")); + assert(await missing(result.runtimeDir)); +}); + +test("drops failure entries that are not known labels", async () => { + const result = await fixture({ ok: false, failures: [] }); + await writeFile( + result.outcomeFile, + JSON.stringify({ + ok: false, + failures: [ + { type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, + { type: "shell", model: "x", reason: "failed" }, + { type: "claude", model: "claude-test", reason: "sign-in failed" }, + ], + }), + ); + const bodies: unknown[] = []; + + await expect(() => + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); + + expect(bodies).toStrictEqual([ + { + body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)", + }, + ]); +}); + +test("reports a generic failure when the runner wrote no outcome", async () => { + const result = await fixture({ ok: false, failures: [] }); + await rm(result.outcomeFile); + const bodies: unknown[] = []; + + await expect(() => + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); + + expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]); + assert(await missing(result.runtimeDir)); +}); + +test("removes runtime credentials when posting fails", async () => { + const result = await fixture({ ok: true, body: "done" }); + + await expect(() => + postRuntime(result.manifestPath, context, () => + Promise.resolve(new Response("failed", { status: 500 })), + ), + ).rejects.toThrow("500"); + assert(await missing(result.runtimeDir)); +}); + +test("preserves a Gitea transport failure without retrying a comment", async () => { + const result = await fixture({ ok: true, body: "done" }); + let requests = 0; + + await expect(() => + postRuntime(result.manifestPath, context, () => { + requests += 1; + return Promise.reject(new Error(`network failure ${requests}`)); + }), + ).rejects.toThrow("network failure 1"); + + expect(requests).toBe(1); + assert(await missing(result.runtimeDir)); +}); + +test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => { + const result = await fixture({ ok: true, body: "done" }); + await symlink(result.reviewerFile, result.reviewPath); + let requests = 0; + + await expect(() => + postRuntime(result.manifestPath, context, () => { + requests += 1; + return Promise.resolve(new Response(null, { status: 200 })); + }), + ).rejects.toThrow("regular private file"); + + expect(requests).toBe(1); + assert(await missing(result.runtimeDir)); +}); + +test("reports an attributed failure when a pull request review is missing", async () => { + const result = await fixture({ ok: true, body: "done" }); + const bodies: unknown[] = []; + + await expect(() => + postRuntime( + result.manifestPath, + { + ...context, + eventName: "pull_request", + }, + recordingFetch(bodies), + ), + ).rejects.toThrow("did not write"); + + expect(bodies).toStrictEqual([ + { + body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)", + }, + ]); + assert(await missing(result.runtimeDir)); +}); diff --git a/src/post.ts b/src/post.ts index 1718bdf..99af124 100644 --- a/src/post.ts +++ b/src/post.ts @@ -1,133 +1,157 @@ -import { join } from "jsr:@std/path@1"; -import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "./runtime.ts"; -import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "./profiles.ts"; +import { readFile, rm } from "node:fs/promises"; +import process from "node:process"; -export type PostContext = { +import { isNotFound } from "./errors.ts"; +import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts"; +import type { Anchor } from "./post-artifacts.ts"; +import { failureFooter, withProfile } from "./profiles.ts"; +import type { ProfileFailure } from "./profiles.ts"; +import { readRuntimeManifest } from "./runtime.ts"; +import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; + +export interface PostContext { apiUrl: string; repository: string; issueIndex: string; eventName: string; -}; +} -type Anchor = { - path: string; - line: number; - side: "new" | "old"; - body: string; -}; +type SuccessOutcome = Extract; -const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g"); +type GiteaRequest = (path: string, body: unknown) => Promise; + +// The reviewer's view of one issue or pull request. +interface Publisher { + context: PostContext; + request: GiteaRequest; + postComment(body: string): Promise; +} + +const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const stripAnsi = (text: string): string => text.replace(ANSI, ""); const VERDICTS: Record = { Approved: ["APPROVED", "✅"], "Changes requested": ["REQUEST_CHANGES", "🛑"], }; -const PROFILE_TYPES = ["claude", "codex", "opencode"]; -const FAILURE_REASONS = ["sign-in failed", "usage exhausted", "failed"]; const failureComment = (failures: ProfileFailure[]): string => `AI bot failed.\n\n${failureFooter(failures)}`; class GiteaRequestError extends Error {} -function parseAnchors(text: string, sourcePath: string): Anchor[] { - const entries: unknown = JSON.parse(text); - if (!Array.isArray(entries)) throw new Error(`${sourcePath}: not an array`); - return entries.map((entry: unknown, index) => { - const at = `${sourcePath}[${index}]`; - if (typeof entry !== "object" || entry === null) { - throw new Error(`${at}: not an object`); +function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest { + return async (path, body) => { + let response: Response; + try { + response = await fetcher(`${apiUrl}/${path}`, { + method: "POST", + headers: { + Authorization: `token ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); } - const { path, line, side = "new", body } = entry as Record; - if (typeof path !== "string" || path === "") { - throw new Error(`${at}.path: expected a repository path`); + if (!response.ok) { + throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); } - if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { - throw new Error(`${at}.line: expected a line number`); - } - if (side !== "new" && side !== "old") { - throw new Error(`${at}.side: expected \"new\" or \"old\"`); - } - if (typeof body !== "string" || body.trim() === "") { - throw new Error(`${at}.body: expected the finding`); - } - return { path, line, side, body }; - }); -} - -async function readOutcome(path: string): Promise { - const value: unknown = JSON.parse(await Deno.readTextFile(path)); - if (typeof value !== "object" || value === null || !("ok" in value)) { - throw new Error("invalid agent outcome"); - } - const outcome = value as Record; - if (outcome.ok === false) { - // Only known labels reach the comment, never raw agent output. - const failures = Array.isArray(outcome.failures) ? outcome.failures : []; - return { - ok: false, - failures: failures.flatMap((entry: unknown) => { - const { type, model, reason } = (entry ?? {}) as Record; - return typeof type === "string" && - PROFILE_TYPES.includes(type) && - typeof model === "string" && - typeof reason === "string" && - FAILURE_REASONS.includes(reason) - ? [ - { - type: type as ProfileFailure["type"], - model, - reason: reason as FailureReason, - }, - ] - : []; - }), - }; - } - if ( - outcome.ok !== true || - typeof outcome.body !== "string" || - typeof outcome.reviewPath !== "string" || - typeof outcome.anchorsPath !== "string" || - typeof outcome.profile !== "object" || - outcome.profile === null - ) { - throw new Error("invalid agent outcome"); - } - const profile = outcome.profile as Record; - if (typeof profile.type !== "string" || typeof profile.model !== "string") { - throw new Error("invalid agent outcome"); - } - return { - ok: true, - body: outcome.body, - reviewPath: outcome.reviewPath, - anchorsPath: outcome.anchorsPath, - profile: { - type: profile.type as "claude" | "codex" | "opencode", - model: profile.model, - }, }; } -async function readAgentArtifact( +async function readSuccess( manifest: RuntimeManifest, - path: string, - name: string, -): Promise { - const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); - if (profile === undefined) throw new Error(`${name}: outside profile home`); - let stat: Deno.FileInfo; + publisher: Publisher, +): Promise { + let outcome: RuntimeOutcome; try { - stat = await Deno.lstat(path); + outcome = await readOutcome(manifest.outcomeFile); } catch (error) { - if (error instanceof Deno.errors.NotFound) return null; + await publisher.postComment(failureComment([])); + throw new Error("agent phase failed", { cause: error }); + } + if (!outcome.ok) { + await publisher.postComment(failureComment(outcome.failures)); + throw new Error("agent phase failed"); + } + return outcome; +} + +async function postReview( + publisher: Publisher, + outcome: SuccessOutcome, + review: string, + anchors: Anchor[], +): Promise { + const { context, request } = publisher; + const [verdict = "", ...rest] = review.split("\n"); + const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; + const send = async (entries: Anchor[]): Promise => { + await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { + body: stripAnsi( + withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), + ), + event, + comments: entries.map(({ path, line, side, body }) => ({ + path, + body: stripAnsi(withProfile(body, outcome.profile)), + new_position: side === "new" ? line : 0, + old_position: side === "old" ? line : 0, + })), + }); + }; + await send(anchors).catch(async (error: unknown) => { + if (anchors.length === 0) throw error; + console.error( + `inline comments rejected: ${error instanceof Error ? error.message : String(error)}`, + ); + await send([]); + }); +} + +async function publishResult( + manifest: RuntimeManifest, + outcome: SuccessOutcome, + publisher: Publisher, +): Promise { + const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md"); + if (review === null) { + if (publisher.context.eventName === "pull_request") { + throw new Error("agent did not write a pull request review"); + } + await publisher.postComment(withProfile(outcome.body, outcome.profile)); + return; + } + const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); + const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); + await postReview(publisher, outcome, review, anchors); +} + +async function publish( + manifest: RuntimeManifest, + context: PostContext, + fetcher: typeof fetch, +): Promise { + const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim(); + const request = giteaRequest(context.apiUrl, reviewerToken, fetcher); + const publisher: Publisher = { + context, + request, + postComment: async (body) => { + await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { + body: stripAnsi(body), + }); + }, + }; + const outcome = await readSuccess(manifest, publisher); + try { + await publishResult(manifest, outcome, publisher); + } catch (error) { + if (error instanceof GiteaRequestError) throw error; + await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); throw error; } - if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) { - throw new Error(`${name}: expected a profile-owned regular private file`); - } - const text = await Deno.readTextFile(path); - return text === "" ? null : text; } export async function postRuntime( @@ -137,93 +161,21 @@ export async function postRuntime( ): Promise { const manifest = await readRuntimeManifest(manifestPath); try { - const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)).trim(); - const request = async (path: string, body: unknown): Promise => { - let response: Response; - try { - response = await fetcher(`${context.apiUrl}/${path}`, { - method: "POST", - headers: { - Authorization: `token ${reviewerToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); - } - if (!response.ok) { - throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); - } - }; - const postComment = (body: string) => - request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { - body: stripAnsi(body), - }); - - let outcome: RuntimeOutcome; - try { - outcome = await readOutcome(manifest.outcomeFile); - } catch (error) { - await postComment(failureComment([])); - throw new Error("agent phase failed", { cause: error }); - } - if (!outcome.ok) { - await postComment(failureComment(outcome.failures)); - throw new Error("agent phase failed"); - } - - try { - const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md"); - if (review === null) { - if (context.eventName === "pull_request") { - throw new Error("agent did not write a pull request review"); - } - await postComment(withProfile(outcome.body, outcome.profile)); - return; - } - - const [verdict, ...rest] = review.split("\n"); - const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; - const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); - const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); - const postReview = (entries: Anchor[]) => - request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { - body: stripAnsi( - withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), - ), - event, - comments: entries.map(({ path, line, side, body }) => ({ - path, - body: stripAnsi(withProfile(body, outcome.profile)), - new_position: side === "new" ? line : 0, - old_position: side === "old" ? line : 0, - })), - }); - await postReview(anchors).catch(async (error: Error) => { - if (anchors.length === 0) throw error; - console.error(`inline comments rejected: ${error.message}`); - await postReview([]); - }); - } catch (error) { - if (error instanceof GiteaRequestError) throw error; - await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); - throw error; - } + await publish(manifest, context, fetcher); } finally { - await Deno.remove(manifest.runtimeDir, { recursive: true }).catch((error) => { - if (!(error instanceof Deno.errors.NotFound)) throw error; + await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { + if (!isNotFound(error)) throw error; }); } } +function required(name: string): string { + const value = process.env[name]; + if (value === undefined || value === "") throw new Error(`${name} is not set`); + return value; +} + if (import.meta.main) { - const required = (name: string): string => { - const value = Deno.env.get(name); - if (!value) throw new Error(`${name} is not set`); - return value; - }; await postRuntime(required("BOT_AGENTS_MANIFEST"), { apiUrl: required("GITEA_API_URL"), repository: required("GITEA_REPOSITORY"), diff --git a/src/post_test.ts b/src/post_test.ts deleted file mode 100644 index ce908cf..0000000 --- a/src/post_test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1"; -import { type PostContext, postRuntime } from "./post.ts"; -import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; - -const context: PostContext = { - apiUrl: "https://gitea.example/api/v1", - repository: "owner/repo", - issueIndex: "7", - eventName: "issue_comment", -}; - -async function fixture( - source: Extract | { ok: true; body: string }, - review?: string, - anchors?: unknown, -): Promise<{ - manifestPath: string; - runtimeDir: string; - reviewerFile: string; - reviewPath: string; - outcomeFile: string; -}> { - const runtimeDir = await Deno.makeTempDir(); - const reviewerFile = `${runtimeDir}/reviewer-token`; - const outcomeFile = `${runtimeDir}/outcome.json`; - const profileHome = `${runtimeDir}/profile-0`; - await Deno.mkdir(profileHome); - await Deno.writeTextFile(reviewerFile, "reviewer-secret"); - const outcome: RuntimeOutcome = source.ok - ? { - ...source, - profile: { type: "codex", model: "gpt-test" }, - reviewPath: `${profileHome}/review.md`, - anchorsPath: `${profileHome}/anchors.json`, - } - : source; - await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome)); - if (outcome.ok) { - if (review !== undefined) { - await Deno.writeTextFile(outcome.reviewPath, review, { mode: 0o600 }); - await Deno.chmod(outcome.reviewPath, 0o600); - } - if (anchors !== undefined) { - await Deno.writeTextFile(outcome.anchorsPath, JSON.stringify(anchors), { - mode: 0o600, - }); - await Deno.chmod(outcome.anchorsPath, 0o600); - } - } - const manifestPath = `${runtimeDir}/manifest.json`; - const manifest: RuntimeManifest = { - runtimeDir, - manifestPath, - reviewerFile, - outcomeFile, - profiles: [ - { - index: 0, - type: "claude", - model: "test", - uid: Deno.uid()!, - gid: Deno.gid()!, - home: profileHome, - tokenFile: null, - }, - ], - }; - await Deno.writeTextFile(manifestPath, JSON.stringify(manifest)); - return { - manifestPath, - runtimeDir, - reviewerFile, - reviewPath: `${profileHome}/review.md`, - outcomeFile, - }; -} - -const missing = async (path: string): Promise => { - try { - await Deno.stat(path); - return false; - } catch (error) { - if (error instanceof Deno.errors.NotFound) return true; - throw error; - } -}; - -Deno.test("posts a successful issue result and removes runtime credentials", async () => { - const result = await fixture({ ok: true, body: "done" }); - const requests: Array<{ url: string; init?: RequestInit }> = []; - - await postRuntime(result.manifestPath, context, (url, init) => { - requests.push({ url: String(url), init }); - return Promise.resolve(new Response(null, { status: 201 })); - }); - - assertEquals(requests.length, 1); - assertEquals(requests[0].url, `${context.apiUrl}/repos/owner/repo/issues/7/comments`); - assertEquals(JSON.parse(String(requests[0].init?.body)), { - body: "done\n\n> Profile: `codex` · `gpt-test`", - }); - assertEquals(requests[0].init?.headers, { - Authorization: "token reviewer-secret", - "Content-Type": "application/json", - }); - assert(await missing(result.runtimeDir)); -}); - -Deno.test("retries a rejected inline review without anchors", async () => { - const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ - { path: "run.ts", line: 1, side: "new", body: "Finding" }, - ]); - const bodies: unknown[] = []; - - await postRuntime( - result.manifestPath, - { - ...context, - eventName: "pull_request", - }, - (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); - }, - ); - - assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1); - assertEquals( - (bodies[0] as { comments: Array<{ body: string }> }).comments[0].body, - "Finding\n\n> Profile: `codex` · `gpt-test`", - ); - assertEquals((bodies[1] as { comments: unknown[] }).comments, []); - assertEquals( - (bodies[1] as { body: string }).body, - "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`", - ); - assert(await missing(result.runtimeDir)); -}); - -Deno.test("reports each tried profile and keeps the action failed", async () => { - const result = await fixture({ - ok: false, - failures: [ - { type: "codex", model: "gpt-test", reason: "sign-in failed" }, - { type: "claude", model: "claude-test", reason: "usage exhausted" }, - { type: "opencode", model: "openrouter/test", reason: "failed" }, - ], - }); - const bodies: unknown[] = []; - - await assertRejects( - () => - postRuntime(result.manifestPath, context, (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Promise.resolve(new Response(null, { status: 201 })); - }), - Error, - "agent phase failed", - ); - - assertEquals(bodies, [ - { - body: - "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " + - "`claude` · `claude-test` (usage exhausted), " + - "`opencode` · `openrouter/test` (failed)", - }, - ]); - assert(!JSON.stringify(bodies).includes("reviewer-secret")); - assert(await missing(result.runtimeDir)); -}); - -Deno.test("drops failure entries that are not known labels", async () => { - const result = await fixture({ ok: false, failures: [] }); - await Deno.writeTextFile( - result.outcomeFile, - JSON.stringify({ - ok: false, - failures: [ - { type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, - { type: "shell", model: "x", reason: "failed" }, - { type: "claude", model: "claude-test", reason: "sign-in failed" }, - ], - }), - ); - const bodies: unknown[] = []; - - await assertRejects( - () => - postRuntime(result.manifestPath, context, (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Promise.resolve(new Response(null, { status: 201 })); - }), - Error, - "agent phase failed", - ); - - assertEquals(bodies, [ - { - body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)", - }, - ]); -}); - -Deno.test("reports a generic failure when the runner wrote no outcome", async () => { - const result = await fixture({ ok: false, failures: [] }); - await Deno.remove(result.outcomeFile); - const bodies: unknown[] = []; - - await assertRejects( - () => - postRuntime(result.manifestPath, context, (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Promise.resolve(new Response(null, { status: 201 })); - }), - Error, - "agent phase failed", - ); - - assertEquals(bodies, [{ body: "AI bot failed.\n\n> Profile: none" }]); - assert(await missing(result.runtimeDir)); -}); - -Deno.test("removes runtime credentials when posting fails", async () => { - const result = await fixture({ ok: true, body: "done" }); - - await assertRejects( - () => - postRuntime(result.manifestPath, context, () => - Promise.resolve(new Response("failed", { status: 500 })), - ), - Error, - "500", - ); - assert(await missing(result.runtimeDir)); -}); - -Deno.test("preserves a Gitea transport failure without retrying a comment", async () => { - const result = await fixture({ ok: true, body: "done" }); - let requests = 0; - - await assertRejects( - () => - postRuntime(result.manifestPath, context, () => { - requests++; - return Promise.reject(new Error(`network failure ${requests}`)); - }), - Error, - "network failure 1", - ); - - assertEquals(requests, 1); - assert(await missing(result.runtimeDir)); -}); - -Deno.test("refuses to follow an agent artifact symlink", async () => { - if (Deno.build.os === "windows") return; - const result = await fixture({ ok: true, body: "done" }); - await Deno.symlink(result.reviewerFile, result.reviewPath); - let requests = 0; - - await assertRejects( - () => - postRuntime(result.manifestPath, context, () => { - requests++; - return Promise.resolve(new Response(null, { status: 200 })); - }), - Error, - "regular private file", - ); - - assertEquals(requests, 1); - assert(await missing(result.runtimeDir)); -}); - -Deno.test("reports an attributed failure when a pull request review is missing", async () => { - const result = await fixture({ ok: true, body: "done" }); - const bodies: unknown[] = []; - - await assertRejects( - () => - postRuntime( - result.manifestPath, - { - ...context, - eventName: "pull_request", - }, - (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Promise.resolve(new Response(null, { status: 201 })); - }, - ), - Error, - "did not write", - ); - - assertEquals(bodies, [ - { - body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)", - }, - ]); - assert(await missing(result.runtimeDir)); -}); diff --git a/src/prepare.ts b/src/prepare.ts index 224f378..e1c2aa0 100644 --- a/src/prepare.ts +++ b/src/prepare.ts @@ -1,8 +1,13 @@ +import { appendFile, rm } from "node:fs/promises"; +import process from "node:process"; + import { prepareRuntime } from "./runtime.ts"; const env = (name: string): string => { - const value = Deno.env.get(name); - if (!value) throw new Error(`${name} is not set`); + const value = process.env[name]; + if (value === undefined || value === "") { + throw new Error(`${name} is not set`); + } return value; }; @@ -12,10 +17,8 @@ const manifest = await prepareRuntime( env("BOT_AGENTS_MASTER_KEY"), ); try { - await Deno.writeTextFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`, { - append: true, - }); + await appendFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`); } catch (error) { - await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() => undefined); + await rm(manifest.runtimeDir, { recursive: true }).catch(() => {}); throw error; } diff --git a/src/profiles_test.ts b/src/profiles.test.ts similarity index 58% rename from src/profiles_test.ts rename to src/profiles.test.ts index 58170b2..784b077 100644 --- a/src/profiles_test.ts +++ b/src/profiles.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@1"; +import { expect, test } from "vite-plus/test"; import { AgentAuthError, agentEnvironment, @@ -9,14 +9,13 @@ import { sanitizedEnvironment, } from "./profiles.ts"; -Deno.test("formats profile attribution as a Markdown quote", () => { - assertEquals( - profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }), +test("formats profile attribution as a Markdown quote", () => { + expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe( "> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`", ); }); -Deno.test("falls through only after usage exhaustion", async () => { +test("falls through only after usage exhaustion", async () => { const visited: string[] = []; const result = await runProfiles( [ @@ -32,11 +31,11 @@ Deno.test("falls through only after usage exhaustion", async () => { }, ); - assertEquals(result, "done"); - assertEquals(visited, ["claude", "codex"]); + expect(result).toBe("done"); + expect(visited).toStrictEqual(["claude", "codex"]); }); -Deno.test("recognizes Claude's weekly limit message", async () => { +test("recognizes Claude's weekly limit message", async () => { const result = await runProfiles( [ { type: "claude", model: "claude-sonnet-5", token: "first" }, @@ -50,10 +49,10 @@ Deno.test("recognizes Claude's weekly limit message", async () => { }, ); - assertEquals(result, "fallback"); + expect(result).toBe("fallback"); }); -Deno.test("recognizes provider API exhaustion status", async () => { +test("recognizes provider API exhaustion status", async () => { const results = await Promise.all( ["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) => runProfiles( @@ -69,10 +68,10 @@ Deno.test("recognizes provider API exhaustion status", async () => { ), ); - assertEquals(results, ["fallback", "fallback"]); + expect(results).toStrictEqual(["fallback", "fallback"]); }); -Deno.test("falls through after a sign-in failure and records each try", async () => { +test("falls through after a sign-in failure and records each try", async () => { const failures: ProfileFailure[] = []; const result = await runProfiles( [ @@ -90,82 +89,73 @@ Deno.test("falls through after a sign-in failure and records each try", async () failures, ); - assertEquals(result, "fallback"); - assertEquals(failures, [ + expect(result).toBe("fallback"); + expect(failures).toStrictEqual([ { type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" }, { type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" }, ]); - assertEquals( - failureFooter(failures), + expect(failureFooter(failures)).toStrictEqual( "> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " + "`claude` · `claude-sonnet-5` (usage exhausted)", ); }); -Deno.test("throws the last sign-in failure when every profile fails", async () => { +test("throws the last sign-in failure when every profile fails", async () => { const failures: ProfileFailure[] = []; - await assertRejects( - () => - runProfiles( - [ - { type: "codex", model: "gpt-5.6-terra" }, - { type: "claude", model: "claude-sonnet-5", token: "first" }, - ], - (profile) => { - throw new AgentAuthError(`${profile.type} sign-in failed`); - }, - failures, - ), - AgentAuthError, - "claude sign-in failed", - ); - assertEquals( - failures.map(({ reason }) => reason), - ["sign-in failed", "sign-in failed"], + await expect(() => + runProfiles( + [ + { type: "codex", model: "gpt-5.6-terra" }, + { type: "claude", model: "claude-sonnet-5", token: "first" }, + ], + (profile) => { + throw new AgentAuthError(`${profile.type} sign-in failed`); + }, + failures, + ), + ).rejects.toThrow( + expect.toSatisfy( + (e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"), + ), ); + expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]); }); -Deno.test("preserves an ordinary agent failure", async () => { +test("preserves an ordinary agent failure", async () => { const visited: string[] = []; - await assertRejects( - () => - runProfiles( - [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, - ], - (profile) => { - visited.push(profile.type); - throw new Error("tests failed"); - }, - ), - Error, - "tests failed", - ); - assertEquals(visited, ["claude"]); + await expect(() => + runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + visited.push(profile.type); + throw new Error("tests failed"); + }, + ), + ).rejects.toThrow("tests failed"); + expect(visited).toStrictEqual(["claude"]); }); -Deno.test("does not mistake incidental quota text for exhaustion", async () => { +test("does not mistake incidental quota text for exhaustion", async () => { const visited: string[] = []; - await assertRejects( - () => - runProfiles( - [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, - ], - (profile) => { - visited.push(profile.type); - throw new Error("quota parser test failed at line 429"); - }, - ), - Error, - "quota parser test failed", - ); - assertEquals(visited, ["claude"]); + await expect(() => + runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + visited.push(profile.type); + throw new Error("quota parser test failed at line 429"); + }, + ), + ).rejects.toThrow("quota parser test failed"); + expect(visited).toStrictEqual(["claude"]); }); -Deno.test("exposes only the selected profile credential to the agent", () => { +test("exposes only the selected profile credential to the agent", () => { const base = { PATH: "/usr/bin", HOME: "/home/bot", @@ -190,18 +180,18 @@ Deno.test("exposes only the selected profile credential to the agent", () => { tokenFile: "/runtime/profile-0/token", }; - assertEquals(agentEnvironment(base, profile, "selected"), { + expect(agentEnvironment(base, profile, "selected")).toStrictEqual({ PATH: "/usr/bin", HOME: "/runtime/profile-0", CLAUDE_CODE_OAUTH_TOKEN: "selected", }); - assertEquals(sanitizedEnvironment(base), { + expect(sanitizedEnvironment(base)).toStrictEqual({ PATH: "/usr/bin", HOME: "/home/bot", }); }); -Deno.test("uses a private Codex home without inventing an API credential", () => { +test("uses a private Codex home without inventing an API credential", () => { const profile = { index: 1, type: "codex" as const, @@ -212,14 +202,14 @@ Deno.test("uses a private Codex home without inventing an API credential", () => tokenFile: null, }; - assertEquals(agentEnvironment({ PATH: "/bin" }, profile), { + expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({ PATH: "/bin", HOME: "/runtime/profile-1", CODEX_HOME: "/runtime/profile-1/.codex", }); }); -Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => { +test("exposes an OpenRouter key only to the OpenCode profile", () => { const profile = { index: 2, type: "opencode" as const, @@ -230,7 +220,7 @@ Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => { tokenFile: "/runtime/profile-2/token", }; - assertEquals(agentEnvironment({ PATH: "/bin" }, profile, "selected"), { + expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({ PATH: "/bin", HOME: "/runtime/profile-2", OPENROUTER_API_KEY: "selected", diff --git a/src/profiles.ts b/src/profiles.ts index 95c5abe..79a1b15 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -1,8 +1,17 @@ -export type BotProfile = { - type: "claude" | "codex" | "opencode"; +export type ProfileType = "claude" | "codex" | "opencode"; + +export interface BotProfile { + type: ProfileType; model: string; token?: string; -}; +} + +const PROFILE_TYPES: Set = new Set(["claude", "codex", "opencode"]); +const FAILURE_REASONS: Set = new Set(["sign-in failed", "usage exhausted", "failed"]); + +export function isProfileType(value: unknown): value is ProfileType { + return PROFILE_TYPES.has(value); +} export function profileFooter(profile: Pick): string { return `> Profile: \`${profile.type}\` · \`${profile.model}\``; @@ -14,6 +23,10 @@ export function withProfile(body: string, profile: Pick & { reason: FailureReason; }; @@ -22,7 +35,7 @@ export type ProfileFailure = Pick & { export class AgentAuthError extends Error {} function isUsageExhausted(error: unknown): boolean { - return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i.test( + return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test( String(error), ); } @@ -39,22 +52,35 @@ export function failureFooter(failures: ProfileFailure[]): string { .join(", ")}`; } -export async function runProfiles>( +// Tries the profiles from `index` on, moving on only after a sign-in failure +// or exhausted usage, and records every profile it tried. +async function attemptFrom>( + profiles: P[], + attempt: (profile: P) => Promise, + failures: ProfileFailure[], + index: number, +): Promise { + const profile = profiles[index]; + if (profile === undefined) { + throw new Error("profiles must contain at least one profile"); + } + try { + return await attempt(profile); + } catch (error) { + const reason = failureReason(error); + failures.push({ type: profile.type, model: profile.model, reason }); + if (reason === "failed" || index === profiles.length - 1) throw error; + console.error(`${profile.type} ${reason}; trying next profile`); + return attemptFrom(profiles, attempt, failures, index + 1); + } +} + +export function runProfiles>( profiles: P[], attempt: (profile: P) => Promise, failures: ProfileFailure[] = [], ): Promise { - for (const [index, profile] of profiles.entries()) { - try { - return await attempt(profile); - } catch (error) { - const reason = failureReason(error); - failures.push({ type: profile.type, model: profile.model, reason }); - if (reason === "failed" || index === profiles.length - 1) throw error; - console.error(`${profile.type} ${reason}; trying next profile`); - } - } - throw new Error("profiles must contain at least one profile"); + return attemptFrom(profiles, attempt, failures, 0); } export function sanitizedEnvironment(environment: Record): Record { @@ -85,7 +111,8 @@ export function sanitizedEnvironment(environment: Record): Recor "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", ]) { - if (environment[name] !== undefined) result[name] = environment[name]; + const value = environment[name]; + if (value !== undefined) result[name] = value; } return result; } @@ -98,7 +125,7 @@ export function agentEnvironment( const result = sanitizedEnvironment(environment); result.HOME = profile.home; if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`; - if (token) { + if (token !== undefined && token !== "") { const tokenName = { claude: "CLAUDE_CODE_OAUTH_TOKEN", codex: "OPENAI_API_KEY", diff --git a/src/run-context.ts b/src/run-context.ts new file mode 100644 index 0000000..33ae659 --- /dev/null +++ b/src/run-context.ts @@ -0,0 +1,79 @@ +// The agent phase's environment and the helpers every agent runner shares. It +// is read once, when the runner starts, as the single-file runner did. +import { chmod, chown, writeFile } from "node:fs/promises"; +import process from "node:process"; + +import { agentCommand } from "./isolation.ts"; +import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts"; +import { readRuntimeManifest } from "./runtime.ts"; +import type { RuntimeProfile } from "./runtime.ts"; + +export const env = (name: string): string => { + const value = process.env[name]; + if (value === undefined) { + throw new Error(`${name} is not set`); + } + return value; +}; + +function definedEnvironment(): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined) { + result[name] = value; + } + } + return result; +} + +export const API = env("GITEA_API_URL"); +export const REPO = env("GITEA_REPOSITORY"); +export const INDEX = env("ISSUE_INDEX"); +export const EVENT = env("EVENT_NAME"); +const AUTHOR_TOKEN = env("GITEA_TOKEN"); +export const WORKSPACE = env("GITHUB_WORKSPACE"); +export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST")); +export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment()); + +export async function gitea(path: string, body?: unknown): Promise { + const response = await fetch(`${API}/${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + Authorization: `token ${AUTHOR_TOKEN}`, + "Content-Type": "application/json", + }, + body: body === undefined ? null : JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${path}: ${response.status} ${await response.text()}`); + } + return response; +} + +export async function postAuthorComment(body: string): Promise { + await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body }); +} + +export async function runAgentCommand( + profile: RuntimeProfile, + command: string, + args: string[], + token?: string, +): Promise { + const { success, code } = await agentCommand(profile, command, args, { + cwd: WORKSPACE, + env: agentEnvironment(PROCESS_ENV, profile, token), + clearEnv: true, + }).output(); + if (!success) { + throw new Error(`${command} ${args[0]} exited with ${code}`); + } +} + +export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { + const path = `${profile.home}/${name}`; + await writeFile(path, "", { mode: 0o600 }); + await chmod(path, 0o600); + await chown(path, profile.uid, profile.gid); + return path; +} diff --git a/src/run.ts b/src/run.ts index f85f54e..b559d11 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,113 +1,66 @@ // Runs one ordered agent profile. Credential decryption and reviewer posting // live in separate processes so neither privileged secret reaches this runner. -import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; -import { - type CodexAuthentication, - codexAuthentication, - isClaudeAuthFailure, - isCodexAuthFailure, - persistCodexAuth, - retryInvalidToken, - stageCodexAuth, -} from "./auth.ts"; -import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts"; -import { - AgentAuthError, - agentEnvironment, - type ProfileFailure, - runProfiles, - sanitizedEnvironment, - withProfile, -} from "./profiles.ts"; -import { - collectOpenCodeOutput, - openCodeArgs, - openCodeError, - parseOpenCodeEvents, -} from "./opencode.ts"; -import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "./runtime.ts"; +import { chmod, readFile, writeFile } from "node:fs/promises"; -type GiteaUser = { login: string; email: string }; -type GiteaComment = { user: GiteaUser; created_at: string; body: string }; -type AttemptResult = { +import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts"; +import { runAgent } from "./agents.ts"; +import type { AgentAnswer } from "./agents.ts"; +import { output } from "./command.ts"; +import { killProfileProcesses, readProfileFile } from "./isolation.ts"; +import { isObject } from "./json.ts"; +import { runProfiles } from "./profiles.ts"; +import type { ProfileFailure } from "./profiles.ts"; +import { + API, + env, + EVENT, + gitea, + INDEX, + makeProfileFile, + MANIFEST, + PROCESS_ENV, + REPO, + runAgentCommand, + WORKSPACE, +} from "./run-context.ts"; +import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts"; + +interface AttemptResult { body: string; reviewPath: string; anchorsPath: string; profile: Pick; -}; +} -const env = (name: string): string => { - const value = Deno.env.get(name); - if (value === undefined) throw new Error(`${name} is not set`); - return value; -}; +interface RenderedPrompt { + prompt: string; + reviewPath: string; + anchorsPath: string; +} -const API = env("GITEA_API_URL"); -const REPO = env("GITEA_REPOSITORY"); -const INDEX = env("ISSUE_INDEX"); -const EVENT = env("EVENT_NAME"); -const AUTHOR_TOKEN = env("GITEA_TOKEN"); -const WORKSPACE = env("GITHUB_WORKSPACE"); -const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST")); -const PROCESS_ENV = sanitizedEnvironment(Deno.env.toObject()); const RULES_PATH = "repos/commons/code-rules/raw/README.md"; const CODEX_AUTH = "/root/.codex/auth.json"; const SKILLS_INSTALL = "skills add obra/superpowers"; -async function gitea(path: string, body?: unknown): Promise { - const response = await fetch(`${API}/${path}`, { - method: body === undefined ? "GET" : "POST", - headers: { - Authorization: `token ${AUTHOR_TOKEN}`, - "Content-Type": "application/json", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - if (!response.ok) { - throw new Error(`${path}: ${response.status} ${await response.text()}`); - } - return response; -} - -async function postAuthorComment(body: string): Promise { - await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body }); -} - async function runRoot(command: string, args: string[]): Promise { - const { success, code } = await new Deno.Command(command, { - args, - env: PROCESS_ENV, - clearEnv: true, - }).output(); - if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`); + const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true }); + if (!success) { + throw new Error(`${command} ${args[0]} exited with ${code}`); + } } async function configureGitAuthor(): Promise { - const user: GiteaUser = await (await gitea("user")).json(); - for (const [key, value] of [ - ["name", user.login], - ["email", user.email], - ]) { - await runRoot("git", ["config", `user.${key}`, value]); + const user: unknown = await (await gitea("user")).json(); + if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") { + throw new Error("user: unexpected Gitea response"); } + await runRoot("git", ["config", "user.name", user.login]); + await runRoot("git", ["config", "user.email", user.email]); } async function readProfileToken(profile: RuntimeProfile): Promise { - return profile.tokenFile === null ? undefined : await Deno.readTextFile(profile.tokenFile); -} - -async function runAgentCommand( - profile: RuntimeProfile, - command: string, - args: string[], - token?: string, -): Promise { - const { success, code } = await agentCommand(profile, command, args, { - cwd: WORKSPACE, - env: agentEnvironment(PROCESS_ENV, profile, token), - clearEnv: true, - }).output(); - if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`); + const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8"); + return token; } async function prepareProfile(profile: RuntimeProfile): Promise { @@ -128,19 +81,26 @@ async function prepareProfile(profile: RuntimeProfile): Promise { ]); } -async function renderPrompt( - profile: RuntimeProfile, -): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> { - const reviewPath = await makeProfileFile(profile, "review.md"); - const anchorsPath = await makeProfileFile(profile, "anchors.json"); - const comments: GiteaComment[] = await ( +// One issue comment as the prompt quotes it. +function formatComment(comment: unknown): string { + const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; + const login = isObject(user) ? user.login : undefined; + return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; +} + +async function promptValues( + reviewPath: string, + anchorsPath: string, +): Promise> { + const comments: unknown = await ( await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) ).json(); - const values: Record = { + if (!Array.isArray(comments)) { + throw new TypeError("comments: unexpected Gitea response"); + } + return { COMMENT: env("COMMENT"), - ISSUE_COMMENTS: comments - .map((comment) => `## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`) - .join("\n"), + ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), CODE_RULES: await (await gitea(RULES_PATH)).text(), EVENT_NAME: EVENT, GITEA_API_URL: API, @@ -149,302 +109,62 @@ async function renderPrompt( REVIEW_PATH: reviewPath, ANCHORS_PATH: anchorsPath, }; - const template = await Deno.readTextFile(new URL("../prompt.md", import.meta.url)); +} + +async function renderPrompt(profile: RuntimeProfile): Promise { + const reviewPath = await makeProfileFile(profile, "review.md"); + const anchorsPath = await makeProfileFile(profile, "anchors.json"); + const values = await promptValues(reviewPath, anchorsPath); + const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8"); return { - prompt: template.replace(/\$\{(\w+)\}/g, (match, name) => values[name] ?? match), + prompt: template.replaceAll( + /\$\{(\w+)\}/gu, + (match: string, name: string) => values[name] ?? match, + ), reviewPath, anchorsPath, }; } -async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise { - const claude = agentCommand( - profile, - "claude", - [ - "--print", - "--dangerously-skip-permissions", - "--model", - profile.model, - "--output-format", - "stream-json", - "--verbose", - prompt, - ], - { - cwd: WORKSPACE, - env: { - ...agentEnvironment(PROCESS_ENV, profile, token), - IS_SANDBOX: "1", - }, - clearEnv: true, - stdout: "piped", - stderr: "piped", - }, - ).spawn(); - let result: - | { - is_error?: boolean; - result?: string; - subtype: string; - api_error_status?: number | null; - } - | undefined; - let error = ""; - const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo( - new WritableStream({ - write(chunk) { - error += chunk; - console.error(chunk); - }, - }), - ); - const lines = claude.stdout - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new TextLineStream()); - for await (const line of lines) { - if (!line) continue; - const event = JSON.parse(line); - for (const part of event.message?.content ?? []) { - const text = part.thinking ?? part.text ?? part.name; - if (text) console.log(text); - } - if (event.type === "result") result = event; - } - const [status] = await Promise.all([claude.status, copyError]); - if (result?.is_error) { - const failure = new Error(result.result ?? `claude ended with ${result.subtype}`); - if (isClaudeAuthFailure(result)) { - throw new AgentAuthError("claude sign-in failed", { cause: failure }); - } - throw failure; - } - if (result?.result === undefined) { - throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`); - } - if (!status.success) { - throw new Error(error.trim() || `claude exited with ${status.code}`); - } - return result.result; -} - -async function codexDeviceLogin( - profile: RuntimeProfile, - environment: Record, -): Promise { - const login = agentCommand(profile, "codex", ["login", "--device-auth"], { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - stdout: "piped", - stderr: "piped", - }).spawn(); - let shown = ""; - const collect = (stream: ReadableStream): Promise => - stream - .pipeThrough(new TextDecoderStream()) - .pipeTo(new WritableStream({ write: (chunk) => void (shown += chunk) })); - const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); - const status = login.status; - await new Promise((resolve) => setTimeout(resolve, 3000)); - while (shown.trim() === "") { - const exited = await Promise.race([ - status.then(() => true), - new Promise((resolve) => setTimeout(() => resolve(false), 100)), - ]); - if (exited) { - throw new AgentAuthError("AI bot login produced no instructions"); - } - } - await postAuthorComment(withProfile(shown, profile)); - const [result] = await Promise.all([status, drained]); - if (!result.success) { - throw new AgentAuthError(`codex login exited with ${result.code}`); - } -} - -async function codexApiLogin( - profile: RuntimeProfile, - environment: Record, - token: string, -): Promise { - const login = agentCommand(profile, "codex", ["login", "--with-api-key"], { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - stdin: "piped", - stderr: "piped", - }).spawn(); - const writer = login.stdin.getWriter(); - await writer.write(new TextEncoder().encode(`${token}\n`)); - await writer.close(); - const [status, error] = await Promise.all([login.status, new Response(login.stderr).text()]); - if (!status.success) { - throw new Error(error.trim() || `codex login exited with ${status.code}`); - } -} - -async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { - const path = `${profile.home}/${name}`; - await Deno.writeTextFile(path, "", { mode: 0o600 }); - await Deno.chmod(path, 0o600); - await Deno.chown(path, profile.uid, profile.gid); - return path; -} - -async function runCodex( - profile: RuntimeProfile, - prompt: string, - authentication: CodexAuthentication, -): Promise { - const token = authentication.kind === "api-key" ? authentication.token : undefined; - const environment = agentEnvironment(PROCESS_ENV, profile, token); - if (authentication.kind === "api-key") { - await codexApiLogin(profile, environment, authentication.token); - } else { - const loggedIn = await agentCommand(profile, "codex", ["login", "status"], { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - }).output(); - if (!loggedIn.success) await codexDeviceLogin(profile, environment); - } - const outputPath = await makeProfileFile(profile, "result.md"); - const attempt = async () => { - const codex = agentCommand( - profile, - "codex", - [ - "exec", - "--model", - profile.model, - "--dangerously-bypass-approvals-and-sandbox", - "--output-last-message", - outputPath, - prompt, - ], - { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - stdout: "inherit", - stderr: "piped", - }, - ).spawn(); - const decoder = new TextDecoder(); - let error = ""; - for await (const chunk of codex.stderr) { - await Deno.stderr.write(chunk); - error += decoder.decode(chunk, { stream: true }); - } - error += decoder.decode(); - // Codex echoes the prompt, whose comments may quote auth errors. - return { status: await codex.status, error: error.replaceAll(prompt, "") }; - }; - const result = - authentication.kind === "api-key" - ? await attempt() - : await retryInvalidToken(attempt, async () => { - await runAgentCommand(profile, "codex", ["logout"]); - await codexDeviceLogin(profile, environment); - }); - if (!result.status.success) { - const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`); - if (isCodexAuthFailure(result.error)) { - throw new AgentAuthError("codex sign-in failed", { cause: failure }); - } - throw failure; - } - return outputPath; -} - -async function runOpenCode( - profile: RuntimeProfile, - token: string, - prompt: string, -): Promise { - const process = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), { - cwd: WORKSPACE, - env: agentEnvironment(PROCESS_ENV, profile, token), - clearEnv: true, - stdout: "piped", - stderr: "piped", - }).spawn(); - const output = collectOpenCodeOutput( - process.stdout, - process.stderr, - async (chunk) => { - await Deno.stdout.write(chunk); - }, - async (chunk) => { - await Deno.stderr.write(chunk); - }, - ); - const [status, streams] = await Promise.all([process.status, output]); - const error = streams.stderr.trim(); - if (!status.success) { - throw ( - openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`) - ); - } - return parseOpenCodeEvents(streams.stdout); -} - async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); const authentication = profile.type === "codex" ? codexAuthentication(token) : null; let stagedCodexAuth: string | null = null; - let body: string | undefined; - let resultPath: string | null = null; - let reviewPath: string | undefined; - let anchorsPath: string | undefined; + let answer: AgentAnswer = {}; + let rendered: RenderedPrompt | undefined; try { if (authentication?.kind === "persistent") { stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); } await prepareProfile(profile); - const rendered = await renderPrompt(profile); - reviewPath = rendered.reviewPath; - anchorsPath = rendered.anchorsPath; - if (profile.type === "claude") { - body = await runClaude(profile, token ?? "", rendered.prompt); - } else if (profile.type === "codex") { - resultPath = await runCodex(profile, rendered.prompt, authentication!); - } else { - body = await runOpenCode(profile, token ?? "", rendered.prompt); - } + rendered = await renderPrompt(profile); + answer = await runAgent(profile, token, rendered.prompt, authentication); } finally { await killProfileProcesses(profile.uid); if (stagedCodexAuth !== null) { await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); } } - if (resultPath !== null) body = await readProfileFile(profile, resultPath); - if (body === undefined || reviewPath === undefined || anchorsPath === undefined) { + const body = + answer.resultPath === undefined + ? answer.body + : await readProfileFile(profile, answer.resultPath); + if (body === undefined || rendered === undefined) { throw new Error("agent attempt produced no result"); } - return { - body, - reviewPath, - anchorsPath, - profile: { type: profile.type, model: profile.model }, - }; + const { reviewPath, anchorsPath } = rendered; + return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } }; } async function writeOutcome(outcome: RuntimeOutcome): Promise { - await Deno.writeTextFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { - mode: 0o600, - }); - await Deno.chmod(MANIFEST.outcomeFile, 0o600); + await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 }); + await chmod(MANIFEST.outcomeFile, 0o600); } const failures: ProfileFailure[] = []; try { await configureGitAuthor(); - await writeOutcome({ - ok: true, - ...(await runProfiles(MANIFEST.profiles, runProfile, failures)), - }); + await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) }); } catch (error) { console.error(error); await writeOutcome({ ok: false, failures }); diff --git a/src/runtime.test.ts b/src/runtime.test.ts new file mode 100644 index 0000000..ff059fc --- /dev/null +++ b/src/runtime.test.ts @@ -0,0 +1,105 @@ +import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { assert, expect, test } from "vite-plus/test"; + +import { output } from "./command.ts"; +import { prepareRuntime } from "./runtime.ts"; +import type { RuntimeProfile } from "./runtime.ts"; +import { IS_ROOT, makeTempDir } from "./testing.ts"; + +const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; +const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; + +// Runs `command` as the profile's user, from the root directory. +async function runAs(profile: RuntimeProfile, command: string, path: string): Promise { + const result = await output(command, { + args: [path], + cwd: "/", + uid: profile.uid, + gid: profile.gid, + }); + return result.success; +} + +async function expectPrivate(path: string, uid: number, mode: number): Promise { + const info = await stat(path); + expect(info.uid).toStrictEqual(uid); + expect(info.mode & 0o777).toBe(mode); +} + +test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => { + const root = await makeTempDir(); + await chmod(root, 0o711); + const action = await makeTempDir(); + try { + await writeFile( + `${action}/profiles.json`, + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }], + }), + ); + + const manifest = await prepareRuntime(action, root, MASTER_KEY); + const serialized = await readFile(manifest.manifestPath, "utf8"); + assert(!serialized.includes("secret-token")); + expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token"); + await expectPrivate(manifest.reviewerFile, 0, 0o600); + + const [claude, codex] = manifest.profiles; + assert(claude !== undefined && codex !== undefined); + expect(claude.uid).not.toStrictEqual(codex.uid); + await expectPrivate(claude.home, claude.uid, 0o700); + assert(claude.tokenFile !== null); + expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); + await expectPrivate(claude.tokenFile, claude.uid, 0o600); + expect((await stat(codex.home)).uid).toStrictEqual(codex.uid); + expect(codex.tokenFile).toBe(null); + + const ownToken = await output("/bin/cat", { + args: [claude.tokenFile], + cwd: "/", + uid: claude.uid, + gid: claude.gid, + }); + expect(ownToken.success).toBe(true); + expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token"); + expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); + expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false); + } finally { + await rm(root, { recursive: true }); + await rm(action, { recursive: true }); + } +}); + +test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => { + const root = await makeTempDir(); + const action = await makeTempDir(); + try { + await writeFile( + `${action}/profiles.json`, + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "claude", token: ENCRYPTED }], + }), + ); + const result = await output(process.execPath, { + args: [fileURLToPath(new URL("prepare.ts", import.meta.url))], + env: { + ACTION_PATH: action, + RUNNER_TEMP: root, + BOT_AGENTS_MASTER_KEY: MASTER_KEY, + GITHUB_OUTPUT: action, + }, + clearEnv: true, + }); + + expect(result.success).toBe(false); + expect(await readdir(root)).toStrictEqual([]); + } finally { + await rm(root, { recursive: true }); + await rm(action, { recursive: true }); + } +}); diff --git a/src/runtime.ts b/src/runtime.ts index 8d11a8f..15bc9ab 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,5 +1,8 @@ -import { join } from "jsr:@std/path@1"; +import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { parseCredentials } from "./credentials.ts"; +import { isMapping } from "./json.ts"; import type { BotProfile, ProfileFailure } from "./profiles.ts"; const FIRST_PROFILE_UID = 20_000; @@ -12,13 +15,13 @@ export type RuntimeProfile = Pick & { tokenFile: string | null; }; -export type RuntimeManifest = { +export interface RuntimeManifest { runtimeDir: string; manifestPath: string; reviewerFile: string; outcomeFile: string; profiles: RuntimeProfile[]; -}; +} export type RuntimeOutcome = | { @@ -31,9 +34,27 @@ export type RuntimeOutcome = | { ok: false; failures: ProfileFailure[] }; async function writePrivate(path: string, value: string, uid: number, gid: number): Promise { - await Deno.writeTextFile(path, value, { mode: 0o600 }); - await Deno.chmod(path, 0o600); - await Deno.chown(path, uid, gid); + await writeFile(path, value, { mode: 0o600 }); + await chmod(path, 0o600); + await chown(path, uid, gid); +} + +async function prepareProfile( + runtimeDir: string, + profile: BotProfile, + index: number, +): Promise { + const uid = FIRST_PROFILE_UID + index; + const home = join(runtimeDir, `profile-${index}`); + await mkdir(home, { mode: 0o700 }); + await chmod(home, 0o700); + await chown(home, uid, uid); + const tokenFile = + profile.token === undefined || profile.token === "" ? null : join(home, "token"); + if (tokenFile !== null && profile.token !== undefined) { + await writePrivate(tokenFile, profile.token, uid, uid); + } + return { index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile }; } export async function prepareRuntime( @@ -42,39 +63,23 @@ export async function prepareRuntime( masterKey: string, ): Promise { const credentials = await parseCredentials( - await Deno.readTextFile(join(actionPath, "profiles.yml")), + await readFile(join(actionPath, "profiles.json"), "utf8"), masterKey, ); - const runtimeDir = await Deno.makeTempDir({ - dir: runtimeRoot, - prefix: "bot-agents-", - }); + const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-")); try { - await Deno.chmod(runtimeDir, 0o711); + await chmod(runtimeDir, 0o711); const reviewerFile = join(runtimeDir, "reviewer-token"); await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0); - const profiles: RuntimeProfile[] = []; - for (const [index, profile] of credentials.profiles.entries()) { - const uid = FIRST_PROFILE_UID + index; - const home = join(runtimeDir, `profile-${index}`); - await Deno.mkdir(home, { mode: 0o700 }); - await Deno.chmod(home, 0o700); - await Deno.chown(home, uid, uid); - const tokenFile = profile.token ? join(home, "token") : null; - if (tokenFile !== null) { - await writePrivate(tokenFile, profile.token!, uid, uid); - } - profiles.push({ - index, - type: profile.type, - model: profile.model, - uid, - gid: uid, - home, - tokenFile, - }); - } + // One profile at a time, in order, as each claims the next UID. + const profiles = await credentials.profiles.reduce>( + async (previous, profile, index) => [ + ...(await previous), + await prepareProfile(runtimeDir, profile, index), + ], + Promise.resolve([]), + ); const manifestPath = join(runtimeDir, "manifest.json"); const manifest: RuntimeManifest = { @@ -87,11 +92,26 @@ export async function prepareRuntime( await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0); return manifest; } catch (error) { - await Deno.remove(runtimeDir, { recursive: true }).catch(() => undefined); + await rm(runtimeDir, { recursive: true }).catch(() => {}); throw error; } } -export async function readRuntimeManifest(path: string): Promise { - return JSON.parse(await Deno.readTextFile(path)); +function isRuntimeManifest(value: unknown): value is RuntimeManifest { + return ( + isMapping(value) && + typeof value.runtimeDir === "string" && + typeof value.manifestPath === "string" && + typeof value.reviewerFile === "string" && + typeof value.outcomeFile === "string" && + Array.isArray(value.profiles) + ); +} + +export async function readRuntimeManifest(path: string): Promise { + const value: unknown = JSON.parse(await readFile(path, "utf8")); + if (!isRuntimeManifest(value)) { + throw new Error(`${path}: invalid runtime manifest`); + } + return value; } diff --git a/src/runtime_test.ts b/src/runtime_test.ts deleted file mode 100644 index e4a643b..0000000 --- a/src/runtime_test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { assert, assertEquals, assertNotEquals } from "jsr:@std/assert@1"; -import { prepareRuntime } from "./runtime.ts"; - -const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; -const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; - -Deno.test({ - name: "prepares root-only reviewer and isolated profile credentials", - permissions: { read: true, write: true, run: true, sys: ["uid"] }, - async fn() { - if (Deno.uid() !== 0) return; - const root = await Deno.makeTempDir(); - await Deno.chmod(root, 0o711); - const action = await Deno.makeTempDir(); - try { - await Deno.writeTextFile( - `${action}/profiles.yml`, - `reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n - type: codex\n`, - ); - - const manifest = await prepareRuntime(action, root, MASTER_KEY); - const serialized = await Deno.readTextFile(manifest.manifestPath); - assert(!serialized.includes("secret-token")); - assertEquals(await Deno.readTextFile(manifest.reviewerFile), "secret-token"); - assertEquals((await Deno.stat(manifest.reviewerFile)).mode! & 0o777, 0o600); - assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0); - - const [claude, codex] = manifest.profiles; - assertNotEquals(claude.uid, codex.uid); - assertEquals((await Deno.stat(claude.home)).uid, claude.uid); - assertEquals((await Deno.stat(claude.home)).mode! & 0o777, 0o700); - assert(claude.tokenFile !== null); - assertEquals(await Deno.readTextFile(claude.tokenFile), "secret-token"); - assertEquals((await Deno.stat(claude.tokenFile)).uid, claude.uid); - assertEquals((await Deno.stat(claude.tokenFile)).mode! & 0o777, 0o600); - assertEquals((await Deno.stat(codex.home)).uid, codex.uid); - assertEquals(codex.tokenFile, null); - - const ownToken = await new Deno.Command("/bin/cat", { - args: [claude.tokenFile], - cwd: "/", - uid: claude.uid, - gid: claude.gid, - }).output(); - assertEquals(ownToken.success, true); - assertEquals(new TextDecoder().decode(ownToken.stdout), "secret-token"); - - const reviewerToken = await new Deno.Command("/bin/cat", { - args: [manifest.reviewerFile], - cwd: "/", - uid: claude.uid, - gid: claude.gid, - }).output(); - assertEquals(reviewerToken.success, false); - - const siblingHome = await new Deno.Command("/bin/ls", { - args: [codex.home], - cwd: "/", - uid: claude.uid, - gid: claude.gid, - }).output(); - assertEquals(siblingHome.success, false); - } finally { - await Deno.remove(root, { recursive: true }); - await Deno.remove(action, { recursive: true }); - } - }, -}); - -Deno.test({ - name: "removes prepared credentials when output publication fails", - permissions: "inherit", - async fn() { - if (Deno.uid() !== 0) return; - const root = await Deno.makeTempDir(); - const action = await Deno.makeTempDir(); - try { - await Deno.writeTextFile( - `${action}/profiles.yml`, - `reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n`, - ); - const result = await new Deno.Command(Deno.execPath(), { - args: ["run", "-A", new URL("./prepare.ts", import.meta.url).pathname], - env: { - ACTION_PATH: action, - RUNNER_TEMP: root, - BOT_AGENTS_MASTER_KEY: MASTER_KEY, - GITHUB_OUTPUT: action, - }, - clearEnv: true, - }).output(); - - assertEquals(result.success, false); - const entries = []; - for await (const entry of Deno.readDir(root)) entries.push(entry.name); - assertEquals(entries, []); - } finally { - await Deno.remove(root, { recursive: true }); - await Deno.remove(action, { recursive: true }); - } - }, -}); diff --git a/src/testing.ts b/src/testing.ts new file mode 100644 index 0000000..0e96f38 --- /dev/null +++ b/src/testing.ts @@ -0,0 +1,23 @@ +// Test support: the current user's IDs and a fresh temporary directory. +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; + +export function currentUid(): number { + return process.getuid?.() ?? -1; +} + +export function currentGid(): number { + return process.getgid?.() ?? -1; +} + +// The isolation tests switch UIDs, which only root may do. +export const IS_ROOT: boolean = currentUid() === 0; + +// Symlinks and POSIX modes, as these tests use them, need a Unix host. +export const IS_WINDOWS: boolean = process.platform === "win32"; + +export function makeTempDir(): Promise { + return mkdtemp(join(tmpdir(), "bot-agents-test-")); +} diff --git a/src/workflow_test.ts b/src/workflow_test.ts deleted file mode 100644 index 1f3ce92..0000000 --- a/src/workflow_test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { assert, assertEquals } from "jsr:@std/assert@1"; -import { parse } from "jsr:@std/yaml@1"; - -type Mapping = Record; - -const mapping = (value: unknown, at: string): Mapping => { - assert(typeof value === "object" && value !== null && !Array.isArray(value), at); - return value as Mapping; -}; - -const sequence = (value: unknown, at: string): Mapping[] => { - assert(Array.isArray(value), at); - return value.map((entry, index) => mapping(entry, `${at}[${index}]`)); -}; - -async function yaml(path: string): Promise { - return mapping(parse(await Deno.readTextFile(path)), path); -} - -async function yamlFiles(directory = "."): Promise { - const result: string[] = []; - for await (const entry of Deno.readDir(directory)) { - if (entry.name === ".git" || entry.name === ".superpowers") continue; - const path = directory === "." ? entry.name : `${directory}/${entry.name}`; - if (entry.isDirectory) result.push(...(await yamlFiles(path))); - if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path); - } - return result; -} - -Deno.test("all committed YAML parses", async () => { - const files = await yamlFiles(); - assert(files.length > 0); - for (const file of files) await yaml(file); -}); - -Deno.test("the action exposes only the master key and scopes it to preparation", async () => { - const action = await yaml("action.yml"); - assertEquals(Object.keys(mapping(action.inputs, "inputs")), ["master-key"]); - const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps"); - for (const step of steps) { - if (typeof step.run === "string") { - assertEquals(step.run.includes("\n"), false, String(step.name)); - } - } - const checkout = steps.find((step) => step.uses === "actions/checkout@v4"); - const prepare = steps.find((step) => step.id === "prepare"); - const agent = steps.find((step) => step.id === "agent"); - const post = steps.find((step) => step.id === "post"); - const source = steps.find((step) => step.id === "source"); - assert(checkout && source && prepare && agent && post); - assertEquals(mapping(checkout.with, "checkout.with").token, "${{ gitea.token }}"); - assertEquals( - mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY, - "${{ inputs.master-key }}", - ); - assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false); - assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false); - assertEquals(mapping(agent.env, "agent.env").GITEA_TOKEN, "${{ gitea.token }}"); - assert(String(post.if).includes("always()")); - for (const step of [prepare, agent, post]) { - assert(String(step.run).includes("steps.source.outputs.path")); - } -}); - -Deno.test("the source protection script stages immutable action files", async () => { - const temp = await Deno.makeTempDir(); - const outputFile = `${temp}/output`; - try { - const output = await new Deno.Command("/bin/bash", { - args: ["scripts/protect-source.sh"], - env: { - ACTION_PATH: Deno.cwd(), - GITHUB_OUTPUT: outputFile, - RUNNER_TEMP: temp, - }, - stderr: "piped", - }).output(); - assert(output.success, new TextDecoder().decode(output.stderr)); - - const path = (await Deno.readTextFile(outputFile)).trim().slice(5); - for (const file of [ - "src/prepare.ts", - "src/run.ts", - "src/post.ts", - "profiles.yml", - "prompt.md", - ]) { - const stat = await Deno.stat(`${path}/${file}`); - assert(stat.isFile, file); - assertEquals(stat.mode! & 0o022, 0, file); - } - } finally { - await Deno.remove(temp, { recursive: true }); - } -}); - -Deno.test("module-relative runtime assets exist", async () => { - const module = new URL("run.ts", import.meta.url); - const source = await Deno.readTextFile(module); - for (const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)) { - const asset = new URL(match[1], module); - const stat = await Deno.stat(asset); - assert(stat.isFile, asset.pathname); - } -}); - -Deno.test("the repository uses its trusted main action", async () => { - const workflow = await yaml(".gitea/workflows/agents.yml"); - const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent"); - const permissions = mapping(job.permissions, "jobs.agent.permissions"); - assertEquals(permissions, { - code: "write", - issues: "write", - "pull-requests": "write", - }); - const steps = sequence(job.steps, "jobs.agent.steps"); - const local = steps.findIndex((step) => step.uses === "./"); - assertEquals(local, -1); - const trusted = steps.findIndex( - (step) => step.uses === "https://git.capsulizers.com/commons/bot-agents@main", - ); - assert(trusted >= 0); - assertEquals( - mapping(steps[trusted].with, "trusted.with")["master-key"], - "${{ secrets.BOT_AGENTS_MASTER_KEY }}", - ); - assertEquals(JSON.stringify(workflow).includes("gitea.actor != 'bot'"), true); -}); - -Deno.test("CI checks every phase and the root isolation suite", async () => { - const workflow = await yaml(".gitea/workflows/check.yml"); - const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno"); - const commands = sequence(job.steps, "jobs.deno.steps") - .map((step) => (typeof step.run === "string" ? step.run : "")) - .join("\n"); - for (const entrypoint of ["src/prepare.ts", "src/run.ts", "src/post.ts"]) { - assert(commands.includes(entrypoint), entrypoint); - } - assert(commands.includes("deno test --allow-all")); -}); - -Deno.test("uses the standard Skills installer without custom discovery", async () => { - const runner = await Deno.readTextFile("src/run.ts"); - assert(runner.includes("skills add obra/superpowers")); - try { - await Deno.stat("superpowers.ts"); - assert(false, "superpowers.ts should be removed"); - } catch (error) { - assert(error instanceof Deno.errors.NotFound); - } -}); - -Deno.test("installs the OpenCode CLI", async () => { - const action = await Deno.readTextFile("action.yml"); - assert(action.includes("opencode-ai")); -}); - -Deno.test("profile preparation does not receive provider credentials", async () => { - const runner = await Deno.readTextFile("src/run.ts"); - assert(runner.includes("await prepareProfile(profile);")); - assertEquals(runner.includes("prepareProfile(profile, token)"), false); -}); - -Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => { - const credentials = await yaml("profiles.yml"); - const profiles = sequence(credentials.profiles, "profiles"); - assertEquals( - profiles.map((profile) => profile.type), - ["codex", "claude", "opencode"], - ); - assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b"); - assert(String(profiles[2].token).startsWith("v1:")); -}); diff --git a/tests/entrypoints.test.ts b/tests/entrypoints.test.ts new file mode 100644 index 0000000..68c10ae --- /dev/null +++ b/tests/entrypoints.test.ts @@ -0,0 +1,27 @@ +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { expect, test } from "vite-plus/test"; + +import { output } from "../src/command.ts"; + +// Each phase runs as `node src/.ts` with no build step. Node loads the +// whole import graph, stripping types, before the phase reads its first +// variable, so failing on that variable proves every module loads. +const PHASES: [phase: string, firstVariable: string][] = [ + ["prepare", "ACTION_PATH"], + ["run", "GITEA_API_URL"], + ["post", "BOT_AGENTS_MANIFEST"], +]; + +test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { + const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url)); + const result = await output(process.execPath, { + args: [path], + env: { PATH: process.env.PATH ?? "" }, + clearEnv: true, + stdin: "null", + }); + expect(result.success).toBe(false); + expect(new TextDecoder().decode(result.stderr)).toContain(`${firstVariable} is not set`); +}); diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts new file mode 100644 index 0000000..23ff4d0 --- /dev/null +++ b/tests/workflow.test.ts @@ -0,0 +1,175 @@ +import { readdir, readFile, rm, stat } from "node:fs/promises"; +import process from "node:process"; + +import { assert, expect, test } from "vite-plus/test"; +import { parse } from "yaml"; + +import { output } from "../src/command.ts"; +import { isNotFound } from "../src/errors.ts"; +import { isMapping } from "../src/json.ts"; +import type { JsonObject } from "../src/json.ts"; +import { makeTempDir } from "../src/testing.ts"; + +const mapping = (value: unknown, at: string): JsonObject => { + assert(isMapping(value), at); + return value; +}; + +const sequence = (value: unknown, at: string): JsonObject[] => { + assert(Array.isArray(value), at); + return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); +}; + +async function yaml(path: string): Promise { + const value: unknown = parse(await readFile(path, "utf8")); + return mapping(value, path); +} + +async function yamlFiles(): Promise { + const entries = await readdir(".", { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name)) + .map((entry) => `${entry.parentPath}/${entry.name}`.replace(/^\.\//u, "")) + .filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path)); +} + +const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => { + const step = steps.find((entry) => entry[key] === value); + assert(step !== undefined, `${key}: ${value}`); + return step; +}; + +test("all committed YAML parses", async () => { + const files = await yamlFiles(); + assert(files.length > 0); + await Promise.all(files.map((file) => yaml(file))); +}); + +test("the action exposes only the master key and scopes it to preparation", async () => { + const action = await yaml("action.yml"); + expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]); + const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps"); + for (const step of steps) { + if (typeof step.run === "string") { + expect(step.run.includes("\n"), String(step.name)).toBe(false); + } + } + const checkout = findStep(steps, "uses", "actions/checkout@v4"); + const prepare = findStep(steps, "id", "prepare"); + const agent = findStep(steps, "id", "agent"); + const post = findStep(steps, "id", "post"); + findStep(steps, "id", "source"); + expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}"); + expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe( + "${{ inputs.master-key }}", + ); + expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); + expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); + expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}"); + assert(String(post.if).includes("always()")); + for (const step of [prepare, agent, post]) { + assert(String(step.run).includes("steps.source.outputs.path")); + } +}); + +test("the source protection script stages immutable action files", async () => { + const temp = await makeTempDir(); + const outputFile = `${temp}/output`; + try { + const result = await output("/bin/bash", { + args: ["scripts/protect-source.sh"], + env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp }, + stderr: "piped", + }); + assert(result.success, new TextDecoder().decode(result.stderr)); + + const path = (await readFile(outputFile, "utf8")).trim().slice(5); + const files = [ + "src/prepare.ts", + "src/run.ts", + "src/post.ts", + "package.json", + "profiles.json", + "prompt.md", + ]; + const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`))); + for (const [index, info] of stats.entries()) { + assert(info.isFile(), files[index]); + expect(info.mode & 0o022, files[index]).toStrictEqual(0); + } + } finally { + await rm(temp, { recursive: true }); + } +}); + +test("module-relative runtime assets exist", async () => { + const module = new URL("../src/run.ts", import.meta.url); + const source = await readFile(module, "utf8"); + const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map( + ([, asset = ""]) => new URL(asset, module), + ); + const stats = await Promise.all(assets.map((asset) => stat(asset))); + for (const [index, info] of stats.entries()) { + assert(info.isFile(), assets[index]?.pathname); + } +}); + +test("the repository uses its trusted main action", async () => { + const workflow = await yaml(".gitea/workflows/agents.yml"); + const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent"); + const permissions = mapping(job.permissions, "jobs.agent.permissions"); + expect(permissions).toStrictEqual({ + code: "write", + issues: "write", + "pull-requests": "write", + }); + const steps = sequence(job.steps, "jobs.agent.steps"); + expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1); + const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); + expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe( + "${{ secrets.BOT_AGENTS_MASTER_KEY }}", + ); + expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); +}); + +test("CI checks every phase and the root isolation suite", async () => { + const workflow = await yaml(".gitea/workflows/check.yml"); + const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check"); + const commands = sequence(job.steps, "jobs.check.steps") + .map((step) => (typeof step.run === "string" ? step.run : "")) + .join("\n"); + // `vp check` type-checks every phase, and `vp test` runs each of them under + // Node as well as the root-only isolation tests. + assert(commands.includes("npm ci")); + assert(commands.includes("vp check")); + assert(commands.includes("vp test")); +}); + +test("uses the standard Skills installer without custom discovery", async () => { + const runner = await readFile("src/run.ts", "utf8"); + assert(runner.includes("skills add obra/superpowers")); + const removed = await stat("superpowers.ts").then( + () => false, + (error: unknown) => isNotFound(error), + ); + assert(removed, "superpowers.ts should be removed"); +}); + +test("installs the OpenCode CLI", async () => { + const action = await readFile("action.yml", "utf8"); + assert(action.includes("opencode-ai")); +}); + +test("profile preparation does not receive provider credentials", async () => { + const runner = await readFile("src/run.ts", "utf8"); + assert(runner.includes("await prepareProfile(profile);")); + expect(runner.includes("prepareProfile(profile, token)")).toBe(false); +}); + +test("central profiles fall back from Codex to Claude to OpenCode", async () => { + const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); + const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles"); + expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]); + expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b"); + assert(String(profiles[2]?.token).startsWith("v1:")); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e2f18b1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2024", + "module": "nodenext", + "lib": ["es2024"], + "types": ["node"], + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4d27725 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite-plus"; + +import projectLint from "./scripts/project-lint.ts"; + +export default defineConfig({ + // Keep package.json in its hand-written order. + fmt: { sortPackageJson: false }, + lint: { + plugins: ["typescript", "unicorn", "oxc", "import"], + jsPlugins: ["./scripts/project-lint.ts"], + categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, + rules: projectLint.configs.project.rules, + // Repo tooling, not app code: the lint plugin compares AST node `type`s, + // which the AST types declare as an enum. + overrides: [ + { files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } }, + ], + options: { + typeAware: true, + typeCheck: true, + maxWarnings: 0, + reportUnusedDisableDirectives: "error", + }, + }, +});